Skip to content

Conversation

@mengnankkkk
Copy link

@mengnankkkk mengnankkkk commented Jan 17, 2026

AgentScope-Java Version

[The version of AgentScope-Java you are working on, e.g. 1.0.7, check your pom.xml dependency version or run mvn dependency:tree | grep agentscope-parent:pom(only mac/linux)]

Description

[Please describe the background, purpose, changes made, and how to test this PR]
qwen3max:

Behavior: ToolUseBlock is not returned during the streaming phase; it is returned all at once at the last moment.

Streaming Phase: No ToolCallStart. A replacement is sent when END is detected.

点击查看测试代码
/*
 * Copyright 2024-2026 the original author or authors.
 *
 * Licensed 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 io.agentscope.core.agui.adapter;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.core.tool.Toolkit;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;


class AguiAgentAdapterRealModelIntegrationTest {

    private static final String API_KEY_ENV = "DASHSCOPE_API_KEY";
    private static final String MODEL_NAME = "qwen3-max"; // 使用 qwen3-max 测试 ToolUseBlock 延迟返回

    /**
     * 测试工具类:提供天气查询功能。
     */
    public static class WeatherTools {
        @Tool(name = "get_weather", description = "获取指定城市的天气信息")
        public Map<String, Object> getWeather(
                @ToolParam(name = "city", description = "城市名称", required = true) String city) {
            return Map.of(
                    "city", city,
                    "temperature", "25°C",
                    "condition", "晴天",
                    "humidity", "60%");
        }
    }

    /**
     * 测试工具类:提供多个工具。
     */
    public static class MultiTools {
        @Tool(name = "get_weather", description = "获取指定城市的天气信息")
        public Map<String, Object> getWeather(
                @ToolParam(name = "city", description = "城市名称", required = true) String city) {
            return Map.of("city", city, "temperature", "25°C");
        }

        @Tool(name = "get_time", description = "获取指定城市的当前时间")
        public Map<String, Object> getTime(
                @ToolParam(name = "city", description = "城市名称", required = true) String city) {
            return Map.of("city", city, "time", "14:30");
        }
    }

    /**
     * 测试工具类:会失败的工具。
     */
    public static class FailingTools {
        @Tool(name = "failing_tool", description = "一个会失败的测试工具")
        public String failingTool(
                @ToolParam(name = "input", description = "输入参数", required = true) String input) {
            throw new RuntimeException("工具执行失败:模拟错误");
        }
    }

    /**
     * 测试真实模型调用场景下的 ToolCallStart 补发逻辑。
     *
     * <p>场景:使用真实的 qwen3-max 模型,该模型可能在流式阶段不返回 ToolUseBlock,
     * 而是在最后一次性返回。适配器应该能够正确处理这种情况,补发 ToolCallStart 事件。
     */
    @Test
    void testRealModelToolCallStartBackfill() {
        // 获取 API key(优先从系统属性,然后从环境变量)
        String apiKey = System.getProperty(API_KEY_ENV, System.getenv(API_KEY_ENV));
        assertNotNull(apiKey, "需要设置 " + API_KEY_ENV + " 环境变量或系统属性");
        assertFalse(apiKey.isBlank(), API_KEY_ENV + " 不能为空");

        // 创建工具包并注册工具
        Toolkit toolkit = new Toolkit();
        toolkit.registration().tool(new WeatherTools()).apply();

        // 创建真实的 DashScope 模型
        DashScopeChatModel model =
                DashScopeChatModel.builder().apiKey(apiKey).modelName(MODEL_NAME).stream(true)
                        .formatter(new DashScopeChatFormatter())
                        .build();

        // 创建 ReActAgent
        ReActAgent agent =
                ReActAgent.builder()
                        .name("WeatherAgent")
                        .model(model)
                        .toolkit(toolkit)
                        .memory(new InMemoryMemory())
                        .maxIters(3) // 限制迭代次数,避免过多 API 调用
                        .build();

        // 创建 AGUI 适配器
        AguiAgentAdapter adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.defaultConfig());

        // 准备输入:要求查询天气
        RunAgentInput input =
                RunAgentInput.builder()
                        .threadId("thread-real-1")
                        .runId("run-real-1")
                        .messages(List.of(AguiMessage.userMessage("msg-1", "请帮我查询北京的天气情况")))
                        .build();

        // 执行并收集事件(设置较长的超时时间)
        List<AguiEvent> events = adapter.run(input).collectList().block(Duration.ofSeconds(60));

        // 验证结果
        assertNotNull(events, "事件列表不应为空");
        assertFalse(events.isEmpty(), "应该有事件返回");

        System.out.println("\n========== 收到的事件序列 ==========");
        for (int i = 0; i < events.size(); i++) {
            AguiEvent event = events.get(i);
            System.out.println(i + ": " + event.getClass().getSimpleName() + " - " + event);
        }

        // 验证基本事件
        assertTrue(
                events.stream().anyMatch(e -> e instanceof AguiEvent.RunStarted),
                "应该有 RunStarted 事件");
        assertTrue(
                events.stream().anyMatch(e -> e instanceof AguiEvent.RunFinished),
                "应该有 RunFinished 事件");

        // 查找工具调用相关事件
        List<AguiEvent.ToolCallStart> toolStarts =
                events.stream()
                        .filter(e -> e instanceof AguiEvent.ToolCallStart)
                        .map(e -> (AguiEvent.ToolCallStart) e)
                        .toList();

        List<AguiEvent.ToolCallArgs> toolArgs =
                events.stream()
                        .filter(e -> e instanceof AguiEvent.ToolCallArgs)
                        .map(e -> (AguiEvent.ToolCallArgs) e)
                        .toList();

        List<AguiEvent.ToolCallEnd> toolEnds =
                events.stream()
                        .filter(e -> e instanceof AguiEvent.ToolCallEnd)
                        .map(e -> (AguiEvent.ToolCallEnd) e)
                        .toList();

        List<AguiEvent.ToolCallResult> toolResults =
                events.stream()
                        .filter(e -> e instanceof AguiEvent.ToolCallResult)
                        .map(e -> (AguiEvent.ToolCallResult) e)
                        .toList();

        // 验证工具调用事件
        if (!toolStarts.isEmpty()) {
            System.out.println("检测到工具调用");

            // 验证每个 ToolCallStart 都有对应的 ToolCallEnd
            for (AguiEvent.ToolCallStart start : toolStarts) {
                String toolCallId = start.toolCallId();
                System.out.println(
                        "  - ToolCallStart: id=" + toolCallId + ", name=" + start.toolCallName());

                boolean hasEnd =
                        toolEnds.stream().anyMatch(end -> end.toolCallId().equals(toolCallId));
                assertTrue(hasEnd, "ToolCallStart (id=" + toolCallId + ") 应该有对应的 ToolCallEnd");

                // 验证事件顺序:Start 应该在 End 之前
                int startIndex = events.indexOf(start);
                int endIndex =
                        events.indexOf(
                                toolEnds.stream()
                                        .filter(end -> end.toolCallId().equals(toolCallId))
                                        .findFirst()
                                        .orElse(null));
                assertTrue(
                        startIndex < endIndex,
                        "ToolCallStart 应该在 ToolCallEnd 之前 (start="
                                + startIndex
                                + ", end="
                                + endIndex
                                + ")");
            }

            // 验证 ToolCallArgs 事件
            if (!toolArgs.isEmpty()) {
                System.out.println("✓ 检测到工具参数事件");

                // 拼接所有 delta 以获取完整参数
                StringBuilder fullArgs = new StringBuilder();
                for (AguiEvent.ToolCallArgs args : toolArgs) {
                    System.out.println(
                            "  - ToolCallArgs: id="
                                    + args.toolCallId()
                                    + ", delta="
                                    + args.delta());
                    fullArgs.append(args.delta());
                }

                // 验证完整参数包含城市信息
                String fullArgsStr = fullArgs.toString();
                assertTrue(
                        fullArgsStr.contains("北京") || fullArgsStr.contains("city"),
                        "工具参数应该包含城市信息,实际参数:" + fullArgsStr);

                // 验证 ToolCallArgs 在 ToolCallStart 之后
                if (!toolArgs.isEmpty()) {
                    AguiEvent.ToolCallArgs firstArgs = toolArgs.get(0);
                    AguiEvent.ToolCallStart correspondingStart =
                            toolStarts.stream()
                                    .filter(s -> s.toolCallId().equals(firstArgs.toolCallId()))
                                    .findFirst()
                                    .orElse(null);

                    if (correspondingStart != null) {
                        int argsIndex = events.indexOf(firstArgs);
                        int startIndex = events.indexOf(correspondingStart);
                        assertTrue(argsIndex > startIndex, "ToolCallArgs 应该在 ToolCallStart 之后");
                    }
                }
            } else {
                System.out.println("注意:没有检测到 ToolCallArgs 事件");
            }

            // 验证工具结果
            if (!toolResults.isEmpty()) {
                System.out.println("检测到工具结果");
                for (AguiEvent.ToolCallResult result : toolResults) {
                    System.out.println(
                            "  - ToolCallResult: id="
                                    + result.toolCallId()
                                    + ", content="
                                    + result.content());
                    assertTrue(
                            result.content().contains("25°C")
                                    || result.content().contains("晴天")
                                    || result.content().contains("北京"),
                            "工具结果应该包含天气信息");
                }
            }

            System.out.println("\n测试通过:适配器正确处理了真实模型的工具调用");
        } else {

            // 即使没有工具调用,也验证基本流程正常
            assertTrue(
                    events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageContent),
                    "应该至少有文本消息内容");
        }
    }

    /**
     * 测试真实模型在多轮对话中的工具调用。
     *
     * <p>场景:测试在多轮对话中,适配器能否正确处理多次工具调用。
     */
    @Test
    void testRealModelMultipleToolCalls() {
        // 获取 API key(优先从系统属性,然后从环境变量)
        String apiKey = System.getProperty(API_KEY_ENV, System.getenv(API_KEY_ENV));
        assertNotNull(apiKey, "需要设置 " + API_KEY_ENV + " 环境变量或系统属性");

        // 创建工具包并注册多个工具
        Toolkit toolkit = new Toolkit();
        toolkit.registration().tool(new MultiTools()).apply();

        // 创建模型和 Agent
        DashScopeChatModel model =
                DashScopeChatModel.builder().apiKey(apiKey).modelName(MODEL_NAME).stream(true)
                        .formatter(new DashScopeChatFormatter())
                        .build();

        ReActAgent agent =
                ReActAgent.builder()
                        .name("MultiToolAgent")
                        .model(model)
                        .toolkit(toolkit)
                        .memory(new InMemoryMemory())
                        .maxIters(5)
                        .build();

        AguiAgentAdapter adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.defaultConfig());

        // 准备输入:要求同时查询天气和时间
        RunAgentInput input =
                RunAgentInput.builder()
                        .threadId("thread-multi-1")
                        .runId("run-multi-1")
                        .messages(List.of(AguiMessage.userMessage("msg-1", "请告诉我上海的天气和当前时间")))
                        .build();

        // 执行并收集事件
        List<AguiEvent> events = adapter.run(input).collectList().block(Duration.ofSeconds(60));

        assertNotNull(events);
        assertFalse(events.isEmpty());

        System.out.println("总事件数: " + events.size());

        // 统计工具调用
        long toolStartCount =
                events.stream().filter(e -> e instanceof AguiEvent.ToolCallStart).count();
        long toolEndCount = events.stream().filter(e -> e instanceof AguiEvent.ToolCallEnd).count();
        long toolResultCount =
                events.stream().filter(e -> e instanceof AguiEvent.ToolCallResult).count();

        System.out.println("ToolCallStart 数量: " + toolStartCount);
        System.out.println("ToolCallEnd 数量: " + toolEndCount);
        System.out.println("ToolCallResult 数量: " + toolResultCount);

        if (toolStartCount > 0) {
            // 验证每个 Start 都有对应的 End
            assertTrue(toolStartCount == toolEndCount, "ToolCallStart 和 ToolCallEnd 数量应该相等");

            System.out.println("多工具调用测试通过");
        } else {
            System.out.println("模型没有调用工具");
        }

    }

    /**
     * 测试真实模型在工具调用失败时的处理。
     *
     * <p>场景:创建一个会抛出异常的工具,验证适配器能否正确处理工具执行失败的情况。
     */
    @Test
    void testRealModelToolCallFailure() {
        // 获取 API key(优先从系统属性,然后从环境变量)
        String apiKey = System.getProperty(API_KEY_ENV, System.getenv(API_KEY_ENV));
        assertNotNull(apiKey, "需要设置 " + API_KEY_ENV + " 环境变量或系统属性");

        // 创建工具包并注册会失败的工具
        Toolkit toolkit = new Toolkit();
        toolkit.registration().tool(new FailingTools()).apply();

        DashScopeChatModel model =
                DashScopeChatModel.builder().apiKey(apiKey).modelName(MODEL_NAME).stream(true)
                        .formatter(new DashScopeChatFormatter())
                        .build();

        ReActAgent agent =
                ReActAgent.builder()
                        .name("FailingToolAgent")
                        .model(model)
                        .toolkit(toolkit)
                        .memory(new InMemoryMemory())
                        .maxIters(2)
                        .build();

        AguiAgentAdapter adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.defaultConfig());

        RunAgentInput input =
                RunAgentInput.builder()
                        .threadId("thread-fail-1")
                        .runId("run-fail-1")
                        .messages(List.of(AguiMessage.userMessage("msg-1", "请使用 failing_tool 工具")))
                        .build();

        List<AguiEvent> events = adapter.run(input).collectList().block(Duration.ofSeconds(60));

        assertNotNull(events);
        assertFalse(events.isEmpty());

        // 查找错误相关的事件
        boolean hasToolResult =
                events.stream().anyMatch(e -> e instanceof AguiEvent.ToolCallResult);

        if (hasToolResult) {
            AguiEvent.ToolCallResult result =
                    events.stream()
                            .filter(e -> e instanceof AguiEvent.ToolCallResult)
                            .map(e -> (AguiEvent.ToolCallResult) e)
                            .findFirst()
                            .orElse(null);

            assertNotNull(result);
            System.out.println("工具结果: " + result.content());

            // 验证错误信息被正确传递
            assertTrue(
                    result.content().contains("失败") || result.content().contains("错误"),
                    "工具结果应该包含错误信息");

            System.out.println("工具失败处理测试通过");
        } else {
            System.out.println("没有检测到工具调用结果");
        }

    }
}

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@mengnankkkk mengnankkkk requested review from a team and Copilot January 17, 2026 12:17
@cla-assistant
Copy link

