weiqingy commented on code in PR #1114:
URL: https://github.com/apache/flink-agents/pull/1114#discussion_r4031610061
##########
python/flink_agents/plan/agent_plan.py:
##########
@@ -293,6 +293,22 @@ def _to_plan_function(func: ApiFunction) -> PythonFunction
| JavaFunction:
raise TypeError(msg)
+def _check_tool_name_not_reserved(name: str) -> None:
Review Comment:
This guard runs at `:341` for the decorator and `:386` for `add_resource`,
but I don't see it on the MCP discovery loop at `:486-492`, which registers
each tool under the name the remote server chose (`name=tool.name`).
Java looks covered. MCP discovery there goes through `addResourceProvider`,
which calls `checkToolNameNotReserved` (`AgentPlan.java:694`). I walked the
other Python TOOL paths, and YAML funnels into `add_resource`, so this looked
like the only gap. I may have missed an entry point though.
So a server advertising `_subagent_lookup` registers a TOOL, and dispatch
then routes it to the AGENT namespace where it can never resolve. Contract 6
says a tool under the prefix is rejected at plan-construction, which holds when
the name is ours to choose. Here it isn't.
Adding `_check_tool_name_not_reserved(tool.name)` to that loop would close
it, but then one badly named remote tool fails the whole plan. Which would you
prefer, failing at discovery, or skipping just that tool with a warning so the
rest of the server still works?
##########
api/src/main/java/org/apache/flink/agents/api/subagent/SubagentSetup.java:
##########
@@ -19,22 +19,105 @@
package org.apache.flink.agents.api.subagent;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.flink.agents.api.context.RunnerContext;
import org.apache.flink.agents.api.resource.ResourceType;
import org.apache.flink.agents.api.resource.SerializableResource;
+import javax.annotation.Nullable;
+
/**
* Caller-facing definition of a sub-agent, registered in the agent plan as an
{@code AGENT}
* resource.
*/
public abstract class SubagentSetup extends SerializableResource {
+ /**
+ * Prefix of the callable name a sub-agent is exposed to a chat model
under. Tools are forbidden
+ * to register under this prefix, so a prefixed callable name
unambiguously addresses a
+ * sub-agent and the executing side routes it to the {@code AGENT}
namespace.
+ */
+ public static final String CALLABLE_NAME_PREFIX = "_subagent_";
Review Comment:
nit: the prefix is `_subagent_` here, but the description still says
`subagent_` throughout, including the runtime-flow paragraph and contract 1.
Two behavior claims also read the other way now. Contract 2 says a sub-agent
with no usable schema "is not offered, and does not stop the others", and the
table row says "dropped with a warning; job continues".
`BaseChatModelSetup.java:149` fails `open()` through `checkState` instead, and
`chat_model.py:451-457` raises to match.
Worth a pass over the description before merge? It is the part people read
later, and the contract list is detailed enough that the stale rows stand out.
##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java:
##########
@@ -344,6 +387,151 @@ private static void recordInlineResponse(
}
}
+ private static void dispatchAgentExecution(
+ ToolCallExecution execution,
+ RunnerContext ctx,
+ Map<String, Boolean> success,
+ Map<String, String> error,
+ Map<String, ToolResponse> responses)
+ throws InterruptedException {
+ try {
+ // submit() and await() already run through durable execution
inside the setup, so
+ // wrapping the call again here would nest durable cursors.
+ SubagentResult result = execution.agent.submit(ctx,
execution.agentArguments).await();
+ recordAgentResult(execution, result, ctx, success, error,
responses);
+ } catch (InterruptedException e) {
+ // A cancellation, not a sub-agent failure: propagate it exactly
like the tool paths do
+ // (#1111) so the caller skips sendEvent instead of folding the
cancellation into a
+ // tool-error response and driving a further chat call off it.
+ Thread.currentThread().interrupt();
+ throw e;
+ } catch (Exception e) {
Review Comment:
Both sub-agent dispatch paths catch `InterruptedException` then `Exception`,
but neither has a `catch (Error e)`. The tool paths do, at `:264` and `:327`.
`ToolResultUtils.requireJsonCompatible` walks `Map`, `List` and arrays with
no cycle guard and no depth limit (`ToolResultUtils.java:120-143`), so a result
that contains itself recurses until the stack runs out. That
`StackOverflowError` is an `Error`, so it passes these catches and fails the
job. Python's same recursion raises `RecursionError`, which is an `Exception`,
so dispatch absorbs it into a `ToolResponse` error. Contract 9 says an
inexpressible result is reported as a failed delegation, not made a job failure.
`InputSchemas.render` already carries a `catch (StackOverflowError)` for
this same shape (`InputSchemas.java:74-87`).
I can't say how likely a cyclic result is in practice. Neither suite has a
cycle or a deep-nesting case though, and the body calls result normalization
the highest-risk area. Would the same clause here work, or would you rather
thread an identity set through `requireJsonCompatible`?
##########
plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionSubagentTest.java:
##########
@@ -0,0 +1,673 @@
+/*
+ * 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.plan.actions;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.api.configuration.ReadableConfiguration;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.Outcome;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.event.ToolRequestEvent;
+import org.apache.flink.agents.api.event.ToolResponseEvent;
+import org.apache.flink.agents.api.memory.BaseLongTermMemory;
+import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.resource.Resource;
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+import org.apache.flink.agents.api.subagent.SubagentFutures;
+import org.apache.flink.agents.api.subagent.SubagentResult;
+import org.apache.flink.agents.api.subagent.SubagentSetup;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.api.tools.ToolMetadata;
+import org.apache.flink.agents.api.tools.ToolParameters;
+import org.apache.flink.agents.api.tools.ToolResponse;
+import org.apache.flink.agents.api.tools.ToolType;
+import org.apache.flink.agents.plan.AgentConfiguration;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/** Tests for dispatching a tool call to an {@code AGENT} resource. */
+class ToolCallActionSubagentTest {
+
+ @Test
+ void delegatesToTheSubagentAndReportsItsNormalizedResult() throws
Exception {
+ Map<String, Object> payload = new LinkedHashMap<>();
+ payload.put("verdict", "approved");
+ payload.put("findings", List.of("style"));
+ RecordingSubagentSetup agent = new
RecordingSubagentSetup(SubagentResult.ok(payload));
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", true);
+ assertThat(response.getResponses().get("call-1").getResult())
+
.isEqualTo("{\"verdict\":\"approved\",\"findings\":[\"style\"]}");
+ assertThat(response.getError()).doesNotContainKey("call-1");
+ }
+
+ @Test
+ void handsTheModelArgumentsToTheSubagentAsThePrompt() throws Exception {
+ RecordingSubagentSetup agent = new
RecordingSubagentSetup(SubagentResult.ok("done"));
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ assertThat(agent.prompts).containsExactly(Map.of("prompt", "review the
diff"));
+ // A sub-agent call resolves through the setup, which owns its own
durable execution.
+ assertThat(ctx.durableExecutions).isZero();
+ }
+
+ @Test
+ void reportsAFailedSubagentResultWithTheDetailExposedToTheModel() throws
Exception {
+ RecordingSubagentSetup agent =
+ new RecordingSubagentSetup(SubagentResult.error("upstream
refused"));
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", false);
+ assertThat(response.getResponses().get("call-1").getError())
+ .isEqualTo("Sub-agent _subagent_reviewer execute failed:
upstream refused");
+ assertThat(response.getError()).containsEntry("call-1", "upstream
refused");
+ }
+
+ @Test
+ void reportsAFailureRaisedWhileSubmitting() throws Exception {
+ RecordingSubagentSetup agent = new
RecordingSubagentSetup(SubagentResult.ok("unreachable"));
+ agent.submitFailure = new IllegalStateException("mailbox is full");
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", false);
+ assertThat(response.getResponses().get("call-1").getError())
+ .isEqualTo("Sub-agent _subagent_reviewer execute failed:
mailbox is full");
+ assertThat(response.getError()).containsEntry("call-1", "mailbox is
full");
+ }
+
+ @Test
+ void rejectsAResultJsonCannotExpress() throws Exception {
+ RecordingSubagentSetup agent =
+ new RecordingSubagentSetup(SubagentResult.ok(Map.of("handle",
new Object())));
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", false);
+ assertThat(response.getResponses().get("call-1").getError())
+ .startsWith("Sub-agent _subagent_reviewer execute failed")
+ .contains("result.handle");
+
assertThat(response.getError().get("call-1")).contains("result.handle");
+ }
+
+ /** A declared result type is what admits a result JSON cannot express on
its own. */
+ @Test
+ void readsAResultThroughTheTypeTheSubagentDeclares() throws Exception {
+ RecordingSubagentSetup agent =
+ new TypedRecordingSubagentSetup(SubagentResult.ok(new
Verdict(true, "clean")));
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", true);
+ assertThat(response.getResponses().get("call-1").getResult())
+ .isEqualTo("{\"approved\":true,\"note\":\"clean\"}");
+ }
+
+ /** The reserved prefix routes each namespace on its own, even under one
shared name. */
+ @Test
+ void routesAToolAndASubagentSharingANameToTheirOwnNamespace() throws
Exception {
+ FakeRunnerContext ctx =
+ new FakeRunnerContext()
+ .withAgent(
+ "reviewer", new
RecordingSubagentSetup(SubagentResult.ok("done")))
+ .withTool("reviewer", new StubTool("reviewer"));
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent delegated =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(delegated.getSuccess()).containsEntry("call-1", true);
+
assertThat(delegated.getResponses().get("call-1").getResult()).isEqualTo("done");
+ // A sub-agent call resolves through the setup, which owns its own
durable execution.
+ assertThat(ctx.durableExecutions).isZero();
+
+ ToolCallAction.processToolRequest(toolRequest("reviewer"), ctx);
+
+ ToolResponseEvent direct =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(1));
+ assertThat(direct.getSuccess()).containsEntry("call-1", true);
+
assertThat(direct.getResponses().get("call-1").getResult()).isEqualTo("reviewer
called");
+ assertThat(ctx.durableExecutions).isOne();
+ }
+
+ @Test
+ void refusesAnAgentResourceThatCarriesNoCallableSetup() throws Exception {
+ FakeRunnerContext ctx = new FakeRunnerContext();
+ ctx.agents.put("reviewer", new StubTool("reviewer"));
+
+ ToolCallAction.processToolRequest(toolRequest("_subagent_reviewer"),
ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", false);
+ assertThat(response.getResponses().get("call-1").getError())
+ .isEqualTo(
+ "Sub-agent _subagent_reviewer execute failed:
Sub-agent reviewer must"
+ + " resolve to a SubagentSetup, but was "
+ + StubTool.class.getName()
+ + ".");
+ assertThat(response.getError().get("call-1"))
+ .isEqualTo(
+ "Sub-agent reviewer must resolve to a SubagentSetup,
but was "
+ + StubTool.class.getName()
+ + ".");
+ }
+
+ @Test
+ void stillDispatchesAToolWhenBothKindsAreRegisteredUnderDifferentNames()
throws Exception {
+ FakeRunnerContext ctx =
+ new FakeRunnerContext()
+ .withAgent(
+ "reviewer", new
RecordingSubagentSetup(SubagentResult.ok("done")))
+ .withTool("queryOrder", new StubTool("queryOrder"));
+
+ ToolCallAction.processToolRequest(toolRequest("queryOrder"), ctx);
+
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess()).containsEntry("call-1", true);
+ assertThat(response.getResponses().get("call-1").getResult())
+ .isEqualTo("queryOrder called");
+ assertThat(ctx.durableExecutions).isOne();
+ }
+
+ /**
+ * The batched path runs sub-agent calls concurrently: every call is
submitted before any is
+ * awaited, so the async setups' remote runs overlap instead of blocking
one behind the next.
+ * The serial path interleaves submit and await per call, which this order
assertion rejects.
+ */
+ @Test
+ void submitsEverySubagentCallBeforeAwaitingAnyUnderParallelDispatch()
throws Exception {
+ List<String> ops = new ArrayList<>();
+ FakeRunnerContext ctx =
+ new FakeRunnerContext()
+ .withParallelToolCalls()
+ .withAgent("a", new OrderRecordingSubagentSetup("a",
ops))
+ .withAgent("b", new OrderRecordingSubagentSetup("b",
ops));
+
+ ToolCallAction.processToolRequest(twoSubagentRequest("a", "b"), ctx);
+
+ assertThat(ops).containsExactly("submit:a", "submit:b", "await:a",
"await:b");
+ ToolResponseEvent response =
ToolResponseEvent.fromEvent(ctx.sentEvents.get(0));
+ assertThat(response.getSuccess())
+ .containsEntry("call-1", true)
+ .containsEntry("call-2", true);
+
assertThat(response.getResponses().get("call-1").getResult()).isEqualTo("a
done");
+
assertThat(response.getResponses().get("call-2").getResult()).isEqualTo("b
done");
+ }
+
+ /**
+ * A cancelled sub-agent call must propagate like a cancelled tool call
(#1111), not be folded
+ * into a tool-error response: no ToolResponseEvent goes out, so no
further chat call is driven
+ * off a cancelled delegation and the action is not persisted as completed
on the back of it.
+ */
+ @Test
+ void propagatesInterruptionFromASubagentCallInsteadOfRecordingAFailure()
throws Exception {
+ RecordingSubagentSetup agent = new
RecordingSubagentSetup(SubagentResult.ok("unreachable"));
+ agent.submitFailure = new InterruptedException("cancelled");
+ FakeRunnerContext ctx = new FakeRunnerContext().withAgent("reviewer",
agent);
+
+ Thread.interrupted();
+
+ assertThatExceptionOfType(InterruptedException.class)
+ .isThrownBy(
+ () ->
+ ToolCallAction.processToolRequest(
+ toolRequest("_subagent_reviewer"),
ctx));
+
+ assertThat(Thread.interrupted()).as("interrupt status should be
restored").isTrue();
+ assertThat(ctx.sentEvents).isEmpty();
+ }
+
+ /**
+ * Under the batched path, a cancellation while awaiting one sub-agent
must propagate (#1111)
+ * and must not leave the other already-submitted handles dangling: the
interrupted handle and
+ * every later one, submitted but now never awaited, are cancelled on the
way out.
+ */
+ @Test
+ void
propagatesInterruptionUnderParallelDispatchAndCancelsSubmittedHandles() throws
Exception {
+ List<String> ops = new ArrayList<>();
+ FakeRunnerContext ctx =
+ new FakeRunnerContext()
+ .withParallelToolCalls()
+ .withAgent("a", new InterruptingSubagentSetup("a",
ops))
+ .withAgent("b", new OrderRecordingSubagentSetup("b",
ops));
+
+ Thread.interrupted();
+
+ assertThatExceptionOfType(InterruptedException.class)
+ .isThrownBy(
+ () ->
ToolCallAction.processToolRequest(twoSubagentRequest("a", "b"), ctx));
+
+ assertThat(Thread.interrupted()).as("interrupt status should be
restored").isTrue();
+ assertThat(ctx.sentEvents).isEmpty();
+ assertThat(ops).containsExactly("submit:a", "submit:b", "await:a",
"cancel:a", "cancel:b");
+ }
+
+ private static ToolRequestEvent toolRequest(String callableName) {
+ return new ToolRequestEvent(
+ "model",
+ List.of(
+ Map.of(
+ "id",
+ "call-1",
+ "type",
+ "function",
+ "function",
+ Map.of(
+ "name",
+ callableName,
+ "arguments",
+ Map.of("prompt", "review the
diff")))));
+ }
+
+ /** One request carrying two sub-agent calls, so the batched path has more
than one to run. */
+ private static ToolRequestEvent twoSubagentRequest(String first, String
second) {
Review Comment:
Both tests that enable the batched path use this builder, so every call in
them is a sub-agent and `toolExecutions` ends up empty.
`recordOutcome(toolExecutions.get(i), outcomes.get(i), ...)` at
`ToolCallAction.java:251` therefore never runs against a non-empty list.
That pairing is what keeps each tool's result on its own call id once
sub-agents are filtered out of the batch. If the two lists drift, one call's
result lands on another call's id and both tests still pass.
A mixed call also seems like the common case, since a model with both tools
and sub-agents available can call one of each in a turn.
Would a third parallel case, one `_subagent_` plus one tool, checking each
id gets its own result, be worth adding? `withParallelToolCalls()` and
`durableExecuteAllAsync` are already in the fixture, so it looks mostly like a
new request builder.
##########
api/src/main/java/org/apache/flink/agents/api/subagent/InputSchemas.java:
##########
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.subagent;
+
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import javax.annotation.Nullable;
+
+/**
+ * Renders the input type a sub-agent declares as the JSON Schema a chat model
is told about, so
+ * that a sub-agent which types its arguments does not also have to spell out
their schema.
+ *
+ * <p>Rendering goes through the same Jackson generator {@code ReActAgent}
renders a POJO output
+ * schema with, which keeps the two type-to-schema paths in this module on one
implementation and
+ * adds no dependency.
+ */
+final class InputSchemas {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private InputSchemas() {}
+
+ /**
+ * The schema of {@code type}, or {@code null} when the type states no
shape a model could build
+ * a call from. That is {@link Object}, the type a sub-agent declares when
it declares none, and
+ * any type that does not render as a JSON object, because the parameters
of a callable must be
+ * one.
+ *
+ * @throws IllegalArgumentException if rendering the type fails, which is
a declaration mistake
+ * worth failing on rather than dropping silently.
+ */
+ @Nullable
+ static String fromType(@Nullable Class<?> type) {
+ if (type == null || type == Object.class) {
+ return null;
+ }
+ JsonNode schema = render(type);
+ return "object".equals(schema.path("type").asText()) ?
schema.toString() : null;
+ }
+
+ private static JsonNode render(Class<?> type) {
+ try {
+ return MAPPER.generateJsonSchema(type).getSchemaNode();
Review Comment:
I ran the pinned jackson-databind 2.18.2 (`pom.xml:48`) and pydantic 2.11.4
over the `Review` type this PR mirrors on both sides.
Java:
```
{"type":"object","properties":{"path":{"type":"string"},"lines":{"type":"integer"}}}
```
Python:
```
{"properties":{"path":{"title":"Path","type":"string"},"lines":{"default":0,"title":"Lines","type":"integer"}},"required":["path"],"title":"Review","type":"object"}
```
The two sides express `required` differently. Jackson's legacy generator
writes it per property, and only under `@JsonProperty(required = true)`.
Nothing on this path sets that, so Java marks no field required at all.
Pydantic writes the object-level `"required":["path"]`. Same declaration, two
different messages to the model about which arguments it must send.
The mirror tests check `type` plus the two property types
(`SubagentSetupTest.java:156-166`, `test_subagent.py:98-104`), which is exactly
where the two outputs agree. So contract 12 passes while the schemas differ on
the field that changes what the model sends.
Is that deliberate? If not, would you rather fill `required` from the
non-defaulted properties on the Java side, or have both mirror tests assert the
whole schema so the next drift fails a build?
nit: from the same run, a `byte[]` field renders in Java as
`{"type":"array","items":{"type":"byte"}}`, and `byte` is not a JSON Schema
type. Python gives `{"type":"string","format":"binary"}`.
--
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]