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 a6848aff6 [python][plan] Split chat model action into per-model 
attempts (#1357)
a6848aff6 is described below

commit a6848aff617434a2ec02322c27416c96b91090fa
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Fri Nov 6 15:23:09 2026 +0900

    [python][plan] Split chat model action into per-model attempts (#1357)
    
    Generated-by: Codex CLI 0.166.0 (gpt-6-astra)
---
 python/flink_agents/api/chat_models/chat_model.py  | 49 +++++++------
 .../flink_agents/plan/actions/chat_model_action.py | 82 ++++++++++++++++------
 .../tests/actions/test_chat_model_action_retry.py  | 42 +++++++++++
 3 files changed, 131 insertions(+), 42 deletions(-)

diff --git a/python/flink_agents/api/chat_models/chat_model.py 
b/python/flink_agents/api/chat_models/chat_model.py
index 1ee890839..d2560f159 100644
--- a/python/flink_agents/api/chat_models/chat_model.py
+++ b/python/flink_agents/api/chat_models/chat_model.py
@@ -588,6 +588,34 @@ class BaseChatModelSetup(Resource):
             )
         self.tools = callables
 
+    def prepare_request_messages(
+        self,
+        messages: Sequence[ChatMessage],
+        prompt_args: Mapping[str, Any] | None = None,
+    ) -> Sequence[ChatMessage]:
+        """Render the setup prompt and skill discovery for a chat request."""
+        # Apply prompt template
+        if self.prompt is not None:
+            str_prompt_args: Dict[str, str] = (
+                {k: str(v) for k, v in prompt_args.items()} if prompt_args 
else {}
+            )
+            prompt_messages = 
self._get_prompt().format_messages(**str_prompt_args)
+
+            # append meaningful messages
+            # any block counts, so image-only messages survive
+            for msg in messages:
+                if len(msg.blocks) > 0 or msg.role == MessageRole.ASSISTANT:
+                    prompt_messages.append(msg)
+            messages = prompt_messages
+
+        if self.skill_discovery_prompt:
+            # Right after the first system message, or at the head when there
+            # is none.
+            index = find_first_system_message(messages) + 1
+            injected = [ChatMessage.system(self.skill_discovery_prompt)]
+            messages = list(messages[:index]) + injected + 
list(messages[index:])
+        return messages
+
     def chat(
         self,
         messages: Sequence[ChatMessage],
@@ -638,26 +666,7 @@ class BaseChatModelSetup(Resource):
             raise ValueError(msg)
 
         if tools is None:
-            # Apply prompt template
-            if self.prompt is not None:
-                str_prompt_args: Dict[str, str] = (
-                    {k: str(v) for k, v in prompt_args.items()} if prompt_args 
else {}
-                )
-                prompt_messages = 
self._get_prompt().format_messages(**str_prompt_args)
-
-                # append meaningful messages
-                # any block counts, so image-only messages survive
-                for msg in messages:
-                    if len(msg.blocks) > 0 or msg.role == 
MessageRole.ASSISTANT:
-                        prompt_messages.append(msg)
-                messages = prompt_messages
-
-            if self.skill_discovery_prompt:
-                # Right after the first system message, or at the head when 
there
-                # is none.
-                index = find_first_system_message(messages) + 1
-                injected = [ChatMessage.system(self.skill_discovery_prompt)]
-                messages = list(messages[:index]) + injected + 
list(messages[index:])
+            messages = self.prepare_request_messages(messages, prompt_args)
 
         # Call chat model connection to execute chat
         merged_kwargs = self.model_kwargs.copy()
diff --git a/python/flink_agents/plan/actions/chat_model_action.py 
b/python/flink_agents/plan/actions/chat_model_action.py
index 596f4efae..1496e6703 100644
--- a/python/flink_agents/plan/actions/chat_model_action.py
+++ b/python/flink_agents/plan/actions/chat_model_action.py
@@ -20,6 +20,7 @@ import logging
 import re
 import time
 from concurrent.futures import CancelledError
+from dataclasses import dataclass
 from functools import wraps
 from typing import TYPE_CHECKING, Any, Callable, Dict, List, Sequence, cast
 from uuid import UUID
@@ -76,9 +77,6 @@ _FINALIZE_DIRECTIVE = (
     " Preserve its meaning and do not add or infer any new information.\n\n"
     "Response:\n"
 )
-# The loop call's own record already pins the answer this call converts, so a 
fixed
-# identity is enough and stays stable whatever form the arguments take on 
recovery.
-_FINALIZE_DURABLE_ID = "chat:final"
 
 _logger = logging.getLogger(__name__)
 
@@ -557,25 +555,34 @@ def _accept_model_response(
     return response
 
 
-async def chat(
+@dataclass
+class _ChatAttemptResult:
+    response: ChatResult
+    chat_model: "BaseChatModelSetup"
+    structured_output: Any
+
+
+class _ChatAttemptFailed(Exception):
+    """A candidate exhausted its retry budget or could not be prepared."""
+
+    def __init__(self, error: Exception) -> None:
+        super().__init__(str(error))
+        self.error = error
+
+
+async def _chat_with_retries(
     initial_request_id: UUID,
     model: str,
     messages: List[ChatMessage],
     prompt_args: Dict | None,
     output_schema: OutputSchema | None,
     ctx: RunnerContext,
-) -> None:
-    """Chat with llm.
-
-    If there is no tool call generated, we return the chat response event 
directly,
-    otherwise, we generate tool request event according to the tool calls in 
chat model
-    response, and save the request and response messages in tool call context.
+    durable_id: str = _CHAT_DURABLE_ID,
+) -> _ChatAttemptResult:
+    """Attempt one model without sending a response or request failure event.
 
-    When ``output_schema`` is set and the chat model reports that it travels 
through
-    the provider's native structured output, the final answer is followed by 
one more
-    call without tools that carries the schema, and that call's response is 
parsed.
-    The extra call belongs to the same attempt, so its failures, and a failure 
to
-    parse its response, consume the same retry budget.
+    Provider and preparation failures raise ``_ChatAttemptFailed``. 
Cancellation
+    and durable storage failures propagate unchanged to the runtime.
     """
     try:
         chat_model = cast(
@@ -584,8 +591,7 @@ async def chat(
     except (CancelledError, InterruptedError):
         raise
     except Exception as error:
-        _send_failure(initial_request_id, error, ctx)
-        return
+        raise _ChatAttemptFailed(error) from error
 
     # Evaluated once: the answer depends only on the chat model and the 
schema, and
     # a raise (a NATIVE strategy the connection cannot honor) cannot change on 
retry.
@@ -600,8 +606,7 @@ async def chat(
         except (CancelledError, InterruptedError):
             raise
         except Exception as error:
-            _send_failure(initial_request_id, error, ctx)
-            return
+            raise _ChatAttemptFailed(error) from error
 
     request_metric_group = ctx.action_metric_group
 
@@ -669,7 +674,7 @@ async def chat(
                 messages,
                 chat_async=chat_async,
                 prompt_args=prompt_args,
-                durable_id=_CHAT_DURABLE_ID,
+                durable_id=durable_id,
             )
             try:
                 response = _accept_model_response(
@@ -705,7 +710,7 @@ async def chat(
                     response.text,
                     output_schema,
                     chat_async=chat_async,
-                    durable_id=_FINALIZE_DURABLE_ID,
+                    durable_id=f"{durable_id}:final",
                 )
                 try:
                     structured = _accept_model_response(
@@ -741,8 +746,41 @@ async def chat(
         )
 
     if final_error is not None:
-        _send_failure(initial_request_id, final_error, ctx)
+        raise _ChatAttemptFailed(final_error) from final_error
+
+    return _ChatAttemptResult(response, chat_model, structured_output)
+
+
+async def chat(
+    initial_request_id: UUID,
+    model: str,
+    messages: List[ChatMessage],
+    prompt_args: Dict | None,
+    output_schema: OutputSchema | None,
+    ctx: RunnerContext,
+) -> None:
+    """Chat with llm.
+
+    If there is no tool call generated, we return the chat response event 
directly,
+    otherwise, we generate tool request event according to the tool calls in 
chat model
+    response, and save the request and response messages in tool call context.
+
+    When ``output_schema`` is set and the chat model reports that it travels 
through
+    the provider's native structured output, the final answer is followed by 
one more
+    call without tools that carries the schema, and that call's response is 
parsed.
+    The extra call belongs to the same attempt, so its failures, and a failure 
to
+    parse its response, consume the same retry budget.
+    """
+    try:
+        attempt = await _chat_with_retries(
+            initial_request_id, model, messages, prompt_args, output_schema, 
ctx
+        )
+    except _ChatAttemptFailed as failure:
+        _send_failure(initial_request_id, failure.error, ctx)
         return
+    response = attempt.response
+    chat_model = attempt.chat_model
+    structured_output = attempt.structured_output
 
     if (
         len(response.tool_calls) > 0
diff --git 
a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py 
b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
index 8169f9a2d..0ca3b9023 100644
--- a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
+++ b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
@@ -48,6 +48,8 @@ from flink_agents.api.trace import (
     LLMExecutionMetadataKeys,
 )
 from flink_agents.plan.actions.chat_model_action import (
+    _chat_with_retries,
+    _ChatAttemptFailed,
     chat,
     process_chat_request_or_tool_response,
 )
@@ -1373,3 +1375,43 @@ class TestNativeStructuredOutputFinalization:
         assert ctx.durable_execute_async.call_count == 2
         ctx.durable_execute.assert_not_called()
         assert sent_events[0].structured_output.result == 42
+
+
[email protected]("chat_async", [False, True])
+def 
test_candidate_attempt_exhausts_retries_without_emitting_failure(chat_async):
+    model = MagicMock()
+    model.chat.side_effect = RuntimeError("candidate unavailable")
+    ctx, events, metrics, memory = _create_mock_runner_context(
+        model, max_retries=2, retry_wait_interval_sec=0, chat_async=chat_async
+    )
+    request_id = uuid4()
+    with pytest.raises(_ChatAttemptFailed) as caught:
+        asyncio.run(_chat_with_retries(
+            request_id, "candidate", [ChatMessage.user("hello")], None, None,
+            ctx, durable_id="chat:router:candidate",
+        ))
+    assert str(caught.value.error) == "RuntimeError: candidate unavailable"
+    assert caught.value.__cause__ is caught.value.error
+    assert events == []
+    assert model.chat.call_count == 3
+    durable = ctx.durable_execute_async if chat_async else ctx.durable_execute
+    assert [c.kwargs["durable_id"] for c in durable.call_args_list] == [
+        "chat:router:candidate"
+    ] * 3
+    assert 
memory.get("_RETRY_STATS_CONTEXT")[str(request_id)]["total_retry_count"] == 2
+    assert metrics.get_sub_group("model_resource", 
"candidate").get_counter("retryCount").get_count() == 2
+
+
+def test_candidate_native_finalization_uses_attempt_identity():
+    model = _native_chat_model()
+    ctx, events, _, _ = _native_context(model)
+    result = asyncio.run(_chat_with_retries(
+        uuid4(), "candidate", [ChatMessage.user("hello")], None,
+        OutputSchema(output_schema=_StructuredResult), ctx,
+        durable_id="chat:router:candidate",
+    ))
+    assert result.structured_output is not None
+    assert events == []
+    assert [c.kwargs["durable_id"] for c in 
ctx.durable_execute.call_args_list] == [
+        "chat:router:candidate", "chat:router:candidate:final"
+    ]

Reply via email to