weiqingy commented on code in PR #924:
URL: https://github.com/apache/flink-agents/pull/924#discussion_r3654253842


##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -368,16 +370,27 @@ public ChatMessage call() throws Exception {
 
         for (int attempt = 0; attempt < numRetries + 1; attempt++) {
             try {
-                response =
-                        chatAsync
-                                ? ctx.durableExecuteAsync(callable)
-                                : ctx.durableExecute(callable);
+                ExecutionReporters.started(ctx, 
ExecutionReporter.EntityTypes.LLM, model);

Review Comment:
   `started` here and `succeeded` at `:389` bracket `ctx.durableExecute(...)`, 
but on replay that returns the cached result and short-circuits 
(`RunnerContextImpl.java:383-385`) while the action body still re-runs. Your 
own `testEarlierCheckpointReplayKeepsDurableState:1039` pins that: 
`DURABLE_CALL_COUNTER` stays at 1 while the output is produced again.
   
   So after a fine-grained recovery the log gets a complete `started` plus 
`succeeded` pair, with a fresh `executionId`, for a model call that never 
happened. `ToolCallAction` has the same shape. Since counting LLM calls is the 
first thing anyone does with this log, cost dashboards would over-report spend 
and under-report latency.
   
   What makes me read this as unintended: the Action level already has 
`ExecutionLifecycleEvents.executionReused()` for exactly this case, but nothing 
below Action can emit it, since `ExecutionReporter` has no such method.
   
   Could a cached durable result surface a reused signal that the reporters 
emit instead of `started`/`succeeded`? Or does the reporting want to move 
inside the durable boundary? And if it's out of scope here, would naming it in 
the monitoring doc be enough for now?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/eventlog/FileEventLogger.java:
##########
@@ -199,24 +207,18 @@ public void append(EventContext context, Event event) 
throws Exception {
         }
         ObjectNode rootNode = (ObjectNode) tree;
 
-        // Truncate the event subtree at STANDARD level.
+        // Truncate event attributes at STANDARD level.
         if (level == EventLogLevel.STANDARD && truncator != null) {
-            JsonNode eventNode = rootNode.get("event");
-            if (eventNode instanceof ObjectNode) {
-                boolean truncated = truncator.truncate((ObjectNode) eventNode);
+            JsonNode attributesNode = rootNode.get("eventAttributes");

Review Comment:
   Passing `rootNode.get("eventAttributes")` into `truncator.truncate(...)` 
narrows truncation in two ways. Same block at `Slf4jEventLogger.java:179-181`, 
so both loggers are affected.
   
   The protections now point at the wrong names. `JsonTruncator` is untouched 
by this PR and still skips `PROTECTED_FIELDS = {eventType, id, attributes}` at 
`isTopLevel` (`:55-56`, `:117-119`). Those were envelope names; under the flat 
shape they are user attribute names, so an attribute called `id` escapes 
`event-log.standard.max-string-length` at STANDARD. 
`JsonTruncatorTest.testProtectedFields:136-161` still pins the old shape and 
passes, so CI won't catch it. Worth noting #923 adds `upstreamEventId` and 
`upstreamActionName` to that same set, which won't take effect under the flat 
shape either.
   
   Separately, `entityMetadata` sits as a sibling of `eventAttributes` 
(`EventLogRecordJsonSerializer.java:73`), so the truncator never sees it. It's 
fed by the public `ToolExecutionMetadataProvider` hook, and 
`LoadSkillTool.java:68-80` copies the model-supplied `name` and `path` straight 
in, so an LLM-controlled value lands unbounded.
   
   Would running the truncator over the whole record, with the new framework 
field names protected, close both at once? That was my first instinct, though I 
may be missing why the narrowing was deliberate. Either way, would 
`testProtectedFields` want re-pointing at what the production path now passes 
in?



##########
plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRetryTest.java:
##########
@@ -127,6 +128,129 @@ void chatSucceedsWithoutRetry_retryCountIsZero() throws 
Exception {
         verify(mockActionMetricGroup, never()).getSubGroup(anyString(), 
anyString());
     }
 
+    @Test
+    void chatReportsLlmExecution() throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(new ChatMessage(MessageRole.ASSISTANT, "hello"));
+
+        ChatModelAction.chat(
+                UUID.randomUUID(),
+                "test-model",
+                List.of(new ChatMessage(MessageRole.USER, "hi")),
+                Map.of(),
+                null,
+                reportingCtx);
+
+        ExecutionReporter reporter = (ExecutionReporter) reportingCtx;
+        verify(reporter)
+                .reportExecutionStarted(ExecutionReporter.EntityTypes.LLM, 
"test-model", Map.of());
+        verify(reporter)
+                .reportExecutionSucceeded(
+                        ExecutionReporter.EntityTypes.LLM, "test-model", 
Map.of());
+    }
+
+    @Test
+    void chatReportsStructuredOutputParserExecution() throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(new ChatMessage(MessageRole.ASSISTANT, 
"{\"answer\":\"42\"}"));
+
+        ChatModelAction.chat(
+                UUID.randomUUID(),
+                "test-model",
+                List.of(new ChatMessage(MessageRole.USER, "hi")),
+                Map.of(),
+                Map.class,
+                reportingCtx);
+
+        ExecutionReporter reporter = (ExecutionReporter) reportingCtx;
+        verify(reporter)
+                .reportExecutionStarted(
+                        ExecutionReporter.EntityTypes.PARSER, 
Agent.STRUCTURED_OUTPUT, Map.of());
+        verify(reporter)
+                .reportExecutionSucceeded(
+                        ExecutionReporter.EntityTypes.PARSER, 
Agent.STRUCTURED_OUTPUT, Map.of());
+    }
+
+    @Test
+    void chatRetriesStructuredOutputParseErrorWithoutFailingLlm() throws 
Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(reportingCtx.getConfig())
+                .thenReturn(readableConfig(Agent.ErrorHandlingStrategy.RETRY, 
1, 0));
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(MessageRole.ASSISTANT, "not-json"),
+                        new ChatMessage(MessageRole.ASSISTANT, 
"{\"answer\":\"42\"}"));
+
+        ChatModelAction.chat(
+                UUID.randomUUID(),
+                "test-model",
+                List.of(new ChatMessage(MessageRole.USER, "hi")),
+                Map.of(),
+                Map.class,
+                reportingCtx);
+
+        verify(chatModel, times(2)).chat(any(), any(), any());
+
+        ExecutionReporter reporter = (ExecutionReporter) reportingCtx;
+        verify(reporter, times(2))
+                .reportExecutionStarted(ExecutionReporter.EntityTypes.LLM, 
"test-model", Map.of());
+        verify(reporter, times(2))
+                .reportExecutionSucceeded(
+                        ExecutionReporter.EntityTypes.LLM, "test-model", 
Map.of());
+        verify(reporter)
+                .reportExecutionFailed(
+                        eq(ExecutionReporter.EntityTypes.PARSER),
+                        eq(Agent.STRUCTURED_OUTPUT),
+                        eq(Map.of()),
+                        any(Exception.class),
+                        
eq(ExecutionReporter.ProblemCategories.MODEL_OUTPUT_PARSE_ERROR));
+        verify(reporter, never())
+                .reportExecutionFailed(
+                        eq(ExecutionReporter.EntityTypes.LLM),
+                        eq("test-model"),
+                        eq(Map.of()),
+                        any(Throwable.class),
+                        any());
+    }
+
+    @Test
+    void chatReportsEachRetriedModelInvocation() throws Exception {

Review Comment:
   Anchoring here since this is where the contract gets pinned: 
`chatReportsEachRetriedModelInvocation` asserts `times(2)` on 
`reportExecutionStarted`. `started` sits inside the retry loop 
(`ChatModelAction.java:371-373`), and Python matches 
(`chat_model_action.py:352-375`), so both languages emit one execution per 
attempt.
   
   On the design discussion the contract landed on one execution per framework 
model invocation, with retries collapsing into it and Parser recorded 
separately. Parser is implemented that way (`:457-474`); the retry half went 
the other way.
   
   Downstream, a `retryCount` of 3 on a successful request reads as 4 LLM 
executions at a 25% success rate, and nothing in the record separates "attempt 
2" from "a second call". `ChatResponseEvent` already carries `retryCount`, so 
the two views disagree.
   
   Which way did you land in the end? Two shapes if either helps: hoist 
`started`/`succeeded` outside the loop and put attempts on the single terminal, 
or keep per-attempt and add an ordinal plus a shared correlation id. Either 
way, would it be worth pinning in `ExecutionReporter`'s javadoc, now that it's 
permanent public API?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to