cla-assistant bot commented Jan 17, 2026

CLA assistant check
All committers have signed the CLA.

@cla-assistant
Copy link

cla-assistant bot commented Jan 17, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@mengnankkkk
Copy link
Author

image

@mengnankkkk
Copy link
Author

releate #588

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an issue with the qwen3-max model where ToolUseBlock events are returned all at once at the end of streaming instead of incrementally, causing ToolCallStart events to be missing. The fix implements a backfill mechanism that caches ToolUseBlocks when they arrive during REASONING events, then emits the missing ToolCallStart and ToolCallArgs events when ToolResultBlock is processed.

Changes:

  • Added caching mechanism for ToolUseBlock instances to support delayed ToolCallStart event emission
  • Implemented backfill logic that checks for missing ToolCallStart events when processing ToolResult events
  • Added HashMap to store ToolUseBlock instances by tool call ID for later retrieval

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +132 to +133
boolean hasToolUseBlock =
msg.getContent().stream().anyMatch(block -> block instanceof ToolUseBlock);
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable hasToolUseBlock is declared but never used. This variable appears to have been introduced for debugging or future use but is not currently utilized in the logic. Consider removing it to keep the code clean.

Suggested change
boolean hasToolUseBlock =
msg.getContent().stream().anyMatch(block -> block instanceof ToolUseBlock);

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +349
void cacheToolUseBlock(ToolUseBlock toolUse) {
if (toolUse.getId() != null) {
toolUseBlocks.put(toolUse.getId(), toolUse);
}
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new private method cacheToolUseBlock is missing Javadoc documentation. According to project standards, all methods should have Javadoc comments explaining their purpose, parameters, and behavior. Please add Javadoc describing that this method caches a ToolUseBlock for later retrieval during the backfill process when ToolCallStart events are missing.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +351 to +353
ToolUseBlock getToolUseBlock(String toolCallId) {
return toolUseBlocks.get(toolCallId);
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new private method getToolUseBlock is missing Javadoc documentation. According to project standards, all methods should have Javadoc comments explaining their purpose, parameters, and return values. Please add Javadoc describing that this method retrieves a cached ToolUseBlock by its tool call ID, and returns null if not found.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +208 to +234
boolean hasStarted = state.hasStartedToolCall(toolCallId);
if (!hasStarted) {
ToolUseBlock toolUse = state.getToolUseBlock(toolCallId);
if (toolUse != null) {
events.add(
new AguiEvent.ToolCallStart(
state.threadId,
state.runId,
toolCallId,
toolUse.getName()));
state.startToolCall(toolCallId);

if (config.isEmitToolCallArgs()) {
String args = toolUse.getContent();
if (args != null && !args.isEmpty()) {
events.add(
new AguiEvent.ToolCallArgs(
state.threadId, state.runId, toolCallId, args));
}
}
} else {
events.add(
new AguiEvent.ToolCallStart(
state.threadId, state.runId, toolCallId, "unknown"));
state.startToolCall(toolCallId);
}
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backfill logic for ToolCallStart events (lines 208-234) introduces critical new behavior but is only covered by integration tests with a real model. Consider adding a unit test to AguiAgentAdapterTest.java that simulates the scenario where a ToolResultBlock arrives without a prior ToolCallStart event. This would verify the backfill logic works correctly without requiring external API calls.

Copilot uses AI. Check for mistakes.
// Emit tool call start
String toolCallId = toolUse.getId();
if (toolCallId == null) {
toolCallId = UUID.randomUUID().toString();
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential logic issue with caching ToolUseBlocks when the ID is null. On line 174-177, if toolUse.getId() is null, a UUID is generated for toolCallId, but the ToolUseBlock object is not updated with this ID. Then on line 178, cacheToolUseBlock(toolUse) is called, which only caches if toolUse.getId() is not null (line 346). This means that if a ToolUseBlock arrives without an ID, it won't be cached, and the backfill logic (lines 210-227) won't be able to retrieve it later. Consider either: 1) only generating a UUID after checking if toolUse.getId() is not null, or 2) modifying the cache to use the generated toolCallId instead of relying on toolUse.getId().

Suggested change
toolCallId = UUID.randomUUID().toString();
toolCallId = UUID.randomUUID().toString();
// Ensure the ToolUseBlock carries the generated ID so caching and backfill work
toolUse.setId(toolCallId);

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Jan 17, 2026

Codecov Report

❌ Patch coverage is 31.81818% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...agentscope/core/agui/adapter/AguiAgentAdapter.java 31.81% 13 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant