|
28 | 28 | import io.agentscope.core.message.Msg; |
29 | 29 | import io.agentscope.core.message.MsgRole; |
30 | 30 | import io.agentscope.core.message.TextBlock; |
| 31 | +import io.agentscope.core.message.ToolResultBlock; |
31 | 32 | import io.agentscope.core.message.ToolUseBlock; |
32 | 33 | import io.agentscope.core.model.ChatResponse; |
33 | 34 | import io.agentscope.core.model.ChatUsage; |
| 35 | +import java.lang.reflect.Method; |
34 | 36 | import java.time.Duration; |
35 | 37 | import java.util.List; |
36 | 38 | import java.util.Map; |
@@ -397,4 +399,319 @@ void testSummaryAddedToMemory() { |
397 | 399 | assertEquals( |
398 | 400 | MsgRole.ASSISTANT, lastMessage.getRole(), "Summary message should be ASSISTANT"); |
399 | 401 | } |
| 402 | + |
| 403 | + @Test |
| 404 | + @DisplayName("Should handle second call after maxIters with pending tool calls - Issue #1005") |
| 405 | + void testSecondCallAfterMaxItersWithPendingToolCalls() { |
| 406 | + // This test reproduces the bug reported in Issue #1005: |
| 407 | + // 1. User has multi-round conversation with tool call |
| 408 | + // 2. Tool doesn't respond (or times out), leaving pending tool calls |
| 409 | + // 3. maxIters is reached, session auto-ends |
| 410 | + // 4. User sends new message -> Should NOT throw IllegalStateException |
| 411 | + |
| 412 | + InMemoryMemory memory = new InMemoryMemory(); |
| 413 | + final String toolId = "call_638e428da2cf48ceb8b05762"; |
| 414 | + |
| 415 | + // Mock model that returns a tool call on first call, then summary |
| 416 | + final int[] callCount = {0}; |
| 417 | + MockModel mockModel = |
| 418 | + new MockModel( |
| 419 | + messages -> { |
| 420 | + int callNum = callCount[0]++; |
| 421 | + if (callNum == 0) { |
| 422 | + // First call: return tool use block (simulating tool call) |
| 423 | + return List.of( |
| 424 | + ChatResponse.builder() |
| 425 | + .id("msg_0") |
| 426 | + .content( |
| 427 | + List.of( |
| 428 | + ToolUseBlock.builder() |
| 429 | + .name("search_tool") |
| 430 | + .id(toolId) |
| 431 | + .input( |
| 432 | + Map.of( |
| 433 | + "query", |
| 434 | + "test")) |
| 435 | + .build())) |
| 436 | + .usage(new ChatUsage(10, 20, 30)) |
| 437 | + .build()); |
| 438 | + } else { |
| 439 | + // Second call: summarizing (because maxIters=1 reached) |
| 440 | + return List.of( |
| 441 | + ChatResponse.builder() |
| 442 | + .id("msg_summary") |
| 443 | + .content( |
| 444 | + List.of( |
| 445 | + TextBlock.builder() |
| 446 | + .text( |
| 447 | + "I reached the" |
| 448 | + + " maximum" |
| 449 | + + " iteration" |
| 450 | + + " limit." |
| 451 | + + " Please try" |
| 452 | + + " again.") |
| 453 | + .build())) |
| 454 | + .usage(new ChatUsage(10, 20, 30)) |
| 455 | + .build()); |
| 456 | + } |
| 457 | + }); |
| 458 | + |
| 459 | + MockToolkit mockToolkit = new MockToolkit(); |
| 460 | + |
| 461 | + // Create agent with maxIters=1 to quickly trigger summarizing |
| 462 | + ReActAgent agent = |
| 463 | + ReActAgent.builder() |
| 464 | + .name("TestAgent") |
| 465 | + .sysPrompt("You are a helpful assistant.") |
| 466 | + .model(mockModel) |
| 467 | + .toolkit(mockToolkit) |
| 468 | + .memory(memory) |
| 469 | + .maxIters(1) |
| 470 | + .build(); |
| 471 | + |
| 472 | + // First user message - triggers tool call and maxIters summarizing |
| 473 | + Msg firstUserMsg = TestUtils.createUserMessage("User", "Please search for something"); |
| 474 | + Msg firstResponse = |
| 475 | + agent.call(firstUserMsg) |
| 476 | + .block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS)); |
| 477 | + |
| 478 | + // Verify first response |
| 479 | + assertNotNull(firstResponse, "First response should not be null"); |
| 480 | + assertEquals(MsgRole.ASSISTANT, firstResponse.getRole()); |
| 481 | + |
| 482 | + // CRITICAL: Verify that the pending tool call has been resolved in memory |
| 483 | + // Before the fix, memory would have pending tool calls without results |
| 484 | + // After the fix, summarizing() should add error results for pending tools |
| 485 | + List<Msg> memoryMessages = memory.getMessages(); |
| 486 | + |
| 487 | + // Find if there's a tool result message for the pending tool |
| 488 | + boolean hasToolResultForPendingTool = |
| 489 | + memoryMessages.stream() |
| 490 | + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) |
| 491 | + .anyMatch(tr -> tr.getId() != null && tr.getId().equals(toolId)); |
| 492 | + |
| 493 | + assertTrue( |
| 494 | + hasToolResultForPendingTool, |
| 495 | + "Memory should contain error result for pending tool call after summarizing"); |
| 496 | + |
| 497 | + Msg pendingToolResultMsg = |
| 498 | + memoryMessages.stream() |
| 499 | + .filter( |
| 500 | + m -> |
| 501 | + m.getContentBlocks(ToolResultBlock.class).stream() |
| 502 | + .anyMatch( |
| 503 | + tr -> |
| 504 | + tr.getId() != null |
| 505 | + && tr.getId() |
| 506 | + .equals(toolId))) |
| 507 | + .findFirst() |
| 508 | + .orElse(null); |
| 509 | + assertNotNull(pendingToolResultMsg, "Pending tool call should have a result message"); |
| 510 | + assertEquals( |
| 511 | + MsgRole.TOOL, |
| 512 | + pendingToolResultMsg.getRole(), |
| 513 | + "Pending tool error result should be stored as TOOL message"); |
| 514 | + |
| 515 | + // Verify the tool result indicates cancellation due to max iterations |
| 516 | + ToolResultBlock toolResult = |
| 517 | + memoryMessages.stream() |
| 518 | + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) |
| 519 | + .filter(tr -> tr.getId() != null && tr.getId().equals(toolId)) |
| 520 | + .findFirst() |
| 521 | + .orElse(null); |
| 522 | + |
| 523 | + // Tool result should be present (either from toolkit or from summarizing fix) |
| 524 | + assertNotNull(toolResult); |
| 525 | + |
| 526 | + // SECOND CALL - This is the critical test for Issue #1005 |
| 527 | + // Before the fix, this would throw: |
| 528 | + // IllegalStateException: Cannot add messages without tool results when pending tool calls |
| 529 | + // exist |
| 530 | + |
| 531 | + // Reset model for second user interaction |
| 532 | + final int[] secondCallCount = {0}; |
| 533 | + MockModel secondMockModel = |
| 534 | + new MockModel( |
| 535 | + messages -> { |
| 536 | + int callNum = secondCallCount[0]++; |
| 537 | + if (callNum == 0) { |
| 538 | + return List.of( |
| 539 | + ChatResponse.builder() |
| 540 | + .id("msg_second_0") |
| 541 | + .content( |
| 542 | + List.of( |
| 543 | + TextBlock.builder() |
| 544 | + .text( |
| 545 | + "Hello! How can I" |
| 546 | + + " help you" |
| 547 | + + " today?") |
| 548 | + .build())) |
| 549 | + .usage(new ChatUsage(5, 10, 15)) |
| 550 | + .build()); |
| 551 | + } |
| 552 | + return List.of(); |
| 553 | + }); |
| 554 | + |
| 555 | + ReActAgent secondAgent = |
| 556 | + ReActAgent.builder() |
| 557 | + .name("TestAgent") |
| 558 | + .sysPrompt("You are a helpful assistant.") |
| 559 | + .model(secondMockModel) |
| 560 | + .toolkit(mockToolkit) |
| 561 | + .memory(memory) // Same memory |
| 562 | + .maxIters(2) |
| 563 | + .build(); |
| 564 | + |
| 565 | + // Second user message - this would throw IllegalStateException before the fix |
| 566 | + Msg secondUserMsg = TestUtils.createUserMessage("User", "Hello again"); |
| 567 | + |
| 568 | + // This should NOT throw: "Cannot add messages without tool results when pending tool calls |
| 569 | + // exist" |
| 570 | + Msg secondResponse = |
| 571 | + secondAgent |
| 572 | + .call(secondUserMsg) |
| 573 | + .block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS)); |
| 574 | + |
| 575 | + // Verify second response succeeded |
| 576 | + assertNotNull(secondResponse, "Second response should not be null"); |
| 577 | + assertEquals(MsgRole.ASSISTANT, secondResponse.getRole()); |
| 578 | + assertTrue( |
| 579 | + secondResponse.getFirstContentBlock() instanceof TextBlock, |
| 580 | + "Second response should contain TextBlock"); |
| 581 | + |
| 582 | + TextBlock secondText = (TextBlock) secondResponse.getFirstContentBlock(); |
| 583 | + assertEquals("Hello! How can I help you today?", secondText.getText()); |
| 584 | + } |
| 585 | + |
| 586 | + @Test |
| 587 | + @DisplayName("Should add exactly one TOOL result for each pending tool during summarizing") |
| 588 | + void testSummarizingAddsOneToolResultPerPendingTool() { |
| 589 | + InMemoryMemory memory = new InMemoryMemory(); |
| 590 | + final String toolId1 = "call_pending_1"; |
| 591 | + final String toolId2 = "call_pending_2"; |
| 592 | + |
| 593 | + Msg pendingAssistantMsg = |
| 594 | + Msg.builder() |
| 595 | + .name("TestAgent") |
| 596 | + .role(MsgRole.ASSISTANT) |
| 597 | + .content( |
| 598 | + List.of( |
| 599 | + ToolUseBlock.builder() |
| 600 | + .name("search_tool") |
| 601 | + .id(toolId1) |
| 602 | + .input(Map.of("query", "weather")) |
| 603 | + .build(), |
| 604 | + ToolUseBlock.builder() |
| 605 | + .name("search_tool") |
| 606 | + .id(toolId2) |
| 607 | + .input(Map.of("query", "news")) |
| 608 | + .build())) |
| 609 | + .build(); |
| 610 | + memory.addMessage(pendingAssistantMsg); |
| 611 | + |
| 612 | + MockModel mockModel = |
| 613 | + new MockModel( |
| 614 | + messages -> |
| 615 | + List.of( |
| 616 | + ChatResponse.builder() |
| 617 | + .id("msg_summary") |
| 618 | + .content( |
| 619 | + List.of( |
| 620 | + TextBlock.builder() |
| 621 | + .text( |
| 622 | + "Iteration limit" |
| 623 | + + " reached.") |
| 624 | + .build())) |
| 625 | + .usage(new ChatUsage(10, 20, 30)) |
| 626 | + .build())); |
| 627 | + |
| 628 | + MockToolkit mockToolkit = new MockToolkit(); |
| 629 | + ReActAgent agent = |
| 630 | + ReActAgent.builder() |
| 631 | + .name("TestAgent") |
| 632 | + .sysPrompt("You are a helpful assistant.") |
| 633 | + .model(mockModel) |
| 634 | + .toolkit(mockToolkit) |
| 635 | + .memory(memory) |
| 636 | + .maxIters(1) |
| 637 | + .build(); |
| 638 | + |
| 639 | + Msg summaryResponse = invokeSummarizing(agent); |
| 640 | + assertNotNull(summaryResponse, "Summary response should not be null"); |
| 641 | + |
| 642 | + List<Msg> memoryMessages = memory.getMessages(); |
| 643 | + |
| 644 | + long toolId1ToolRoleCount = |
| 645 | + memoryMessages.stream() |
| 646 | + .filter(m -> m.getRole() == MsgRole.TOOL) |
| 647 | + .filter( |
| 648 | + m -> |
| 649 | + m.getContentBlocks(ToolResultBlock.class).stream() |
| 650 | + .anyMatch( |
| 651 | + tr -> |
| 652 | + tr.getId() != null |
| 653 | + && tr.getId() |
| 654 | + .equals(toolId1))) |
| 655 | + .count(); |
| 656 | + long toolId2ToolRoleCount = |
| 657 | + memoryMessages.stream() |
| 658 | + .filter(m -> m.getRole() == MsgRole.TOOL) |
| 659 | + .filter( |
| 660 | + m -> |
| 661 | + m.getContentBlocks(ToolResultBlock.class).stream() |
| 662 | + .anyMatch( |
| 663 | + tr -> |
| 664 | + tr.getId() != null |
| 665 | + && tr.getId() |
| 666 | + .equals(toolId2))) |
| 667 | + .count(); |
| 668 | + |
| 669 | + assertEquals( |
| 670 | + 1L, toolId1ToolRoleCount, "toolId1 should have exactly one TOOL result message"); |
| 671 | + assertEquals( |
| 672 | + 1L, toolId2ToolRoleCount, "toolId2 should have exactly one TOOL result message"); |
| 673 | + |
| 674 | + long toolId1NonToolRoleCount = |
| 675 | + memoryMessages.stream() |
| 676 | + .filter(m -> m.getRole() != MsgRole.TOOL) |
| 677 | + .filter( |
| 678 | + m -> |
| 679 | + m.getContentBlocks(ToolResultBlock.class).stream() |
| 680 | + .anyMatch( |
| 681 | + tr -> |
| 682 | + tr.getId() != null |
| 683 | + && tr.getId() |
| 684 | + .equals(toolId1))) |
| 685 | + .count(); |
| 686 | + long toolId2NonToolRoleCount = |
| 687 | + memoryMessages.stream() |
| 688 | + .filter(m -> m.getRole() != MsgRole.TOOL) |
| 689 | + .filter( |
| 690 | + m -> |
| 691 | + m.getContentBlocks(ToolResultBlock.class).stream() |
| 692 | + .anyMatch( |
| 693 | + tr -> |
| 694 | + tr.getId() != null |
| 695 | + && tr.getId() |
| 696 | + .equals(toolId2))) |
| 697 | + .count(); |
| 698 | + |
| 699 | + assertEquals( |
| 700 | + 0L, toolId1NonToolRoleCount, "toolId1 should not have non-TOOL result messages"); |
| 701 | + assertEquals( |
| 702 | + 0L, toolId2NonToolRoleCount, "toolId2 should not have non-TOOL result messages"); |
| 703 | + } |
| 704 | + |
| 705 | + private static Msg invokeSummarizing(ReActAgent agent) { |
| 706 | + try { |
| 707 | + Method method = ReActAgent.class.getDeclaredMethod("summarizing"); |
| 708 | + method.setAccessible(true); |
| 709 | + @SuppressWarnings("unchecked") |
| 710 | + reactor.core.publisher.Mono<Msg> mono = |
| 711 | + (reactor.core.publisher.Mono<Msg>) method.invoke(agent); |
| 712 | + return mono.block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS)); |
| 713 | + } catch (Exception e) { |
| 714 | + throw new RuntimeException("Failed to invoke summarizing()", e); |
| 715 | + } |
| 716 | + } |
400 | 717 | } |
0 commit comments