This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new c22e93b83 [api][runtime][java][python] Simplify agent output 
materialization with typed toDataStream/toTable (#1183)
c22e93b83 is described below

commit c22e93b835c4d251898a4a7ea71981215483f828
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Fri Oct 9 11:16:46 2026 +0800

    [api][runtime][java][python] Simplify agent output materialization with 
typed toDataStream/toTable (#1183)
    
    Generated-by: Qoder 1.32.1 (Qwen3.8-Max)
---
 .../org/apache/flink/agents/api/AgentBuilder.java  |  74 ++++-
 .../agents/api/AgentBuilderApplyByNameTest.java    |  21 ++
 .../docs/development/integrate_with_flink.md       |  78 +++--
 .../docs/get-started/quickstart/workflow_agent.md  |   6 +-
 .../integration/test/FlinkIntegrationAgent.java    |  34 +++
 .../integration/test/FlinkIntegrationTest.java     | 103 +++++++
 .../agents/integration/test/ReActAgentTest.java    |  11 +-
 .../integration/test/SkillsIntegrationTest.java    |  11 +-
 .../examples/WorkflowMultipleAgentExample.java     |  10 +-
 python/flink_agents/api/execution_environment.py   |  39 ++-
 .../quickstart/workflow_multiple_agent_example.py  |   6 +-
 python/flink_agents/runtime/output_type_utils.py   | 331 +++++++++++++++++++++
 .../runtime/remote_execution_environment.py        | 161 ++++++++--
 .../runtime/tests/output_type_fixtures.py          |  52 ++++
 .../runtime/tests/test_output_type_utils.py        | 297 ++++++++++++++++++
 .../tests/test_remote_execution_environment.py     | 214 +++++++++++++
 runtime/pom.xml                                    |   9 +
 .../flink/agents/runtime/OutputTypeUtils.java      | 317 ++++++++++++++++++++
 .../runtime/env/RemoteExecutionEnvironment.java    |  51 +++-
 .../flink/agents/runtime/OutputTypeUtilsTest.java  | 303 +++++++++++++++++++
 .../runtime/env/TypedOutputTerminalTest.java       | 176 +++++++++++
 21 files changed, 2216 insertions(+), 88 deletions(-)

diff --git a/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java 
b/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java
index 4aac8b5ee..362e88b1f 100644
--- a/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java
+++ b/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.api;
 
 import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.table.api.Schema;
 import org.apache.flink.table.api.Table;
@@ -72,20 +73,85 @@ public interface AgentBuilder {
      * Get output DataStream of agent execution.
      *
      * <p>This method converts the agent's output events into a Flink 
DataStream that can be further
-     * processed in the Flink pipeline.
+     * processed in the Flink pipeline. The returned view is unrestricted: its 
elements are whatever
+     * the agent emitted ({@code Object}). To obtain a typed view, declare the 
output type with
+     * {@link #toDataStream(TypeInformation)} or {@link #toDataStream(Class)}.
      *
      * @return DataStream containing outputs from agent execution.
      */
     DataStream<Object> toDataStream();
 
     /**
-     * Get output Table of agent execution.
+     * Get output DataStream of agent execution, typed as {@code T}.
      *
-     * <p>This method converts the agent's output events into a Flink Table 
using the provided
-     * schema for structure definition.
+     * <p>The agent operator keeps emitting {@code Object} on the shared raw 
stream and the declared
+     * type is applied by a downstream conversion operator, so the 
unrestricted {@link
+     * #toDataStream()} view is unaffected and heterogeneous output stays 
available through it. Each
+     * call is independent, so several typed views of different types can 
coexist on one execution.
+     *
+     * @param typeInformation the declared type of the agent's output elements.
+     * @param <T> the declared output element type.
+     * @return DataStream whose elements are typed as {@code T}.
+     */
+    <T> DataStream<T> toDataStream(TypeInformation<T> typeInformation);
+
+    /**
+     * Get output DataStream of agent execution, typed as {@code T}, deriving 
the {@link
+     * TypeInformation} from the given output class.
+     *
+     * <p>Convenience overload for the common case. Prefer {@link 
#toDataStream(TypeInformation)}
+     * for generic or custom types that a {@link Class} cannot express.
+     *
+     * @param outputType the declared class of the agent's output elements.
+     * @param <T> the declared output element type.
+     * @return DataStream whose elements are typed as {@code T}.
+     */
+    default <T> DataStream<T> toDataStream(Class<T> outputType) {
+        return toDataStream(TypeInformation.of(outputType));
+    }
+
+    /**
+     * Get output Table of agent execution, materializing the unrestricted 
view with an explicit
+     * physical schema.
+     *
+     * <p>The table's row type is derived from the schema's physical columns, 
and a downstream
+     * conversion operator adapts each agent output element into a row 
matching those columns by
+     * name. An element may be a row, a map keyed by column name, an object 
exposing each column
+     * through a getter or field, or a scalar for a single-column schema. 
Computed and metadata
+     * columns declared in the schema are derived by the planner rather than 
read from the agent
+     * output. To declare the output with a type instead of a physical schema, 
use {@link
+     * #toTable(TypeInformation)} or {@link #toTable(Class)}.
      *
      * @param schema Schema indicating the structure of the output table.
      * @return Table containing outputs from agent execution.
      */
     Table toTable(Schema schema);
+
+    /**
+     * Get output Table of agent execution, typed as {@code T}, deriving the 
physical schema from
+     * the declared type (for example, a POJO's fields become columns).
+     *
+     * <p>The declared type is applied by a downstream conversion operator on 
the shared raw stream,
+     * so the unrestricted {@link #toDataStream()} view is unaffected.
+     *
+     * @param typeInformation the declared type of the agent's output elements.
+     * @param <T> the declared output element type.
+     * @return Table containing the agent's output, typed as {@code T}.
+     */
+    <T> Table toTable(TypeInformation<T> typeInformation);
+
+    /**
+     * Get output Table of agent execution, typed as {@code T}, deriving the 
{@link TypeInformation}
+     * from the given output class.
+     *
+     * <p>Convenience overload for the common case. Prefer {@link 
#toTable(TypeInformation)} for
+     * generic or custom types that a {@link Class} cannot express.
+     *
+     * @param outputType the declared class of the agent's output elements.
+     * @param <T> the declared output element type.
+     * @return Table containing the agent's output, typed as {@code T}.
+     */
+    default <T> Table toTable(Class<T> outputType) {
+        return toTable(TypeInformation.of(outputType));
+    }
 }
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java
index 8e3d6d3fd..e4f739912 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.api;
 
 import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.table.api.Schema;
 import org.apache.flink.table.api.Table;
@@ -62,10 +63,20 @@ class AgentBuilderApplyByNameTest {
             return null;
         }
 
+        @Override
+        public <T> DataStream<T> toDataStream(TypeInformation<T> 
typeInformation) {
+            return null;
+        }
+
         @Override
         public Table toTable(Schema schema) {
             return null;
         }
+
+        @Override
+        public <T> Table toTable(TypeInformation<T> typeInformation) {
+            return null;
+        }
     }
 
     /** Stub builder that overrides apply(String) to resolve from a one-agent 
registry. */
@@ -104,9 +115,19 @@ class AgentBuilderApplyByNameTest {
             return null;
         }
 
+        @Override
+        public <T> DataStream<T> toDataStream(TypeInformation<T> 
typeInformation) {
+            return null;
+        }
+
         @Override
         public Table toTable(Schema schema) {
             return null;
         }
+
+        @Override
+        public <T> Table toTable(TypeInformation<T> typeInformation) {
+            return null;
+        }
     }
 }
diff --git a/docs/content/docs/development/integrate_with_flink.md 
b/docs/content/docs/development/integrate_with_flink.md
index dc93bfda4..c264baebd 100644
--- a/docs/content/docs/development/integrate_with_flink.md
+++ b/docs/content/docs/development/integrate_with_flink.md
@@ -161,7 +161,6 @@ Integrate the agent with input `Table`, and return the 
output `Table` can be con
 
 {{< tab "Python" >}}
 ```python
-from pyflink.common.typeinfo import BasicTypeInfo, ExternalTypeInfo, 
RowTypeInfo
 from pyflink.datastream import KeySelector
 from pyflink.table import DataTypes, Schema
 
@@ -179,19 +178,14 @@ input_table = t_env.from_elements(
     ["id", "input"],
 )
 
-# The output TypeInformation and Schema must be mutually consistent: both
-# describe a single "result" INT column here.
-output_type = ExternalTypeInfo(RowTypeInfo(
-    [BasicTypeInfo.INT_TYPE_INFO()],
-    ["result"],
-))
-
-schema = (Schema.new_builder().column("result", DataTypes.INT())).build()
+# A single output-type declaration: the Schema's physical columns give the
+# output row type, and each agent output element is adapted into a matching 
row.
+schema = Schema.new_builder().column("result", DataTypes.INT()).build()
 
 output_table = (
     agents_env.from_table(input=input_table, key_selector=MyKeySelector())
     .apply(your_agent)
-    .to_table(schema=schema, output_type=output_type)
+    .to_table(schema=schema)
 )
 ```
 {{< /tab >}}
@@ -225,11 +219,14 @@ Table inputTable =
                 Row.of(2, "Bob", 92.0),
                 Row.of(3, "Charlie", 78.3));
 
-// The agent output is exposed as a single anonymous column named "f0".
-// Declare "f0" with the agent's OUTPUT type: a scalar type (e.g. 
DataTypes.STRING())
-// when the agent emits a scalar value, or a nested DataTypes.ROW(...) only 
when the
-// agent emits a composite row.
-Schema outputSchema = Schema.newBuilder().column("f0", 
DataTypes.STRING()).build();
+// Declare the output columns. Each agent output element is adapted into a row 
by
+// matching columns by name (from a POJO's getters/public fields, a Row, or a 
Map);
+// a scalar output maps to a single-column schema.
+Schema outputSchema =
+        Schema.newBuilder()
+                .column("name", DataTypes.STRING())
+                .column("score", DataTypes.DOUBLE())
+                .build();
 
 Table outputTable =
         agentsEnv
@@ -244,18 +241,49 @@ Table outputTable =
 
 User should provide `KeySelector` in `from_table()` to tell how to convert the 
input `Table` to `KeyedStream` internally.
 
-The arguments required by `to_table()` differ by language:
+## Typed outputs
+
+`to_datastream()` / `toDataStream()` return an unrestricted stream whose 
elements are whatever the agent emitted (`Object` in Java). To give the output 
a concrete type, pass the type straight to the terminal: 
`to_datastream(output_type)` / `toDataStream(TypeInformation)` materialize a 
typed stream, and `to_table(output_type=...)` / `toTable(TypeInformation)` 
materialize a typed table. The unrestricted call stays available and unchanged:
+
+{{< tabs "Typed outputs" >}}
+
+{{< tab "Python" >}}
+```python
+builder = agents_env.from_datastream(input_stream, 
key_selector).apply(your_agent)
+
+raw = builder.to_datastream()   # unrestricted: whatever the agent emitted
+
+# Pass the output type to the terminal to materialize a typed stream or table.
+typed = builder.to_datastream(ReviewOutput)          # stream of validated 
ReviewOutput
+table = builder.to_table(output_type=ReviewOutput)   # schema derived from the 
type
+# Or pass both: the Schema drives the physical table and is cross-checked
+# against the declared type.
+both = builder.to_table(review_schema, ReviewOutput)
+```
+{{< /tab >}}
+
+{{< tab "Java" >}}
+```java
+AgentBuilder builder =
+        agentsEnv.fromDataStream(inputStream, keySelector).apply(yourAgent);
+
+DataStream<Object> raw = builder.toDataStream();   // unrestricted
+
+// Pass the output type to the terminal to materialize a typed stream or table.
+DataStream<ReviewOutput> typed = builder.toDataStream(ReviewOutput.class);
+Table fromType = builder.toTable(ReviewOutput.class);   // schema derived from 
the type
+```
+{{< /tab >}}
+
+{{< /tabs >}}
 
-- **Python**: provide both `Schema` and `TypeInformation` to define the output 
`Table` schema.
-- **Java**: provide only `Schema` (`toTable(Schema)`); `TypeInformation` is 
not required.
+For the `Table` terminal:
 
-The two languages also name the output columns differently:
+- Calling `to_table(output_type=...)` / `toTable(TypeInformation)` with no 
schema derives the physical columns from the declared type, so a POJO's fields 
(or a structured Python type's fields) become columns.
+- In Python, `to_table(schema, output_type)` accepts both: the `Schema` drives 
the physical table, preserving Table-domain information such as a primary key, 
computed columns, metadata columns, or a watermark, and is cross-checked 
against the declared type, raising if the two describe different row types. At 
least one of the two is required. In Java the schema-only `toTable(Schema)` and 
the type-only `toTable(TypeInformation)` are separate overloads.
 
-- **Python**: the `TypeInformation` passed to `to_table()` is a `RowTypeInfo` 
whose field names become the output columns, so you name them directly (the 
`"result"` column above matches `RowTypeInfo([...], ["result"])`).
-- **Java**: `toTable(Schema)` exposes the agent output as a single anonymous 
column named `f0` (internally it calls 
`StreamTableEnvironment.fromDataStream(DataStream<Object>, schema)`), so the 
`Schema` must reference `f0` — wrap it in a `ROW(...)` only when the agent 
emits a composite row.
+In Java, `toDataStream(Class)` / `toTable(Class)` are conveniences that derive 
the `TypeInformation` from the class; the `TypeInformation` overloads remain 
for generic or custom types.
 
-{{< hint info >}}
-In Python, `to_table()` currently requires both `Schema` and 
`TypeInformation`; we plan to support providing only one of them in the future.
-{{< /hint >}}
+The schema-only `to_table(schema=...)` / `toTable(Schema)` stays available 
when you have a physical schema but no output type to declare.
 
-For complete, runnable examples, see 
[`WorkflowMultipleAgentExample.java`](https://github.com/apache/flink-agents/blob/main/examples/src/main/java/org/apache/flink/agents/examples/WorkflowMultipleAgentExample.java)
 (Java) and 
[`workflow_multiple_agent_example.py`](https://github.com/apache/flink-agents/blob/main/python/flink_agents/examples/quickstart/workflow_multiple_agent_example.py)
 (Python).
+Each agent output element is adapted to the declared row type by matching 
columns by name — from a POJO's getters or public fields, a `Row`, or a `Map`. 
A scalar output maps to a single-column row.
diff --git a/docs/content/docs/get-started/quickstart/workflow_agent.md 
b/docs/content/docs/get-started/quickstart/workflow_agent.md
index 4e844fa88..d71ca7ff9 100644
--- a/docs/content/docs/get-started/quickstart/workflow_agent.md
+++ b/docs/content/docs/get-started/quickstart/workflow_agent.md
@@ -414,7 +414,7 @@ input_table = t_env.from_path("product_reviews")
 review_analysis_res_stream = (
     agents_env.from_table(input=input_table, key_selector=TableKeySelector())
     .apply(TableReviewAnalysisAgent())
-    .to_datastream()
+    .to_datastream(ProductReviewAnalysisRes)
 )
 ```
 {{< /tab >}}
@@ -423,11 +423,11 @@ review_analysis_res_stream = (
 ```java
 Table inputTable = tableEnv.from("product_reviews");
 
-DataStream<Object> reviewAnalysisResStream =
+DataStream<ProductReviewAnalysisRes> reviewAnalysisResStream =
         agentsEnv
                 .fromTable(inputTable, new 
TableReviewAnalysisAgent.RowKeySelector())
                 .apply(new TableReviewAnalysisAgent())
-                .toDataStream();
+                .toDataStream(ProductReviewAnalysisRes.class);
 ```
 {{< /tab >}}
 
diff --git 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
index bacb4aa0c..e0c934df3 100644
--- 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
+++ 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
@@ -187,4 +187,38 @@ public class FlinkIntegrationAgent {
             ctx.sendEvent(new OutputEvent(output));
         }
     }
+
+    /** A structured output type used to exercise the typed output terminals. 
*/
+    public static class TypedOutput {
+        public int value;
+        public String label;
+
+        public TypedOutput() {}
+
+        public TypedOutput(int value, String label) {
+            this.value = value;
+            this.label = label;
+        }
+
+        @Override
+        public String toString() {
+            return "TypedOutput{value=" + value + ", label='" + label + "'}";
+        }
+    }
+
+    /** Agent emitting a structured {@link TypedOutput} derived from each 
integer input. */
+    public static class TypedOutputAgent extends Agent {
+        /**
+         * Turns each integer input into a deterministic structured output.
+         *
+         * @param event the input event carrying an integer id.
+         * @param ctx the runner context for sending the output event.
+         */
+        @Action(EventType.InputEvent)
+        public static void processInput(Event event, RunnerContext ctx) throws 
Exception {
+            InputEvent inputEvent = InputEvent.fromEvent(event);
+            int id = (Integer) inputEvent.getInput();
+            ctx.sendEvent(new OutputEvent(new TypedOutput(id * 10, "item-" + 
id)));
+        }
+    }
 }
diff --git 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationTest.java
 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationTest.java
index 11c677ac2..d7b34970a 100644
--- 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationTest.java
+++ 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationTest.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.AgentsExecutionEnvironment;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.functions.KeySelector;
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
@@ -181,6 +182,108 @@ public class FlinkIntegrationTest {
         checkResult(results, "test_from_datastream_to_table.txt");
     }
 
+    @Test
+    public void testToDataStreamWithTypeInformation() throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(1);
+
+        DataStream<Integer> inputStream = env.fromData(1, 2, 3);
+        AgentsExecutionEnvironment agentsEnv =
+                AgentsExecutionEnvironment.getExecutionEnvironment(env);
+
+        DataStream<FlinkIntegrationAgent.TypedOutput> outputStream =
+                agentsEnv
+                        .fromDataStream(inputStream)
+                        .apply(new FlinkIntegrationAgent.TypedOutputAgent())
+                        
.toDataStream(TypeInformation.of(FlinkIntegrationAgent.TypedOutput.class));
+
+        CloseableIterator<FlinkIntegrationAgent.TypedOutput> results = 
outputStream.collectAsync();
+        agentsEnv.execute();
+
+        List<String> actual = new ArrayList<>();
+        while (results.hasNext()) {
+            actual.add(results.next().toString());
+        }
+        actual.sort(Comparator.naturalOrder());
+
+        List<String> expected =
+                new ArrayList<>(
+                        List.of(
+                                "TypedOutput{value=10, label='item-1'}",
+                                "TypedOutput{value=20, label='item-2'}",
+                                "TypedOutput{value=30, label='item-3'}"));
+        expected.sort(Comparator.naturalOrder());
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testToTableWithTypeInformation() throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(1);
+
+        DataStream<Integer> inputStream = env.fromData(1, 2, 3);
+        AgentsExecutionEnvironment agentsEnv =
+                AgentsExecutionEnvironment.getExecutionEnvironment(env);
+
+        Table outputTable =
+                agentsEnv
+                        .fromDataStream(inputStream)
+                        .apply(new FlinkIntegrationAgent.TypedOutputAgent())
+                        
.toTable(TypeInformation.of(FlinkIntegrationAgent.TypedOutput.class));
+
+        // The schema is derived from the POJO type; locate columns by name.
+        List<String> columns = 
outputTable.getResolvedSchema().getColumnNames();
+        int valueIdx = columns.indexOf("value");
+        int labelIdx = columns.indexOf("label");
+        Assertions.assertTrue(valueIdx >= 0 && labelIdx >= 0, "expected value 
and label columns");
+
+        List<String> actual = collectValueLabel(outputTable, valueIdx, 
labelIdx);
+        Assertions.assertEquals(List.of("10:item-1", "20:item-2", 
"30:item-3"), actual);
+    }
+
+    @Test
+    public void testToTableWithMultiColumnSchema() throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(1);
+
+        DataStream<Integer> inputStream = env.fromData(1, 2, 3);
+        AgentsExecutionEnvironment agentsEnv =
+                AgentsExecutionEnvironment.getExecutionEnvironment(env);
+
+        // A POJO output materialized into the named columns declared by the 
schema.
+        Schema outputSchema =
+                Schema.newBuilder()
+                        .column("value", DataTypes.INT())
+                        .column("label", DataTypes.STRING())
+                        .build();
+
+        Table outputTable =
+                agentsEnv
+                        .fromDataStream(inputStream)
+                        .apply(new FlinkIntegrationAgent.TypedOutputAgent())
+                        .toTable(outputSchema);
+
+        Assertions.assertEquals(
+                List.of("value", "label"), 
outputTable.getResolvedSchema().getColumnNames());
+
+        List<String> actual = collectValueLabel(outputTable, 0, 1);
+        Assertions.assertEquals(List.of("10:item-1", "20:item-2", 
"30:item-3"), actual);
+    }
+
+    private List<String> collectValueLabel(Table table, int valueIdx, int 
labelIdx)
+            throws Exception {
+        List<String> actual = new ArrayList<>();
+        try (CloseableIterator<Row> results = table.execute().collect()) {
+            while (results.hasNext()) {
+                Row row = results.next();
+                actual.add(row.getField(valueIdx) + ":" + 
row.getField(labelIdx));
+            }
+        }
+        actual.sort(Comparator.naturalOrder());
+        return actual;
+    }
+
     private void checkResult(CloseableIterator<?> results, String fileName) 
throws IOException {
         String path =
                 Objects.requireNonNull(
diff --git 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ReActAgentTest.java
 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ReActAgentTest.java
index e17f970bf..459ad37a1 100644
--- 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ReActAgentTest.java
+++ 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ReActAgentTest.java
@@ -29,7 +29,6 @@ import 
org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.resource.ResourceName;
 import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.api.tools.Tool;
-import org.apache.flink.api.common.functions.MapFunction;
 import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.functions.KeySelector;
@@ -126,10 +125,7 @@ public class ReActAgentTest {
                         Row.of(2131, 29847, 3));
 
         // Define output schema
-        Schema outputSchema =
-                Schema.newBuilder()
-                        .column("f0", DataTypes.ROW(DataTypes.FIELD("result", 
DataTypes.DOUBLE())))
-                        .build();
+        Schema outputSchema = Schema.newBuilder().column("result", 
DataTypes.DOUBLE()).build();
 
         // Apply agent to the Table
         Table outputTable =
@@ -142,10 +138,7 @@ public class ReActAgentTest {
                         .toTable(outputSchema);
 
         // Collect the results to fully display the data
-        CloseableIterator<Row> results =
-                tableEnv.toDataStream(outputTable)
-                        .map((MapFunction<Row, Row>) x -> (Row) 
x.getField("f0"))
-                        .collectAsync();
+        CloseableIterator<Row> results = 
tableEnv.toDataStream(outputTable).collectAsync();
 
         env.execute();
 
diff --git 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java
 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java
index db5fbdd8e..b67e7aeed 100644
--- 
a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java
+++ 
b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java
@@ -28,7 +28,6 @@ import 
org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.resource.ResourceName;
 import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.api.skills.Skills;
-import org.apache.flink.api.common.functions.MapFunction;
 import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.functions.KeySelector;
@@ -198,10 +197,7 @@ public class SkillsIntegrationTest {
                                 DataTypes.FIELD("b", DataTypes.INT())),
                         Row.of(2, 3));
 
-        Schema outputSchema =
-                Schema.newBuilder()
-                        .column("f0", DataTypes.ROW(DataTypes.FIELD("result", 
DataTypes.INT())))
-                        .build();
+        Schema outputSchema = Schema.newBuilder().column("result", 
DataTypes.INT()).build();
 
         Table outputTable =
                 agentsEnv
@@ -212,10 +208,7 @@ public class SkillsIntegrationTest {
                         .apply(agent)
                         .toTable(outputSchema);
 
-        CloseableIterator<Row> results =
-                tableEnv.toDataStream(outputTable)
-                        .map((MapFunction<Row, Row>) x -> (Row) 
x.getField("f0"))
-                        .collectAsync();
+        CloseableIterator<Row> results = 
tableEnv.toDataStream(outputTable).collectAsync();
 
         env.execute();
 
diff --git 
a/examples/src/main/java/org/apache/flink/agents/examples/WorkflowMultipleAgentExample.java
 
b/examples/src/main/java/org/apache/flink/agents/examples/WorkflowMultipleAgentExample.java
index 17ee75892..7218faa2d 100644
--- 
a/examples/src/main/java/org/apache/flink/agents/examples/WorkflowMultipleAgentExample.java
+++ 
b/examples/src/main/java/org/apache/flink/agents/examples/WorkflowMultipleAgentExample.java
@@ -166,19 +166,21 @@ public class WorkflowMultipleAgentExample {
         Table inputTable = tableEnv.from("product_reviews");
 
         // Use the TableReviewAnalysisAgent (LLM) to analyze each review.
-        // The agent extracts the review score and unsatisfied reasons.
-        DataStream<Object> reviewAnalysisResStream =
+        // The agent extracts the review score and unsatisfied reasons. 
Declaring the
+        // output type via toDataStream(ProductReviewAnalysisRes.class) yields 
a typed
+        // stream directly, so the result is already a
+        // DataStream<ProductReviewAnalysisRes> and needs no manual downcast 
below.
+        DataStream<ProductReviewAnalysisRes> reviewAnalysisResStream =
                 agentsEnv
                         .fromTable(inputTable, new 
TableReviewAnalysisAgent.RowKeySelector())
                         .apply(new TableReviewAnalysisAgent())
-                        .toDataStream();
+                        .toDataStream(ProductReviewAnalysisRes.class);
 
         // Aggregate the analysis results in 1-minute tumbling windows.
         // This produces a score distribution and collects all unsatisfied 
reasons for
         // each product.
         DataStream<String> aggregatedAnalysisResStream =
                 reviewAnalysisResStream
-                        .map(element -> (ProductReviewAnalysisRes) element)
                         .keyBy(ProductReviewAnalysisRes::getId)
                         
.window(TumblingProcessingTimeWindows.of(Duration.ofMinutes(1)))
                         .process(new 
AggregateScoreDistributionAndDislikeReasons());
diff --git a/python/flink_agents/api/execution_environment.py 
b/python/flink_agents/api/execution_environment.py
index 02ba083db..d368fd9a6 100644
--- a/python/flink_agents/api/execution_environment.py
+++ b/python/flink_agents/api/execution_environment.py
@@ -23,7 +23,6 @@ if TYPE_CHECKING:
     from pathlib import Path
 
 from importlib_resources import files
-from pyflink.common import TypeInformation
 from pyflink.datastream import DataStream, KeySelector, 
StreamExecutionEnvironment
 from pyflink.table import Schema, StreamTableEnvironment, Table
 
@@ -53,8 +52,22 @@ class AgentBuilder(ABC):
         """
 
     @abstractmethod
-    def to_datastream(self) -> DataStream:
-        """Get output datastream of agent execution.
+    def to_datastream(self, output_type: Any = None) -> DataStream:
+        """Get the output datastream of agent execution.
+
+        Without ``output_type`` the returned view is unrestricted: it carries
+        whatever the agent emitted, with no output-type declaration. Passing
+        ``output_type`` layers a downstream conversion operator that 
materializes
+        each element as that declared type, leaving the unrestricted view
+        unaffected, so several typed calls of different types can coexist on 
one
+        execution.
+
+        Parameters
+        ----------
+        output_type : Any
+            Optional output-type declaration -- a Pydantic model / dataclass /
+            named tuple / ``TypedDict`` / ``RowTypeInfo``, or an explicit
+            ``TypeInformation``. When omitted, the unrestricted view is 
returned.
 
         Returns:
         -------
@@ -62,17 +75,25 @@ class AgentBuilder(ABC):
             Output datastream of agent execution.
         """
 
-    # TODO: auto generate output_type.
     @abstractmethod
-    def to_table(self, schema: Schema, output_type: TypeInformation) -> Table:
+    def to_table(self, schema: Schema | None = None, output_type: Any = None) 
-> Table:
         """Get output table of agent execution.
 
+        At least one of ``schema`` or ``output_type`` must be given. A 
``schema``
+        fixes the physical columns and may carry Table-domain information such 
as
+        a primary key, computed columns, or a watermark; an ``output_type``
+        derives the physical schema from the declared type (for example a 
POJO's
+        fields become columns). Passing both cross-checks that they describe 
the
+        same row type.
+
         Parameters
         ----------
-        schema : Schema
-            Indicate schema of the output table.
-        output_type : TypeInformation
-            Indicate schema corresponding type information.
+        schema : Schema | None
+            A Table ``Schema`` whose physical columns give the row type.
+        output_type : Any
+            The output-type declaration -- a Pydantic model / dataclass / named
+            tuple / ``TypedDict`` / ``RowTypeInfo``, or an explicit
+            ``TypeInformation``.
 
         Returns:
         -------
diff --git 
a/python/flink_agents/examples/quickstart/workflow_multiple_agent_example.py 
b/python/flink_agents/examples/quickstart/workflow_multiple_agent_example.py
index fafa5c30c..b9bc3e6d5 100644
--- a/python/flink_agents/examples/quickstart/workflow_multiple_agent_example.py
+++ b/python/flink_agents/examples/quickstart/workflow_multiple_agent_example.py
@@ -142,11 +142,13 @@ def main() -> None:
     )
 
     # Use the TableReviewAnalysisAgent (LLM) to analyze each review.
-    # The agent extracts the review score and unsatisfied reasons.
+    # The agent extracts the review score and unsatisfied reasons. Declaring 
the
+    # output type with to_datastream(ProductReviewAnalysisRes) yields a typed
+    # stream, so downstream operators receive validated instances.
     review_analysis_res_stream = (
         agents_env.from_table(input=input_table, 
key_selector=TableKeySelector())
         .apply(TableReviewAnalysisAgent())
-        .to_datastream()
+        .to_datastream(ProductReviewAnalysisRes)
     )
 
     # Aggregate the analysis results in 1-minute tumbling windows.
diff --git a/python/flink_agents/runtime/output_type_utils.py 
b/python/flink_agents/runtime/output_type_utils.py
new file mode 100644
index 000000000..0e08ddbe0
--- /dev/null
+++ b/python/flink_agents/runtime/output_type_utils.py
@@ -0,0 +1,331 @@
+################################################################################
+#  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.
+#################################################################################
+"""Output-type inference and conversion helpers for typed agent terminals.
+
+An agent output terminal (``to_datastream`` / ``to_table``) accepts at most one
+type declaration. This module normalizes the accepted declarations -- a 
Pydantic
+model, dataclass, named tuple, ``TypedDict``, an explicit ``RowTypeInfo``, or a
+Table ``Schema`` -- into the Flink type metadata each terminal needs, converts 
an
+emitted value to a physical ``Row`` for the Table/Row serializer, and
+reconstructs declared instances for the typed ``to_datastream`` operator.
+
+``Schema`` and ``RowTypeInfo`` are converted into each other through Flink's 
own
+``TypeConversions`` over the py4j gateway, so the derivation covers the full 
type
+system (nested ``ROW``, arrays, temporal types) instead of a fixed scalar 
subset.
+Only inference from a bare Python annotation (``infer_row_type_info``) stays
+limited to common scalars, because a Python type carries no Flink type on its
+own.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from typing import Any, get_type_hints, is_typeddict
+
+from pydantic import BaseModel
+from pyflink.common import Row
+from pyflink.common.typeinfo import (
+    ExternalTypeInfo,
+    RowTypeInfo,
+    TypeInformation,
+    Types,
+    _from_java_type,
+)
+from pyflink.java_gateway import get_gateway
+from pyflink.table import Schema
+from pyflink.table.types import _from_java_data_type
+
+__all__ = [
+    "infer_row_type_info",
+    "is_structured_declaration",
+    "reconstruct_instance",
+    "resolve_row_type_info",
+    "row_shape",
+    "row_type_info_to_schema",
+    "schema_to_row_type_info",
+    "to_row",
+]
+
+# python scalar type -> DataStream/Table TypeInformation. A bare Python
+# annotation carries no Flink type, so inference from a structured declaration
+# is limited to these common scalars; the py4j-backed Schema <-> RowTypeInfo
+# derivation below covers the full type system.
+_PY_TO_TYPEINFO: dict[type, TypeInformation] = {
+    str: Types.STRING(),
+    int: Types.LONG(),
+    float: Types.DOUBLE(),
+    bool: Types.BOOLEAN(),
+}
+
+
+def _is_named_tuple(cls: Any) -> bool:
+    """Whether ``cls`` is a ``NamedTuple`` class (a tuple subclass with 
fields)."""
+    return isinstance(cls, type) and issubclass(cls, tuple) and hasattr(cls, 
"_fields")
+
+
+def _field_types(cls: Any) -> list[tuple[str, Any]]:
+    """Return ordered ``(field_name, python_type)`` pairs for a structured 
type.
+
+    Supports Pydantic models, dataclasses, named tuples and ``TypedDict``.
+    """
+    if isinstance(cls, type) and issubclass(cls, BaseModel):
+        return [(name, field.annotation) for name, field in 
cls.model_fields.items()]
+    if isinstance(cls, type) and dataclasses.is_dataclass(cls):
+        hints = get_type_hints(cls)
+        return [(f.name, hints[f.name]) for f in dataclasses.fields(cls)]
+    if _is_named_tuple(cls):
+        hints = get_type_hints(cls)
+        return [(name, hints[name]) for name in cls._fields]
+    if is_typeddict(cls):
+        return list(get_type_hints(cls).items())
+    msg = (
+        f"cannot infer output fields from {cls!r}; expected a Pydantic model, "
+        "dataclass, named tuple, or TypedDict"
+    )
+    raise TypeError(msg)
+
+
+def _typeinfo_of(py_type: Any) -> TypeInformation:
+    if py_type not in _PY_TO_TYPEINFO:
+        supported = ", ".join(sorted(t.__name__ for t in _PY_TO_TYPEINFO))
+        msg = f"unsupported output field type {py_type!r}; supported types: 
{supported}"
+        raise TypeError(msg)
+    return _PY_TO_TYPEINFO[py_type]
+
+
+def is_structured_declaration(decl: Any) -> bool:
+    """Whether ``decl`` is a python structured type whose fields can be 
inferred."""
+    if isinstance(decl, TypeInformation | Schema):
+        return False
+    if not isinstance(decl, type):
+        return False
+    try:
+        _field_types(decl)
+    except TypeError:
+        return False
+    return True
+
+
+def infer_row_type_info(cls: Any) -> RowTypeInfo:
+    """Infer a ``RowTypeInfo`` from a Pydantic/dataclass/named 
tuple/TypedDict."""
+    fields = _field_types(cls)
+    return RowTypeInfo(
+        [_typeinfo_of(py_type) for _, py_type in fields],
+        [name for name, _ in fields],
+    )
+
+
+def _unwrap_type_info(decl: Any) -> Any:
+    """Unwrap an ``ExternalTypeInfo`` to its inner ``TypeInformation``.
+
+    A Table terminal accepts an ``ExternalTypeInfo``-wrapped ``RowTypeInfo`` 
(the
+    form its Row serializer needs); resolution and canonicalization compare the
+    inner row type, so the wrapper is transparent to declaration equality.
+    """
+    if isinstance(decl, ExternalTypeInfo):
+        return decl._type_info
+    return decl
+
+
+def resolve_row_type_info(decl: Any) -> RowTypeInfo:
+    """Normalize a Table output declaration to a ``RowTypeInfo``.
+
+    Accepts an existing ``RowTypeInfo`` (optionally 
``ExternalTypeInfo``-wrapped,
+    passed through) or a structured python type (inferred). A non-row
+    ``TypeInformation`` is rejected because a Table terminal needs a row-shaped
+    output.
+    """
+    decl = _unwrap_type_info(decl)
+    if isinstance(decl, RowTypeInfo):
+        return decl
+    if isinstance(decl, TypeInformation):
+        msg = (
+            "to_table requires a row-shaped output type, but got a non-row "
+            f"TypeInformation {decl!r}; declare a Pydantic model / dataclass / 
"
+            "named tuple / TypedDict, or a RowTypeInfo"
+        )
+        raise TypeError(msg)
+    if is_structured_declaration(decl):
+        return infer_row_type_info(decl)
+    msg = f"cannot resolve {decl!r} to a row type for Table output"
+    raise TypeError(msg)
+
+
+def row_type_info_to_schema(typeinfo: RowTypeInfo) -> Schema:
+    """Derive a physical Table ``Schema`` from a ``RowTypeInfo``.
+
+    Delegates to Flink's ``TypeConversions.fromLegacyInfoToDataType`` over the
+    py4j gateway, so nested ``ROW`` and non-scalar fields round-trip instead of
+    being rejected.
+    """
+    gateway = get_gateway()
+    type_conversions = 
gateway.jvm.org.apache.flink.table.types.utils.TypeConversions
+    j_data_type = type_conversions.fromLegacyInfoToDataType(
+        typeinfo.get_java_type_info()
+    )
+    row_data_type = _from_java_data_type(j_data_type)
+    return Schema.new_builder().from_row_data_type(row_data_type).build()
+
+
+def _resolve_data_type(abstract_type: Any) -> Any:
+    """Resolve a schema column's ``AbstractDataType`` to a concrete 
``DataType``.
+
+    A column declared with a ``DataTypes`` object is already a resolved
+    ``DataType`` and is returned as-is. A column declared with a SQL type 
string
+    (for example ``column("id", "BIGINT")``) is an ``UnresolvedDataType`` that
+    ``TypeConversions.fromDataTypeToLegacyInfo`` rejects, so its SQL text is
+    parsed through Flink's ``LogicalTypeParser`` first -- the same resolution
+    PyFlink applies when the schema is handed to ``from_data_stream``.
+    """
+    gateway = get_gateway()
+    data_type_class = abstract_type.getClass().forName(
+        "org.apache.flink.table.types.DataType"
+    )
+    if data_type_class.isInstance(abstract_type):
+        return abstract_type
+    sql_type = abstract_type.toString()
+    if sql_type.startswith("[") and sql_type.endswith("]"):
+        sql_type = sql_type[1:-1]
+    type_conversions = 
gateway.jvm.org.apache.flink.table.types.utils.TypeConversions
+    logical_type_parser = (
+        
gateway.jvm.org.apache.flink.table.types.logical.utils.LogicalTypeParser
+    )
+    class_loader = 
gateway.jvm.java.lang.Thread.currentThread().getContextClassLoader()
+    logical_type = logical_type_parser.parse(sql_type, class_loader)
+    return type_conversions.fromLogicalToDataType(logical_type)
+
+
+def schema_to_row_type_info(schema: Schema) -> RowTypeInfo:
+    """Derive a ``RowTypeInfo`` from the physical columns of a Table 
``Schema``.
+
+    Each column type is resolved to a concrete ``DataType`` -- a SQL type 
string
+    such as ``"BIGINT"`` is parsed first -- then converted to a legacy
+    ``TypeInformation`` through Flink's ``fromDataTypeToLegacyInfo``. The
+    assembled Java ``RowTypeInfo`` is mapped back by PyFlink's
+    ``_from_java_type``, so nested ``ROW``, array, and temporal columns are
+    preserved.
+    """
+    gateway = get_gateway()
+    type_conversions = 
gateway.jvm.org.apache.flink.table.types.utils.TypeConversions
+    j_row_type_info = 
gateway.jvm.org.apache.flink.api.java.typeutils.RowTypeInfo
+    j_type_info = 
gateway.jvm.org.apache.flink.api.common.typeinfo.TypeInformation
+    j_string = gateway.jvm.java.lang.String
+
+    # Only physical columns feed the DataStream Row; computed and metadata
+    # columns are derived by the Table planner rather than read from the agent
+    # output, mirroring the Java toTable(Schema) conversion.
+    columns = [
+        column
+        for column in schema._j_schema.getColumns()
+        if column.getClass().getSimpleName() == "UnresolvedPhysicalColumn"
+    ]
+    j_types = gateway.new_array(j_type_info, len(columns))
+    j_names = gateway.new_array(j_string, len(columns))
+    for i, column in enumerate(columns):
+        data_type = _resolve_data_type(column.getDataType())
+        j_types[i] = type_conversions.fromDataTypeToLegacyInfo(data_type)
+        j_names[i] = column.getName()
+    return _from_java_type(j_row_type_info(j_types, j_names))
+
+
+def row_shape(row_type_info: RowTypeInfo) -> tuple:
+    """Picklable nested field-name shape of a ``RowTypeInfo``.
+
+    The result is ``(names, children)`` where ``children[i]`` is the shape of a
+    nested ``RowTypeInfo`` field and ``None`` otherwise. It carries only the 
row
+    nesting that value conversion needs, so it can be captured in a worker
+    closure; a ``RowTypeInfo`` itself holds py4j objects once its Java type has
+    been materialized and cannot be pickled into the closure.
+    """
+    names = list(row_type_info.get_field_names())
+    children = [
+        row_shape(field_type) if isinstance(field_type, RowTypeInfo) else None
+        for field_type in row_type_info.get_field_types()
+    ]
+    return (names, children)
+
+
+def to_row(value: Any, shape: tuple) -> Any:
+    """Adapt an emitted value to a physical ``Row`` using a :func:`row_shape`.
+
+    The raw agent output crosses a JSON boundary, so a structured value arrives
+    as a dict (a Pydantic model / dataclass degraded to its fields) or, for a
+    ``NamedTuple``, as a positional array; a value the agent emitted as a 
``Row``
+    is reconstructed as one. Only fields the shape marks as nested rows are
+    converted recursively; every other value -- scalar, map, or list -- is left
+    untouched for its own coder, so no column kind is silently reshaped. This 
is
+    the payload of the Table/Row conversion operator.
+    """
+    if isinstance(value, Row):
+        return value
+    names, children = shape
+    if isinstance(value, dict):
+        fields = [
+            to_row(value.get(name), child) if child is not None else 
value.get(name)
+            for name, child in zip(names, children, strict=False)
+        ]
+        return Row(*fields)
+    if isinstance(value, list | tuple) and len(value) == len(names):
+        # A NamedTuple degrades to a positional array across the OutputEvent 
JSON
+        # boundary (mirroring reconstruct_instance), so rebuild the Row by 
field
+        # order. A length mismatch means the sequence is one column's own value
+        # (for example an ARRAY), which is left untouched for its coder.
+        fields = [
+            to_row(item, child) if child is not None else item
+            for item, child in zip(value, children, strict=False)
+        ]
+        return Row(*fields)
+    return value
+
+
+def reconstruct_instance(cls: Any, data: Any) -> Any:
+    """Rebuild an instance of a structured declaration from its emitted form.
+
+    This is the payload of the typed ``to_datastream`` conversion operator. A
+    Pydantic model / dataclass degrades to a dict across the JSON output 
boundary
+    (only ``Row`` is reconstructed there) while a ``NamedTuple`` degrades to a
+    positional array, so the declared type is restored here; a ``Row`` is 
adapted
+    through its field mapping, a ``TypedDict`` declaration is already satisfied
+    by a dict, and a value that is already an instance is passed through.
+    """
+    if isinstance(data, Row):
+        data = data.as_dict()
+    if is_typeddict(cls):
+        if not isinstance(data, dict):
+            msg = f"cannot reconstruct TypedDict {cls!r} from 
{type(data).__name__}"
+            raise TypeError(msg)
+        return data
+    if isinstance(cls, type) and isinstance(data, cls):
+        return data
+    if _is_named_tuple(cls) and isinstance(data, list | tuple):
+        # A NamedTuple crosses the OutputEvent JSON boundary as a positional
+        # array, so rebuild it by field order instead of by name.
+        return cls(*data)
+    if not isinstance(data, dict):
+        msg = (
+            f"cannot reconstruct {cls!r} from {type(data).__name__}; expected 
a "
+            "mapping of field names to values"
+        )
+        raise TypeError(msg)
+    if isinstance(cls, type) and issubclass(cls, BaseModel):
+        return cls.model_validate(data)
+    try:
+        return cls(**data)
+    except TypeError as error:
+        msg = f"cannot reconstruct {cls!r} from fields {sorted(data)}: {error}"
+        raise TypeError(msg) from error
diff --git a/python/flink_agents/runtime/remote_execution_environment.py 
b/python/flink_agents/runtime/remote_execution_environment.py
index a5d1515b2..d26c14e5f 100644
--- a/python/flink_agents/runtime/remote_execution_environment.py
+++ b/python/flink_agents/runtime/remote_execution_environment.py
@@ -21,9 +21,10 @@ from pathlib import Path
 from typing import Any, Callable, Dict
 
 import cloudpickle
-from pyflink.common import TypeInformation
 from pyflink.common.typeinfo import (
+    ExternalTypeInfo,
     PickledBytesTypeInfo,
+    RowTypeInfo,
 )
 from pyflink.datastream import (
     DataStream,
@@ -42,6 +43,16 @@ from flink_agents.api.execution_environment import (
 from flink_agents.api.resource import ResourceType
 from flink_agents.plan.agent_plan import AgentPlan
 from flink_agents.plan.configuration import AgentConfiguration
+from flink_agents.runtime.output_type_utils import (
+    _unwrap_type_info,
+    is_structured_declaration,
+    reconstruct_instance,
+    resolve_row_type_info,
+    row_shape,
+    row_type_info_to_schema,
+    schema_to_row_type_info,
+    to_row,
+)
 
 _CONFIG_FILE_NAME = "config.yaml"
 _LEGACY_CONFIG_FILE_NAME = "flink-conf.yaml"
@@ -49,12 +60,23 @@ _AGENT_PLAN_JSON_VALIDATOR_CLASS = 
"org.apache.flink.agents.plan.AgentPlanJsonVa
 _AGENT_PLAN_JSON_VALIDATOR_METHOD = "validateAgentPlan"
 
 
+def _row_types_equal(left: RowTypeInfo, right: RowTypeInfo) -> bool:
+    """Whether two row types describe the same fields (names and types).
+
+    Java ``RowTypeInfo.equals`` ignores field names, so names are compared
+    separately while the Java check covers the (possibly nested) field types.
+    """
+    return list(left.get_field_names()) == list(right.get_field_names()) and 
bool(
+        left.get_java_type_info().equals(right.get_java_type_info())
+    )
+
+
 class RemoteAgentBuilder(AgentBuilder):
     """RemoteAgentBuilder for integrating datastream/table and agent."""
 
     __input: DataStream
     __agent_plan_json: str | None = None
-    __output: DataStream = None
+    __raw_output: DataStream = None
     __t_env: StreamTableEnvironment
     __config: AgentConfiguration
     __resources: Dict[ResourceType, Dict[str, Any]] = None
@@ -136,20 +158,19 @@ class RemoteAgentBuilder(AgentBuilder):
         if error_message is not None:
             raise ValueError(error_message)
 
-    def to_datastream(self, output_type: TypeInformation | None = None) -> 
DataStream:
-        """Get output datastream of agent execution.
+    def _raw_output_stream(self) -> DataStream:
+        """Return the shared, untyped agent output stream (pickled bytes).
 
-        Returns:
-        -------
-        DataStream
-            Output datastream of agent execution.
+        Every terminal layers its own conversion operator on this single cached
+        stream, so requesting a typed view never re-runs the agent operator and
+        never changes the element type of the unrestricted view. Caching here, 
at
+        the untyped boundary, is what removes the previous bug where the first
+        requested ``output_type`` was silently reused by every later terminal.
         """
         if self.__agent_plan_json is None:
             err_msg = "Must apply agent before call to_datastream/to_table."
             raise RuntimeError(err_msg)
-
-        # return the same output datastream when call to_datastream multiple.
-        if self.__output is None:
+        if self.__raw_output is None:
             j_data_stream_output = invoke_method(
                 None,
                 "org.apache.flink.agents.runtime.CompileUtils",
@@ -163,28 +184,126 @@ class RemoteAgentBuilder(AgentBuilder):
                     "java.lang.String",
                 ],
             )
-            output_stream = DataStream(j_data_stream_output)
-            self.__output = output_stream.map(
-                lambda x: cloudpickle.loads(x), output_type=output_type
+            self.__raw_output = DataStream(j_data_stream_output)
+        return self.__raw_output
+
+    def _row_stream(self, row_type_info: RowTypeInfo) -> DataStream:
+        """Conversion operator adapting the raw output to physical ``Row``s.
+
+        Only the picklable nested field-name shape is captured in the closure;
+        the py4j-backed ``RowTypeInfo`` stays on the driver and is handed to 
the
+        operator as ``ExternalTypeInfo`` for the Table/Row serializer.
+        """
+        raw = self._raw_output_stream()
+        shape = row_shape(row_type_info)
+        return raw.map(
+            lambda b: to_row(cloudpickle.loads(b), shape),
+            output_type=ExternalTypeInfo(row_type_info),
+        )
+
+    def to_datastream(self, output_type: Any = None) -> DataStream:
+        """Get the output datastream of agent execution.
+
+        The typed view is a downstream conversion operator on the shared, 
cached
+        raw stream, so it never re-runs the agent operator nor changes the 
element
+        type of the unrestricted view, and several typed views of different 
types
+        can coexist on one execution.
+
+        Parameters
+        ----------
+        output_type : Any
+            Optional output-type declaration. When omitted, the unrestricted 
view
+            carries whatever the agent emitted; when given, a downstream 
conversion
+            operator materializes each element as that type.
+
+        Returns:
+        -------
+        DataStream
+            Output datastream of agent execution.
+        """
+        raw = self._raw_output_stream()
+        if output_type is None:
+            # Unrestricted view: keep the agent's own output elements.
+            return raw.map(lambda b: cloudpickle.loads(b))
+        if is_structured_declaration(output_type):
+            # Typed view of a python structured type: rebuild declared 
instances.
+            # A Pydantic model has no native Flink TypeInformation, so the 
stream
+            # stays pickle-typed and the increment is the validated instances.
+            model_cls = output_type
+            return raw.map(
+                lambda b: reconstruct_instance(model_cls, cloudpickle.loads(b))
             )
-        return self.__output
+        declared = _unwrap_type_info(output_type)
+        if isinstance(declared, RowTypeInfo):
+            return self._row_stream(declared)
+        # Scalar / other explicit TypeInformation: elements already match the 
type.
+        return raw.map(lambda b: cloudpickle.loads(b), output_type=output_type)
 
-    def to_table(self, schema: Schema, output_type: TypeInformation) -> Table:
+    def to_table(self, schema: Schema | None = None, output_type: Any = None) 
-> Table:
         """Get output Table of agent execution.
 
+        At least one of ``schema`` or ``output_type`` must be given; passing 
both
+        cross-checks that they describe the same row type.
+
         Parameters
         ----------
-        schema : Schema
-            Indicate schema of the output table.
-        output_type : TypeInformation
-            Indicate schema corresponding type information.
+        schema : Schema | None
+            A Table ``Schema`` whose physical columns give the row type. It may
+            also carry Table-domain information such as a primary key, computed
+            columns, or a watermark.
+        output_type : Any
+            The output-type declaration -- a Pydantic model / dataclass / named
+            tuple / ``TypedDict`` / ``RowTypeInfo``, or an explicit
+            ``TypeInformation``. The physical schema is derived from it.
 
         Returns:
         -------
         Table
             Output Table of agent execution.
         """
-        return self.t_env.from_data_stream(self.to_datastream(output_type), 
schema)
+        if schema is not None and not isinstance(schema, Schema):
+            msg = (
+                "to_table 'schema' accepts a Table Schema only; to declare the 
"
+                "output with a type (Pydantic model / dataclass / named tuple 
/ "
+                "TypedDict / RowTypeInfo), pass it as output_type=... instead."
+            )
+            raise TypeError(msg)
+        if isinstance(output_type, Schema):
+            msg = (
+                "to_table 'output_type' accepts a type declaration (Pydantic 
model "
+                "/ dataclass / named tuple / TypedDict / RowTypeInfo / "
+                "TypeInformation); pass a Table Schema as schema=... instead."
+            )
+            raise TypeError(msg)
+        if schema is None and output_type is None:
+            msg = (
+                "to_table requires at least one of 'schema' or 'output_type'; "
+                "neither was given."
+            )
+            raise ValueError(msg)
+        schema_row_type = (
+            schema_to_row_type_info(schema) if schema is not None else None
+        )
+        output_row_type = (
+            None if output_type is None else resolve_row_type_info(output_type)
+        )
+        if schema_row_type is not None and output_row_type is not None:
+            if not _row_types_equal(schema_row_type, output_row_type):
+                msg = (
+                    "to_table got a schema and an output type describing 
different "
+                    f"row types: {schema_row_type!r} vs {output_row_type!r}"
+                )
+                raise ValueError(msg)
+        row_type_info = schema_row_type or output_row_type
+
+        # Preserve the caller's Schema when present so Table-domain information
+        # (primary key, computed columns, watermark) survives; otherwise 
derive it.
+        table_schema = (
+            schema if schema is not None else 
row_type_info_to_schema(row_type_info)
+        )
+        return self.t_env.from_data_stream(
+            self._row_stream(row_type_info), table_schema
+        )
 
 
 class RemoteExecutionEnvironment(AgentsExecutionEnvironment):
diff --git a/python/flink_agents/runtime/tests/output_type_fixtures.py 
b/python/flink_agents/runtime/tests/output_type_fixtures.py
new file mode 100644
index 000000000..e2155f2ea
--- /dev/null
+++ b/python/flink_agents/runtime/tests/output_type_fixtures.py
@@ -0,0 +1,52 @@
+################################################################################
+#  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.
+#################################################################################
+"""Shared structured output types for the typed-terminal tests.
+
+The output-type helpers and the typed ``to_datastream`` / ``to_table`` 
terminals
+are exercised with the same four kinds of python structured declaration: a
+Pydantic model, a dataclass, a named tuple and a ``TypedDict``. They live in 
one
+module so the helper tests and the environment tests share a single definition
+instead of each redeclaring equivalent types.
+"""
+
+import dataclasses
+from typing import NamedTuple, TypedDict
+
+from pydantic import BaseModel
+
+
+class ModelOutput(BaseModel):
+    id: int
+    label: str
+    score: float
+
+
[email protected]
+class DcOutput:
+    id: int
+    label: str
+
+
+class NtOutput(NamedTuple):
+    id: int
+    label: str
+
+
+class TdOutput(TypedDict):
+    id: int
+    label: str
diff --git a/python/flink_agents/runtime/tests/test_output_type_utils.py 
b/python/flink_agents/runtime/tests/test_output_type_utils.py
new file mode 100644
index 000000000..67d5d137e
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_output_type_utils.py
@@ -0,0 +1,297 @@
+################################################################################
+#  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.
+#################################################################################
+"""Unit tests for output type inference and conversion helpers."""
+
+import pytest
+from pydantic import BaseModel
+from pyflink.common import Row
+from pyflink.common.typeinfo import ExternalTypeInfo, RowTypeInfo, Types
+from pyflink.table import DataTypes, Schema
+
+from flink_agents.api.events.event import Event, OutputEvent
+from flink_agents.runtime.output_type_utils import (
+    infer_row_type_info,
+    is_structured_declaration,
+    reconstruct_instance,
+    resolve_row_type_info,
+    row_shape,
+    row_type_info_to_schema,
+    schema_to_row_type_info,
+    to_row,
+)
+from flink_agents.runtime.tests.output_type_fixtures import (
+    DcOutput,
+    ModelOutput,
+    NtOutput,
+    TdOutput,
+)
+
+
+def _row_spec(typeinfo: RowTypeInfo) -> tuple:
+    """Canonical comparable form of a flat row type: names plus type 
strings."""
+    return (
+        list(typeinfo.get_field_names()),
+        [str(t) for t in typeinfo.get_field_types()],
+    )
+
+
+def test_infer_agrees_across_structured_types():
+    expected = _row_spec(RowTypeInfo([Types.LONG(), Types.STRING()], ["id", 
"label"]))
+    for cls in (DcOutput, NtOutput, TdOutput):
+        assert _row_spec(infer_row_type_info(cls)) == expected
+
+
+def test_infer_pydantic_fields():
+    typeinfo = infer_row_type_info(ModelOutput)
+    assert typeinfo.get_field_names() == ["id", "label", "score"]
+    assert [str(t) for t in typeinfo.get_field_types()] == ["Long", "String", 
"Double"]
+
+
+def test_is_structured_declaration():
+    assert is_structured_declaration(ModelOutput)
+    assert is_structured_declaration(DcOutput)
+    assert not is_structured_declaration(Types.LONG())
+    assert not is_structured_declaration(42)
+
+
+def test_row_type_info_to_schema_round_trip():
+    typeinfo = infer_row_type_info(ModelOutput)
+    schema = row_type_info_to_schema(typeinfo)
+    assert _row_spec(schema_to_row_type_info(schema)) == _row_spec(typeinfo)
+
+
+def test_schema_to_row_type_info():
+    schema = (
+        Schema.new_builder()
+        .column("id", DataTypes.BIGINT())
+        .column("label", DataTypes.STRING())
+        .build()
+    )
+    typeinfo = schema_to_row_type_info(schema)
+    assert typeinfo.get_field_names() == ["id", "label"]
+    assert [str(t) for t in typeinfo.get_field_types()] == ["Long", "String"]
+
+
+def test_schema_to_row_type_info_sql_type_strings():
+    """A column declared with a SQL type string resolves like a ``DataTypes`` 
one.
+
+    ``column("id", "BIGINT")`` stores an ``UnresolvedDataType``; resolving it
+    before the legacy conversion yields the same row type as the equivalent
+    ``DataTypes`` declaration, for scalars and a nested ``ROW`` alike.
+    """
+    schema = (
+        Schema.new_builder()
+        .column("id", "BIGINT")
+        .column("label", "STRING")
+        .column("nested", "ROW<x INT, y STRING>")
+        .build()
+    )
+    typeinfo = schema_to_row_type_info(schema)
+    assert typeinfo.get_field_names() == ["id", "label", "nested"]
+    assert [str(t) for t in typeinfo.get_field_types()[:2]] == ["Long", 
"String"]
+    nested = typeinfo.get_field_types()[2]
+    assert isinstance(nested, RowTypeInfo)
+    assert nested.get_field_names() == ["x", "y"]
+
+    # The SQL strings agree with the equivalent ``DataTypes`` object columns.
+    object_schema = (
+        Schema.new_builder()
+        .column("id", DataTypes.BIGINT())
+        .column("label", DataTypes.STRING())
+        .build()
+    )
+    sql_schema = (
+        Schema.new_builder().column("id", "BIGINT").column("label", 
"STRING").build()
+    )
+    assert _row_spec(schema_to_row_type_info(sql_schema)) == _row_spec(
+        schema_to_row_type_info(object_schema)
+    )
+
+
+def test_schema_to_row_type_info_skips_computed_and_metadata_columns():
+    """Only physical columns feed the derived row type.
+
+    Computed and metadata columns are planner-derived rather than read from the
+    agent output, so ``schema_to_row_type_info`` skips them; this mirrors the
+    Java ``toTable(Schema)`` conversion.
+    """
+    schema = (
+        Schema.new_builder()
+        .column("id", DataTypes.BIGINT())
+        .column("label", DataTypes.STRING())
+        .column_by_expression("id_plus_one", "id + 1")
+        .column_by_metadata("rowtime", "TIMESTAMP_LTZ(3)")
+        .build()
+    )
+    typeinfo = schema_to_row_type_info(schema)
+    assert typeinfo.get_field_names() == ["id", "label"]
+    assert [str(t) for t in typeinfo.get_field_types()] == ["Long", "String"]
+
+
+def test_nested_row_schema_derives_and_converts():
+    """A nested ROW column survives py4j derivation in both directions.
+
+    This is the full-type coverage the py4j-backed conversion adds over the
+    previous scalar-subset mapping, which rejected a nested ROW column 
outright.
+    """
+    schema = (
+        Schema.new_builder()
+        .column("id", DataTypes.BIGINT())
+        .column(
+            "nested",
+            DataTypes.ROW(
+                [
+                    DataTypes.FIELD("x", DataTypes.INT()),
+                    DataTypes.FIELD("y", DataTypes.STRING()),
+                ]
+            ),
+        )
+        .build()
+    )
+    typeinfo = schema_to_row_type_info(schema)
+    assert typeinfo.get_field_names() == ["id", "nested"]
+    nested = typeinfo.get_field_types()[1]
+    assert isinstance(nested, RowTypeInfo)
+    assert nested.get_field_names() == ["x", "y"]
+
+    # A nested dict is converted to a nested Row along the picklable shape.
+    shape = row_shape(typeinfo)
+    row = to_row({"id": 1, "nested": {"x": 2, "y": "z"}}, shape)
+    assert row == Row(1, Row(2, "z"))
+
+    # The nested row type derives an equivalent schema and maps back.
+    round_tripped = schema_to_row_type_info(row_type_info_to_schema(typeinfo))
+    assert round_tripped.get_field_names() == ["id", "nested"]
+    rt_nested = round_tripped.get_field_types()[1]
+    assert isinstance(rt_nested, RowTypeInfo)
+    assert rt_nested.get_field_names() == ["x", "y"]
+
+
+def test_resolve_passes_through_and_infers():
+    typeinfo = infer_row_type_info(ModelOutput)
+    assert _row_spec(resolve_row_type_info(typeinfo)) == _row_spec(typeinfo)
+    assert _row_spec(resolve_row_type_info(DcOutput)) == _row_spec(
+        infer_row_type_info(DcOutput)
+    )
+
+
+def test_resolve_rejects_scalar_typeinfo():
+    with pytest.raises(TypeError, match="row-shaped"):
+        resolve_row_type_info(Types.LONG())
+
+
+def test_resolve_unwraps_external_type_info():
+    typeinfo = infer_row_type_info(ModelOutput)
+    wrapped = ExternalTypeInfo(typeinfo)
+    assert _row_spec(resolve_row_type_info(wrapped)) == _row_spec(typeinfo)
+
+
+def test_row_shape_marks_only_nested_rows():
+    names, children = row_shape(infer_row_type_info(ModelOutput))
+    assert names == ["id", "label", "score"]
+    assert children == [None, None, None]
+
+
+def test_to_row_from_dict():
+    shape = row_shape(infer_row_type_info(ModelOutput))
+    assert list(to_row({"id": 2, "label": "x", "score": 2.5}, shape)) == [2, 
"x", 2.5]
+
+
+def test_to_row_passes_through_row():
+    shape = row_shape(infer_row_type_info(ModelOutput))
+    existing = Row(3, "y", 3.5)
+    assert to_row(existing, shape) is existing
+
+
+def test_to_row_passes_through_non_dict():
+    # A non-mapping value is left untouched for its own coder.
+    shape = row_shape(infer_row_type_info(ModelOutput))
+    assert to_row(42, shape) == 42
+
+
+def test_to_row_named_tuple_through_output_event_boundary():
+    """A ``NamedTuple`` degrades to a positional array across the JSON 
boundary.
+
+    The Table path runs ``to_row`` (not ``reconstruct_instance``), so the
+    positional array the ``OutputEvent`` boundary produces must be rebuilt 
into a
+    ``Row`` by field order here; otherwise the declared Row coder receives a 
bare
+    list and fails with 'list' object has no attribute 'get_fields_by_names'.
+    """
+    event = OutputEvent(output=NtOutput(1, "good"))
+    degraded = Event.from_json(event.model_dump_json()).attributes["output"]
+    assert degraded == [1, "good"]
+
+    shape = row_shape(infer_row_type_info(NtOutput))
+    assert to_row(degraded, shape) == Row(1, "good")
+
+
+def test_to_row_passes_through_sequence_of_mismatched_length():
+    """The positional rebuild fires only when the sequence length matches the 
row width.
+
+    A sequence whose length differs from the number of columns is a single
+    column's own value (an ARRAY, for instance) rather than a degraded 
NamedTuple,
+    so it is left untouched for its coder instead of being split across 
columns.
+    """
+    shape = row_shape(RowTypeInfo([Types.STRING()], ["arr"]))
+    assert to_row([1, 2, 3], shape) == [1, 2, 3]
+
+
+def test_infer_rejects_unsupported_field_type():
+    class Bad(BaseModel):
+        payload: bytes
+
+    with pytest.raises(TypeError, match="unsupported output field type"):
+        infer_row_type_info(Bad)
+
+
+def test_reconstruct_instance_from_dict():
+    data = {"id": 1, "label": "a"}
+    assert reconstruct_instance(DcOutput, data) == DcOutput(id=1, label="a")
+    assert reconstruct_instance(NtOutput, data) == NtOutput(id=1, label="a")
+    assert reconstruct_instance(TdOutput, data) == data
+    instance = reconstruct_instance(ModelOutput, {"id": 1, "label": "a", 
"score": 1.5})
+    assert isinstance(instance, ModelOutput)
+
+
+def test_reconstruct_named_tuple_through_output_event_boundary():
+    """A ``NamedTuple`` degrades to a positional array across the JSON 
boundary.
+
+    The agent emits ``NtOutput``; ``OutputEvent.model_dump_json`` serializes 
the
+    tuple as a JSON array, so the value ``reconstruct_instance`` receives is
+    ``[1, "good"]`` rather than a mapping and must be rebuilt by field order.
+    """
+    event = OutputEvent(output=NtOutput(1, "good"))
+    degraded = Event.from_json(event.model_dump_json()).attributes["output"]
+    assert degraded == [1, "good"]
+    assert reconstruct_instance(NtOutput, degraded) == NtOutput(1, "good")
+
+
+def test_reconstruct_instance_from_row():
+    assert reconstruct_instance(DcOutput, Row(id=1, label="a")) == DcOutput(
+        id=1, label="a"
+    )
+
+
+def test_reconstruct_instance_passes_through_instance():
+    existing = ModelOutput(id=1, label="a", score=1.5)
+    assert reconstruct_instance(ModelOutput, existing) is existing
+
+
+def test_reconstruct_instance_rejects_non_mapping():
+    with pytest.raises(TypeError, match="expected a mapping"):
+        reconstruct_instance(DcOutput, 42)
diff --git 
a/python/flink_agents/runtime/tests/test_remote_execution_environment.py 
b/python/flink_agents/runtime/tests/test_remote_execution_environment.py
index ed8dcbdc8..7705c7629 100644
--- a/python/flink_agents/runtime/tests/test_remote_execution_environment.py
+++ b/python/flink_agents/runtime/tests/test_remote_execution_environment.py
@@ -18,20 +18,32 @@
 import json
 import os
 import tempfile
+from collections.abc import Iterator
+from contextlib import contextmanager
 from pathlib import Path
 from unittest.mock import MagicMock, patch
 
+import cloudpickle
 import pytest
 import yaml
+from pyflink.common import Row
+from pyflink.common.typeinfo import ExternalTypeInfo, Types
+from pyflink.table import DataTypes, Schema
 
 from flink_agents.api.agents.agent import Agent
 from flink_agents.api.events.event import Event
 from flink_agents.api.runner_context import RunnerContext
 from flink_agents.plan.configuration import AgentConfiguration
+from flink_agents.runtime.output_type_utils import (
+    infer_row_type_info,
+    schema_to_row_type_info,
+)
 from flink_agents.runtime.remote_execution_environment import (
     RemoteAgentBuilder,
     RemoteExecutionEnvironment,
+    _row_types_equal,
 )
+from flink_agents.runtime.tests.output_type_fixtures import DcOutput, 
ModelOutput
 
 test_data = {
     "agent": {
@@ -358,3 +370,205 @@ def test_to_datastream_submits_java_validated_plan_json() 
-> None:
     assert 
json.loads(submitted_plan_json)["config"]["conf_data"]["plan.version"] == (
         "validated"
     )
+
+
+def _output_schema() -> Schema:
+    return (
+        Schema.new_builder()
+        .column("id", DataTypes.BIGINT())
+        .column("label", DataTypes.STRING())
+        .column("score", DataTypes.DOUBLE())
+        .build()
+    )
+
+
+@contextmanager
+def _applied_builder(t_env=None) -> Iterator[tuple]:
+    """Yield an applied builder with a capturable raw output stream.
+
+    ``invoke_method`` is patched so plan validation passes and 
``connectToAgent``
+    returns a stub; ``DataStream`` is patched so the cached raw stream is
+    ``raw_mock``. A test then inspects each terminal's conversion operator 
through
+    ``raw_mock.map.call_args_list`` without a Flink runtime.
+    """
+    if t_env is None:
+        t_env = MagicMock()
+    builder = RemoteAgentBuilder(
+        input=MagicMock(),
+        config=AgentConfiguration(),
+        t_env=t_env,
+        resources={},
+        agents={},
+    )
+    agent = _agent_with_conditions(["_input_event"])
+    raw_mock = MagicMock(name="raw_output")
+    with (
+        patch(
+            "flink_agents.runtime.remote_execution_environment.invoke_method",
+            side_effect=[None, MagicMock()],
+        ) as invoke_mock,
+        patch(
+            "flink_agents.runtime.remote_execution_environment.DataStream",
+            return_value=raw_mock,
+        ),
+    ):
+        builder.apply(agent)
+        yield builder, raw_mock, t_env, invoke_mock
+
+
+def _last_map(raw_mock) -> tuple:
+    """Return ``(func, output_type)`` of the most recent ``raw.map`` call."""
+    call = raw_mock.map.call_args_list[-1]
+    return call.args[0], call.kwargs.get("output_type")
+
+
+def test_to_datastream_raw_view_is_untyped():
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream()
+    func, output_type = _last_map(raw_mock)
+    assert output_type is None
+    assert func(cloudpickle.dumps({"id": 1})) == {"id": 1}
+
+
+def test_to_datastream_scalar_type():
+    long_ti = Types.LONG()
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream(long_ti)
+    func, output_type = _last_map(raw_mock)
+    assert output_type is long_ti
+    assert func(cloudpickle.dumps(42)) == 42
+
+
+def test_to_datastream_structured_model_yields_instances():
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream(ModelOutput)
+    func, output_type = _last_map(raw_mock)
+    # A Pydantic model has no native Flink type: the stream stays pickle-typed.
+    assert output_type is None
+    payload = cloudpickle.dumps({"id": 1, "label": "a", "score": 1.5})
+    assert func(payload) == ModelOutput(id=1, label="a", score=1.5)
+    # The closure captures no py4j object, so it survives a pickle round-trip.
+    restored = cloudpickle.loads(cloudpickle.dumps(func))
+    assert restored(payload) == ModelOutput(id=1, label="a", score=1.5)
+
+
+def test_to_datastream_row_type_info_yields_rows():
+    row_ti = infer_row_type_info(ModelOutput)
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream(row_ti)
+    func, output_type = _last_map(raw_mock)
+    assert isinstance(output_type, ExternalTypeInfo)
+    payload = cloudpickle.dumps({"id": 1, "label": "a", "score": 1.5})
+    assert list(func(payload)) == [1, "a", 1.5]
+    # The closure captures only the picklable shape, not the py4j RowTypeInfo.
+    restored = cloudpickle.loads(cloudpickle.dumps(func))
+    assert list(restored(payload)) == [1, "a", 1.5]
+
+
+def test_to_datastream_external_row_type_info_yields_rows():
+    # An ExternalTypeInfo-wrapped RowTypeInfo (the form a Table terminal hands
+    # back) is unwrapped to its inner row type before the row conversion.
+    wrapped = ExternalTypeInfo(infer_row_type_info(ModelOutput))
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream(wrapped)
+    func, output_type = _last_map(raw_mock)
+    assert isinstance(output_type, ExternalTypeInfo)
+    payload = cloudpickle.dumps({"id": 1, "label": "a", "score": 1.5})
+    assert list(func(payload)) == [1, "a", 1.5]
+
+
+def test_to_datastream_caches_raw_stream_without_welding_type():
+    long_ti = Types.LONG()
+    with _applied_builder() as (builder, raw_mock, _, invoke_mock):
+        builder.to_datastream()
+        builder.to_datastream(long_ti)
+    # The agent operator is connected once; each view maps its own conversion.
+    assert invoke_mock.call_count == 2
+    assert raw_mock.map.call_count == 2
+    first_type = raw_mock.map.call_args_list[0].kwargs.get("output_type")
+    second_type = raw_mock.map.call_args_list[1].kwargs.get("output_type")
+    assert first_type is None
+    assert second_type is long_ti
+
+
+def test_multiple_typed_datastreams_of_different_types_coexist():
+    # With no single-declaration guard, one execution exposes several 
independent
+    # typed streams. Each typed call binds its own type through a separate
+    # conversion operator on the shared raw stream, and the unrestricted view
+    # stays untyped.
+    with _applied_builder() as (builder, raw_mock, _, _):
+        builder.to_datastream()
+        builder.to_datastream(ModelOutput)
+        builder.to_datastream(DcOutput)
+    # Raw view + two typed streams = three independent operators; no conflict.
+    assert raw_mock.map.call_count == 3
+    assert raw_mock.map.call_args_list[0].kwargs.get("output_type") is None
+
+
+def test_to_table_rejects_type_declaration_in_schema_slot():
+    # The builder's 'schema' slot is Schema-only; a type declaration must be
+    # passed as output_type=... instead.
+    with (
+        _applied_builder() as (builder, _, _, _),
+        pytest.raises(TypeError, match="accepts a Table Schema only"),
+    ):
+        builder.to_table(ModelOutput)
+
+
+def test_to_table_schema_only_builds_row_stream():
+    schema = _output_schema()
+    with _applied_builder() as (builder, raw_mock, t_env, _):
+        builder.to_table(schema)
+    func, output_type = _last_map(raw_mock)
+    assert isinstance(output_type, ExternalTypeInfo)
+    payload = cloudpickle.dumps({"id": 1, "label": "a", "score": 1.5})
+    assert isinstance(func(payload), Row)
+    # The caller's Schema is passed through to preserve Table-domain 
information.
+    assert t_env.from_data_stream.call_args.args[1] is schema
+
+
+def test_to_table_output_type_derives_schema():
+    with _applied_builder() as (builder, _, t_env, _):
+        builder.to_table(output_type=ModelOutput)
+    derived = t_env.from_data_stream.call_args.args[1]
+    assert isinstance(derived, Schema)
+    assert _row_types_equal(
+        schema_to_row_type_info(derived), infer_row_type_info(ModelOutput)
+    )
+
+
+def test_to_table_cross_check_matching():
+    schema = _output_schema()
+    output_type = ExternalTypeInfo(infer_row_type_info(ModelOutput))
+    with _applied_builder() as (builder, _, t_env, _):
+        builder.to_table(schema=schema, output_type=output_type)
+    assert t_env.from_data_stream.call_args.args[1] is schema
+
+
+def test_to_table_cross_check_mismatched():
+    schema = _output_schema()
+    output_type = ExternalTypeInfo(infer_row_type_info(DcOutput))
+    with (
+        _applied_builder() as (builder, _, _, _),
+        pytest.raises(ValueError, match="different row types"),
+    ):
+        builder.to_table(schema=schema, output_type=output_type)
+
+
+def test_to_table_requires_schema_or_output_type():
+    # Neither a schema nor an output type: the terminal cannot derive a row 
type.
+    with (
+        _applied_builder() as (builder, _, _, _),
+        pytest.raises(ValueError, match="at least one of 'schema' or 
'output_type'"),
+    ):
+        builder.to_table()
+
+
+def test_to_table_rejects_schema_in_output_type_slot():
+    # The 'output_type' slot takes a type declaration; a Table Schema belongs 
in
+    # the schema= slot, mirroring the schema-slot rejection above.
+    with (
+        _applied_builder() as (builder, _, _, _),
+        pytest.raises(TypeError, match="pass a Table Schema as schema="),
+    ):
+        builder.to_table(output_type=_output_schema())
diff --git a/runtime/pom.xml b/runtime/pom.xml
index e9e3521f2..4e96d98e4 100644
--- a/runtime/pom.xml
+++ b/runtime/pom.xml
@@ -98,6 +98,15 @@ under the License.
             <version>${flink.version}</version>
             <scope>provided</scope>
         </dependency>
+        <!-- Test-scoped planner so the typed toTable terminals can be 
unit-tested headlessly
+             (StreamTableEnvironment.create needs an ExecutorFactory); 
production gets the planner
+             from the cluster classpath. -->
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-table-planner_2.12</artifactId>
+            <version>${flink.version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.flink</groupId>
             <artifactId>flink-test-utils-junit</artifactId>
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/OutputTypeUtils.java 
b/runtime/src/main/java/org/apache/flink/agents/runtime/OutputTypeUtils.java
new file mode 100644
index 000000000..983eec500
--- /dev/null
+++ b/runtime/src/main/java/org/apache/flink/agents/runtime/OutputTypeUtils.java
@@ -0,0 +1,317 @@
+/*
+ * 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.runtime;
+
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.typeutils.RowTypeInfo;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.types.AbstractDataType;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.utils.LogicalTypeParser;
+import org.apache.flink.table.types.utils.TypeConversions;
+import org.apache.flink.types.Row;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.time.temporal.Temporal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Output-type conversion helpers for the typed {@code toTable(Schema)} 
terminal.
+ *
+ * <p>An agent emits arbitrary output objects through a {@code 
DataStream<Object>}. Turning that
+ * untyped stream into a {@link org.apache.flink.table.api.Table} with a 
user-declared {@link
+ * Schema} happens in a downstream conversion operator, so the raw stream 
keeps its unrestricted
+ * {@code Object} element type and heterogeneous output stays available 
through the raw view.
+ *
+ * <p>This class supplies the two pieces that operator needs:
+ *
+ * <ul>
+ *   <li>{@link #schemaToRowTypeInfo(Schema)} derives the {@link RowTypeInfo} 
of the physical
+ *       columns a {@link Schema} declares. Computed and metadata columns are 
skipped because the
+ *       planner derives them, and only physical columns are read from the 
agent output.
+ *   <li>{@link #adaptToRow(Object, RowTypeInfo)} adapts one emitted value 
into a {@link Row} that
+ *       matches that row type, accepting a {@link Row}, a {@link Map}, a POJO 
(read by field name),
+ *       or a scalar (mapped into a single-column row type).
+ * </ul>
+ */
+public final class OutputTypeUtils {
+
+    private OutputTypeUtils() {}
+
+    /**
+     * Derives a {@link RowTypeInfo} from the physical columns of a Table 
{@link Schema}.
+     *
+     * <p>Each physical column's type is resolved to a concrete {@link 
DataType}: a {@code
+     * DataTypes} object is used as-is, while a SQL type string such as {@code 
column("id",
+     * "BIGINT")} is parsed first. Computed and metadata columns are ignored 
because the planner
+     * derives them rather than reading them from the agent output.
+     *
+     * @param schema the output schema declared by the user.
+     * @return a row type whose field names and types follow the schema's 
physical columns, in
+     *     order.
+     * @throws IllegalArgumentException if the schema has no physical columns, 
or a physical
+     *     column's type is neither a resolved {@link DataType} nor a 
parseable SQL type string.
+     */
+    public static RowTypeInfo schemaToRowTypeInfo(Schema schema) {
+        List<String> names = new ArrayList<>();
+        List<TypeInformation<?>> types = new ArrayList<>();
+        for (Schema.UnresolvedColumn column : schema.getColumns()) {
+            if (!(column instanceof Schema.UnresolvedPhysicalColumn)) {
+                continue;
+            }
+            AbstractDataType<?> abstractType =
+                    ((Schema.UnresolvedPhysicalColumn) column).getDataType();
+            names.add(column.getName());
+            types.add(
+                    TypeConversions.fromDataTypeToLegacyInfo(
+                            resolveDataType(abstractType, column.getName())));
+        }
+        if (names.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Cannot derive an output row type: the schema declares no 
physical columns.");
+        }
+        return new RowTypeInfo(types.toArray(new TypeInformation[0]), 
names.toArray(new String[0]));
+    }
+
+    /**
+     * Resolves a schema column's {@link AbstractDataType} to a concrete 
{@link DataType}.
+     *
+     * <p>A column declared with a {@code DataTypes} object is already 
resolved and is returned
+     * as-is. A column declared with a SQL type string such as {@code 
column("id", "BIGINT")}
+     * carries an {@link org.apache.flink.table.types.UnresolvedDataType} that 
{@link
+     * TypeConversions#fromDataTypeToLegacyInfo} rejects, so its SQL text is 
parsed through Flink's
+     * {@link LogicalTypeParser} first -- the same resolution the planner 
applies once the schema
+     * reaches it, and the one the Python {@code schema_to_row_type_info} 
performs.
+     */
+    private static DataType resolveDataType(AbstractDataType<?> abstractType, 
String columnName) {
+        if (abstractType instanceof DataType) {
+            return (DataType) abstractType;
+        }
+        String sqlType = abstractType.toString();
+        if (sqlType.startsWith("[") && sqlType.endsWith("]")) {
+            sqlType = sqlType.substring(1, sqlType.length() - 1);
+        }
+        try {
+            LogicalType logicalType =
+                    LogicalTypeParser.parse(
+                            sqlType, 
Thread.currentThread().getContextClassLoader());
+            return TypeConversions.fromLogicalToDataType(logicalType);
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException(
+                    "Cannot derive the output row type from column '"
+                            + columnName
+                            + "': its type "
+                            + abstractType
+                            + " is neither a resolved DataType nor a parseable 
SQL type string.",
+                    e);
+        }
+    }
+
+    /**
+     * Adapts a single agent output value into a {@link Row} matching {@code 
rowType}.
+     *
+     * <p>This is the payload of the {@code toTable(Schema)} conversion 
operator. The accepted
+     * shapes are:
+     *
+     * <ul>
+     *   <li>a {@link Row}: projected by name when named, otherwise passed 
through positionally
+     *       after an arity check;
+     *   <li>a {@link Map}: read by the row type's field names;
+     *   <li>a scalar value with a single-column row type: wrapped into a 
one-column row, checked
+     *       before the POJO lookup so a column name matching an internal 
member of the scalar's own
+     *       type does not extract that member;
+     *   <li>an object exposing every column name (a POJO): read by name via 
getters or fields.
+     * </ul>
+     *
+     * @param value the emitted output object.
+     * @param rowType the target row type derived from the output schema.
+     * @return a positional {@link Row} whose fields follow {@code rowType}'s 
order.
+     * @throws IllegalArgumentException if the value cannot supply the 
declared fields.
+     */
+    public static Row adaptToRow(Object value, RowTypeInfo rowType) {
+        String[] names = rowType.getFieldNames();
+        int arity = names.length;
+        if (value instanceof Row) {
+            return fromRow((Row) value, names, arity);
+        }
+        if (value instanceof Map) {
+            Map<?, ?> map = (Map<?, ?>) value;
+            Object[] fields = new Object[arity];
+            for (int i = 0; i < arity; i++) {
+                fields[i] = map.get(names[i]);
+            }
+            return Row.of(fields);
+        }
+        if (arity == 1 && isScalar(value)) {
+            // A scalar output mapped into a single-column table. Recognized 
before the POJO
+            // property lookup so a column name that happens to match an 
internal member of the
+            // scalar's own type -- String.value, String.getBytes() -- does 
not extract that
+            // member instead of wrapping the value itself.
+            return Row.of(value);
+        }
+        if (canReadAll(value, names)) {
+            Object[] fields = new Object[arity];
+            for (int i = 0; i < arity; i++) {
+                fields[i] = readProperty(value, names[i], rowType);
+            }
+            return Row.of(fields);
+        }
+        if (arity == 1) {
+            // A single-column value that is not a recognized scalar (e.g. a 
nested structure the
+            // schema maps into one column); wrap it whole.
+            return Row.of(value);
+        }
+        throw new IllegalArgumentException(
+                "Cannot adapt an output of type "
+                        + value.getClass().getName()
+                        + " to the declared schema "
+                        + rowType
+                        + ": it is neither a Row/Map nor an object exposing 
the columns "
+                        + Arrays.toString(names)
+                        + ".");
+    }
+
+    private static Row fromRow(Row value, String[] names, int arity) {
+        Set<String> rowNames = tryFieldNames(value);
+        if (rowNames != null && rowNames.containsAll(Arrays.asList(names))) {
+            Object[] fields = new Object[arity];
+            for (int i = 0; i < arity; i++) {
+                fields[i] = value.getField(names[i]);
+            }
+            return Row.of(fields);
+        }
+        if (value.getArity() != arity) {
+            throw new IllegalArgumentException(
+                    "Agent emitted a Row with "
+                            + value.getArity()
+                            + " fields, but the output schema declares "
+                            + arity
+                            + " columns "
+                            + Arrays.toString(names)
+                            + ".");
+        }
+        return value;
+    }
+
+    private static Set<String> tryFieldNames(Row row) {
+        try {
+            return row.getFieldNames(false);
+        } catch (RuntimeException e) {
+            // A positional row has no field names; fall back to positional 
adaptation.
+            return null;
+        }
+    }
+
+    private static Object readProperty(Object bean, String name, RowTypeInfo 
rowType) {
+        Class<?> type = bean.getClass();
+        Method getter = findGetter(type, name);
+        if (getter != null) {
+            try {
+                return getter.invoke(bean);
+            } catch (ReflectiveOperationException | RuntimeException e) {
+                throw new IllegalArgumentException(
+                        "Failed to read output field '" + name + "' from " + 
type.getName() + ".",
+                        e);
+            }
+        }
+        Field field = findField(type, name);
+        if (field == null) {
+            throw new IllegalArgumentException(
+                    "Cannot read output field '"
+                            + name
+                            + "' from "
+                            + type.getName()
+                            + " to match the declared schema "
+                            + rowType
+                            + ". Provide a matching getter or field.");
+        }
+        try {
+            field.setAccessible(true);
+            return field.get(bean);
+        } catch (ReflectiveOperationException | RuntimeException e) {
+            throw new IllegalArgumentException(
+                    "Failed to read output field '" + name + "' from " + 
type.getName() + ".", e);
+        }
+    }
+
+    /**
+     * Returns whether {@code value} is a scalar (non-decomposable) output 
rather than a POJO whose
+     * fields should be projected into columns.
+     *
+     * <p>JDK value types carry internal members -- {@code String} has a 
private {@code value} field
+     * and a {@code getBytes()} accessor -- that a by-name property lookup 
would otherwise mistake
+     * for schema columns. Treating them as scalars keeps a single-column 
schema wrapping the value
+     * itself, while a user POJO still projects its fields by name.
+     */
+    private static boolean isScalar(Object value) {
+        return value instanceof CharSequence
+                || value instanceof Number
+                || value instanceof Boolean
+                || value instanceof Character
+                || value instanceof Enum<?>
+                || value instanceof Temporal
+                || value instanceof Date
+                || value instanceof UUID
+                || value instanceof byte[];
+    }
+
+    private static boolean canReadAll(Object bean, String[] names) {
+        Class<?> type = bean.getClass();
+        for (String name : names) {
+            if (findGetter(type, name) == null && findField(type, name) == 
null) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static Method findGetter(Class<?> type, String name) {
+        String suffix = Character.toUpperCase(name.charAt(0)) + 
name.substring(1);
+        for (String methodName : new String[] {"get" + suffix, "is" + suffix}) 
{
+            try {
+                Method method = type.getMethod(methodName);
+                if (method.getParameterCount() == 0) {
+                    return method;
+                }
+            } catch (NoSuchMethodException e) {
+                // try the next candidate
+            }
+        }
+        return null;
+    }
+
+    private static Field findField(Class<?> type, String name) {
+        for (Class<?> current = type; current != null; current = 
current.getSuperclass()) {
+            try {
+                return current.getDeclaredField(name);
+            } catch (NoSuchFieldException e) {
+                // keep walking up the hierarchy
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java
index c89e4c556..8cb9e599d 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java
@@ -25,7 +25,11 @@ import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.plan.AgentConfiguration;
 import org.apache.flink.agents.plan.AgentPlan;
 import org.apache.flink.agents.runtime.CompileUtils;
+import org.apache.flink.agents.runtime.OutputTypeUtils;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.api.java.typeutils.RowTypeInfo;
 import org.apache.flink.configuration.ConfigConstants;
 import org.apache.flink.configuration.YamlParserUtils;
 import org.apache.flink.streaming.api.datastream.DataStream;
@@ -33,6 +37,7 @@ import 
org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.flink.table.api.Schema;
 import org.apache.flink.table.api.Table;
 import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.types.Row;
 
 import javax.annotation.Nullable;
 
@@ -234,10 +239,52 @@ public class RemoteExecutionEnvironment extends 
AgentsExecutionEnvironment {
             return outputDataStream;
         }
 
+        @Override
+        public <V> DataStream<V> toDataStream(TypeInformation<V> 
typeInformation) {
+            // Layer a downstream conversion operator on the shared raw stream 
instead of
+            // retyping it, so the raw Object view stays unrestricted and 
heterogeneous output
+            // remains available through toDataStream(). Each call is 
independent, so several
+            // typed views of different types can coexist on one execution.
+            return toDataStream().map(new 
OutputCaster<V>()).returns(typeInformation);
+        }
+
         @Override
         public Table toTable(Schema schema) {
-            DataStream<Object> dataStream = toDataStream();
-            return getTableEnvironment().fromDataStream(dataStream, schema);
+            // Convert each agent output into a Row matching the schema's 
physical columns before
+            // handing the stream to the planner, so named columns declared in 
the schema resolve
+            // against the emitted values. The conversion is a downstream 
operator on the shared
+            // raw stream, so the unrestricted toDataStream() view stays 
unaffected.
+            RowTypeInfo rowType = OutputTypeUtils.schemaToRowTypeInfo(schema);
+            DataStream<Row> rowStream =
+                    toDataStream()
+                            .map(value -> OutputTypeUtils.adaptToRow(value, 
rowType))
+                            .returns(rowType);
+            return getTableEnvironment().fromDataStream(rowStream, schema);
+        }
+
+        @Override
+        public <V> Table toTable(TypeInformation<V> typeInformation) {
+            // Derive the physical schema from the declared type: 
fromDataStream reads the
+            // table structure off the typed stream's TypeInformation (a 
POJO's fields become
+            // columns). The type is applied by a downstream operator on the 
shared raw stream,
+            // so the unrestricted toDataStream() view is unaffected.
+            return 
getTableEnvironment().fromDataStream(toDataStream(typeInformation));
+        }
+    }
+
+    /**
+     * Casts the unrestricted agent output objects to the type declared by a 
typed terminal. The
+     * elements are already instances of the declared type; the operator 
exists so the declared type
+     * is attached downstream of the shared raw stream rather than welded onto 
it.
+     */
+    private static final class OutputCaster<T> implements MapFunction<Object, 
T> {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public T map(Object value) {
+            return (T) value;
         }
     }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/OutputTypeUtilsTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/OutputTypeUtilsTest.java
new file mode 100644
index 000000000..69a87b4c1
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/OutputTypeUtilsTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.runtime;
+
+import org.apache.flink.api.java.typeutils.RowTypeInfo;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link OutputTypeUtils}. */
+public class OutputTypeUtilsTest {
+
+    /** A POJO exposing its values through getters only. */
+    public static class ReviewOutput {
+        private final int score;
+        private final String comment;
+
+        public ReviewOutput(int score, String comment) {
+            this.score = score;
+            this.comment = comment;
+        }
+
+        public int getScore() {
+            return score;
+        }
+
+        public String getComment() {
+            return comment;
+        }
+    }
+
+    /** A POJO exposing its values through public fields only. */
+    public static class PublicFields {
+        public String name;
+        public double value;
+
+        public PublicFields(String name, double value) {
+            this.name = name;
+            this.value = value;
+        }
+    }
+
+    private static RowTypeInfo rowType(Schema schema) {
+        return OutputTypeUtils.schemaToRowTypeInfo(schema);
+    }
+
+    @Test
+    void schemaToRowTypeInfoDerivesPhysicalColumnsInOrder() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("name", DataTypes.STRING())
+                                .column("score", DataTypes.INT())
+                                .column("ratio", DataTypes.DOUBLE())
+                                .column("flag", DataTypes.BOOLEAN())
+                                .build());
+
+        assertThat(rowType.getFieldNames()).containsExactly("name", "score", 
"ratio", "flag");
+        
assertThat(rowType.getTypeAt("name").getTypeClass()).isEqualTo(String.class);
+        
assertThat(rowType.getTypeAt("score").getTypeClass()).isEqualTo(Integer.class);
+        
assertThat(rowType.getTypeAt("ratio").getTypeClass()).isEqualTo(Double.class);
+        
assertThat(rowType.getTypeAt("flag").getTypeClass()).isEqualTo(Boolean.class);
+    }
+
+    @Test
+    void schemaToRowTypeInfoSkipsComputedColumns() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("id", DataTypes.BIGINT())
+                                .columnByExpression("doubled", "id * 2")
+                                .build());
+
+        assertThat(rowType.getFieldNames()).containsExactly("id");
+        
assertThat(rowType.getTypeAt("id").getTypeClass()).isEqualTo(Long.class);
+    }
+
+    @Test
+    void schemaToRowTypeInfoRejectsSchemaWithoutPhysicalColumns() {
+        Schema schema = Schema.newBuilder().columnByExpression("c", 
"1").build();
+
+        assertThatThrownBy(() -> OutputTypeUtils.schemaToRowTypeInfo(schema))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("no physical columns");
+    }
+
+    @Test
+    void schemaToRowTypeInfoResolvesSqlStringColumns() {
+        // A column declared with a SQL type string is an UnresolvedDataType; 
it resolves to the
+        // same row type as the equivalent DataTypes declaration, for scalars 
and a nested ROW.
+        RowTypeInfo fromSql =
+                rowType(
+                        Schema.newBuilder()
+                                .column("id", "BIGINT")
+                                .column("label", "STRING")
+                                .column("nested", "ROW<x INT, y STRING>")
+                                .build());
+
+        assertThat(fromSql.getFieldNames()).containsExactly("id", "label", 
"nested");
+        
assertThat(fromSql.getTypeAt("id").getTypeClass()).isEqualTo(Long.class);
+        
assertThat(fromSql.getTypeAt("label").getTypeClass()).isEqualTo(String.class);
+        assertThat(((RowTypeInfo) 
fromSql.<Row>getTypeAt("nested")).getFieldNames())
+                .containsExactly("x", "y");
+
+        // The SQL strings agree with the equivalent DataTypes object columns.
+        RowTypeInfo fromDataTypes =
+                rowType(
+                        Schema.newBuilder()
+                                .column("id", DataTypes.BIGINT())
+                                .column("label", DataTypes.STRING())
+                                .build());
+        RowTypeInfo sqlScalars =
+                rowType(
+                        Schema.newBuilder()
+                                .column("id", "BIGINT")
+                                .column("label", "STRING")
+                                .build());
+        
assertThat(sqlScalars.getFieldNames()).containsExactly(fromDataTypes.getFieldNames());
+        assertThat(sqlScalars.getTypeAt("id").getTypeClass())
+                .isEqualTo(fromDataTypes.getTypeAt("id").getTypeClass());
+        assertThat(sqlScalars.getTypeAt("label").getTypeClass())
+                .isEqualTo(fromDataTypes.getTypeAt("label").getTypeClass());
+    }
+
+    @Test
+    void adaptToRowWrapsScalarIntoSingleColumn() {
+        RowTypeInfo rowType = rowType(Schema.newBuilder().column("f0", 
DataTypes.STRING()).build());
+
+        Row row = OutputTypeUtils.adaptToRow("hello", rowType);
+
+        assertThat(row.getArity()).isEqualTo(1);
+        assertThat(row.getField(0)).isEqualTo("hello");
+    }
+
+    @Test
+    void adaptToRowWrapsScalarWhenColumnNameCollidesWithStringInternals() {
+        // "value" is String's private byte[] field and "bytes" matches 
String.getBytes(); a scalar
+        // String must be wrapped whole rather than have that internal member 
extracted, which
+        // would otherwise surface as ClassCastException: byte[] cannot be 
cast to String.
+        RowTypeInfo valueColumn =
+                rowType(Schema.newBuilder().column("value", 
DataTypes.STRING()).build());
+        assertThat(OutputTypeUtils.adaptToRow("hello", 
valueColumn).getField(0)).isEqualTo("hello");
+
+        RowTypeInfo bytesColumn =
+                rowType(Schema.newBuilder().column("bytes", 
DataTypes.STRING()).build());
+        assertThat(OutputTypeUtils.adaptToRow("hello", 
bytesColumn).getField(0)).isEqualTo("hello");
+    }
+
+    @Test
+    void adaptToRowReadsPojoGettersBySchemaName() {
+        // Schema column order intentionally differs from the POJO declaration 
order.
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("comment", DataTypes.STRING())
+                                .column("score", DataTypes.INT())
+                                .build());
+
+        Row row = OutputTypeUtils.adaptToRow(new ReviewOutput(5, "great"), 
rowType);
+
+        assertThat(row.getField(0)).isEqualTo("great");
+        assertThat(row.getField(1)).isEqualTo(5);
+    }
+
+    @Test
+    void adaptToRowReadsPublicFieldsWhenNoGetter() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("name", DataTypes.STRING())
+                                .column("value", DataTypes.DOUBLE())
+                                .build());
+
+        Row row = OutputTypeUtils.adaptToRow(new PublicFields("x", 1.5), 
rowType);
+
+        assertThat(row.getField(0)).isEqualTo("x");
+        assertThat(row.getField(1)).isEqualTo(1.5);
+    }
+
+    @Test
+    void adaptToRowPrefersPojoPropertyOverScalarForSingleColumn() {
+        RowTypeInfo rowType = rowType(Schema.newBuilder().column("score", 
DataTypes.INT()).build());
+
+        Row row = OutputTypeUtils.adaptToRow(new ReviewOutput(7, "c"), 
rowType);
+
+        assertThat(row.getField(0)).isEqualTo(7);
+    }
+
+    @Test
+    void adaptToRowReadsMapBySchemaName() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("a", DataTypes.STRING())
+                                .column("b", DataTypes.INT())
+                                .build());
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("b", 7);
+        map.put("a", "v");
+
+        Row row = OutputTypeUtils.adaptToRow(map, rowType);
+
+        assertThat(row.getField(0)).isEqualTo("v");
+        assertThat(row.getField(1)).isEqualTo(7);
+    }
+
+    @Test
+    void adaptToRowPassesThroughPositionalRow() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("a", DataTypes.STRING())
+                                .column("b", DataTypes.INT())
+                                .build());
+
+        Row row = OutputTypeUtils.adaptToRow(Row.of("v", 7), rowType);
+
+        assertThat(row.getField(0)).isEqualTo("v");
+        assertThat(row.getField(1)).isEqualTo(7);
+    }
+
+    @Test
+    void adaptToRowProjectsNamedRowBySchemaName() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("a", DataTypes.STRING())
+                                .column("b", DataTypes.INT())
+                                .build());
+        Row named = Row.withNames();
+        named.setField("b", 7);
+        named.setField("a", "v");
+
+        Row row = OutputTypeUtils.adaptToRow(named, rowType);
+
+        assertThat(row.getField(0)).isEqualTo("v");
+        assertThat(row.getField(1)).isEqualTo(7);
+    }
+
+    @Test
+    void adaptToRowRejectsRowWithWrongArity() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("a", DataTypes.STRING())
+                                .column("b", DataTypes.INT())
+                                .build());
+
+        assertThatThrownBy(() -> 
OutputTypeUtils.adaptToRow(Row.of("only-one"), rowType))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("declares 2 columns");
+    }
+
+    @Test
+    void adaptToRowRejectsPojoMissingColumn() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("score", DataTypes.INT())
+                                .column("missing", DataTypes.STRING())
+                                .build());
+
+        assertThatThrownBy(() -> OutputTypeUtils.adaptToRow(new 
ReviewOutput(1, "c"), rowType))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("missing");
+    }
+
+    @Test
+    void adaptToRowRejectsScalarForMultiColumnSchema() {
+        RowTypeInfo rowType =
+                rowType(
+                        Schema.newBuilder()
+                                .column("a", DataTypes.STRING())
+                                .column("b", DataTypes.STRING())
+                                .build());
+
+        assertThatThrownBy(() -> OutputTypeUtils.adaptToRow("scalar", rowType))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Cannot adapt an output of type");
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/env/TypedOutputTerminalTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/env/TypedOutputTerminalTest.java
new file mode 100644
index 000000000..e99c808a5
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/env/TypedOutputTerminalTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.runtime.env;
+
+import org.apache.flink.agents.api.AgentBuilder;
+import org.apache.flink.agents.api.AgentsExecutionEnvironment;
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.annotation.Action;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.api.Table;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for the typed output terminals of the remote {@link AgentBuilder}: 
the {@code
+ * toDataStream(TypeInformation)} / {@code toDataStream(Class)} and {@code 
toTable(TypeInformation)}
+ * / {@code toTable(Class)} overloads.
+ *
+ * <p>A typed terminal must attach its declared type through a downstream 
conversion operator rather
+ * than retyping the shared, cached raw stream. Because the type is applied 
downstream, several
+ * typed terminals of different types can coexist on one execution while the 
unrestricted {@code
+ * toDataStream()} view stays available and unchanged. These tests inspect the 
constructed graph and
+ * never execute a job.
+ */
+class TypedOutputTerminalTest {
+
+    /** Minimal agent so a plan can be built; the graph is inspected without 
executing it. */
+    public static class EchoAgent extends Agent {
+        @Action(EventType.InputEvent)
+        public static void handle(Event event, RunnerContext ctx) {
+            // Never runs: the terminal wiring is asserted at 
graph-construction time.
+        }
+    }
+
+    /** Minimal POJO so a typed table terminal derives one physical column per 
field. */
+    public static class Score {
+        public int value;
+        public String label;
+
+        public Score() {}
+
+        public Score(int value, String label) {
+            this.value = value;
+            this.label = label;
+        }
+    }
+
+    private static AgentBuilder appliedBuilder() {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(1);
+        AgentsExecutionEnvironment agentsEnv =
+                AgentsExecutionEnvironment.getExecutionEnvironment(env);
+        DataStream<String> inputStream = env.fromData(List.of("a", "b", "c"));
+        return agentsEnv.fromDataStream(inputStream).apply(new EchoAgent());
+    }
+
+    @Test
+    void rawDataStreamIsCachedAcrossCalls() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+
+        // The agent operator is connected once; repeated calls reuse the same 
cached stream.
+        assertThat(builder.toDataStream()).isSameAs(raw);
+    }
+
+    @Test
+    void typedTerminalDoesNotWeldTypeOntoSharedRawStream() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+        TypeInformation<?> rawTypeBefore = raw.getType();
+
+        DataStream<Integer> typed = 
builder.toDataStream(TypeInformation.of(Integer.class));
+
+        // The declared type is attached to the typed stream...
+        
assertThat(typed.getType()).isEqualTo(TypeInformation.of(Integer.class));
+        // ...and is not welded onto the shared raw stream, which keeps its 
original type. Retyping
+        // the cached stream in place (for example via returns() on it) would 
change raw.getType().
+        assertThat(raw.getType()).isEqualTo(rawTypeBefore);
+        
assertThat(raw.getType()).isNotEqualTo(TypeInformation.of(Integer.class));
+        // The unrestricted view is still the same cached stream after the 
typed terminal was built.
+        assertThat(builder.toDataStream()).isSameAs(raw);
+    }
+
+    @Test
+    void classOverloadDerivesTheSameTypeAsTypeInformation() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+        TypeInformation<?> rawTypeBefore = raw.getType();
+
+        // The Class overload is a convenience that derives the 
TypeInformation internally.
+        DataStream<Integer> typed = builder.toDataStream(Integer.class);
+
+        
assertThat(typed.getType()).isEqualTo(TypeInformation.of(Integer.class));
+        // It carries the same anti-welding guarantee as the explicit 
TypeInformation overload.
+        assertThat(raw.getType()).isEqualTo(rawTypeBefore);
+    }
+
+    @Test
+    void multipleTypedTerminalsOfDifferentTypesCoexist() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+        TypeInformation<?> rawTypeBefore = raw.getType();
+
+        // Two independent typed terminals of different types on the same 
execution. Each layers its
+        // own conversion operator on the shared raw stream, so they do not 
conflict with one
+        // another.
+        DataStream<Integer> numbers = 
builder.toDataStream(TypeInformation.of(Integer.class));
+        DataStream<String> names = 
builder.toDataStream(TypeInformation.of(String.class));
+
+        
assertThat(numbers.getType()).isEqualTo(TypeInformation.of(Integer.class));
+        
assertThat(names.getType()).isEqualTo(TypeInformation.of(String.class));
+        // Neither terminal welds its type onto the shared raw stream, which 
stays unrestricted.
+        assertThat(raw.getType()).isEqualTo(rawTypeBefore);
+        assertThat(builder.toDataStream()).isSameAs(raw);
+    }
+
+    @Test
+    void typedTableDerivesColumnsFromTypeInformation() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+        TypeInformation<?> rawTypeBefore = raw.getType();
+
+        Table table = builder.toTable(TypeInformation.of(Score.class));
+
+        // The physical schema is derived from the declared type: each POJO 
field becomes a column.
+        assertThat(table.getResolvedSchema().getColumnNames())
+                .containsExactlyInAnyOrder("value", "label");
+        // The type is attached by a downstream operator, so the shared raw 
stream stays
+        // unrestricted.
+        assertThat(raw.getType()).isEqualTo(rawTypeBefore);
+    }
+
+    @Test
+    void typedTableClassOverloadDerivesTheSameColumns() {
+        AgentBuilder builder = appliedBuilder();
+
+        DataStream<Object> raw = builder.toDataStream();
+        TypeInformation<?> rawTypeBefore = raw.getType();
+
+        // The Class overload is a convenience that derives the 
TypeInformation internally.
+        Table table = builder.toTable(Score.class);
+
+        assertThat(table.getResolvedSchema().getColumnNames())
+                .containsExactlyInAnyOrder("value", "label");
+        // It carries the same anti-welding guarantee as the explicit 
TypeInformation overload.
+        assertThat(raw.getType()).isEqualTo(rawTypeBefore);
+    }
+}

Reply via email to