From ef08b35b2353664e096e8e12f32162568255f5d8 Mon Sep 17 00:00:00 2001 From: wyi184246-creator Date: Tue, 29 Sep 2026 12:31:47 +0800 Subject: [PATCH 1/2] fix(harness): isolate subagents from parent frontend tools --- .../agent/subagent/DefaultAgentManager.java | 4 + .../harness/agent/tool/AgentSpawnTool.java | 32 ++- ...aultAgentManagerToolRequestConfigTest.java | 221 ++++++++++++++++++ .../tool/AgentSpawnToolSuspensionTest.java | 155 ++++++++++++ docs/v2/en/docs/harness/subagent.md | 14 +- docs/v2/zh/docs/harness/subagent.md | 12 +- 6 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerToolRequestConfigTest.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java index b97508e7e1..0cee05ad71 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java @@ -183,6 +183,8 @@ public Mono invokeAgent( ? RuntimeContext.builder(parentRc) .sessionId(sessionId) .userId(userId) + // Frontend tools belong to the parent run's client. + .toolRequestConfig(null) .build() : RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); if (agent instanceof ReActAgent react) { @@ -238,6 +240,8 @@ public Flux invokeAgentStream( ? RuntimeContext.builder(parentRc) .sessionId(sessionId) .userId(userId) + // Frontend tools belong to the parent run's client. + .toolRequestConfig(null) .build() : RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); if (agent instanceof ReActAgent react) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java index e1fd5c6bcc..71bf15aaf8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java @@ -27,7 +27,9 @@ import io.agentscope.core.event.AgentEventEmitter; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.SubagentExposedEvent; +import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.permission.PermissionContextState; import io.agentscope.core.permission.PermissionMode; import io.agentscope.core.permission.PermissionRule; @@ -469,8 +471,9 @@ public Mono agentSpawn( spec = new TaskRunSpec.LocalTaskRunSpec( () -> { + Msg reply; try { - Msg reply = + reply = manager.invokeAgent( agent, sessionId, @@ -478,13 +481,13 @@ public Mono agentSpawn( capturedTask, runtimeContext) .block(); - return reply != null ? reply.getTextContent() : ""; } catch (RuntimeException e) { return "Error: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); } + return textOf(reply); }); } taskRepository.putTask(runtimeContext, taskId, agentId, parentSessionId, spec); @@ -646,8 +649,9 @@ public Mono agentSend( spec = new TaskRunSpec.LocalTaskRunSpec( () -> { + Msg reply; try { - Msg reply = + reply = manager.invokeAgent( spawned.agent(), spawned.sessionId(), @@ -655,13 +659,13 @@ public Mono agentSend( capturedMessage, runtimeContext) .block(); - return reply != null ? reply.getTextContent() : ""; } catch (RuntimeException e) { return "Error: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); } + return textOf(reply); }); } taskRepository.putTask( @@ -904,6 +908,7 @@ private Mono execWithTimeoutPromotion( task, spawned, runtimeContext) + .map(AgentSpawnTool::requireNonSuspendedReply) .contextWrite( c -> reactor.util.context.Context.of( @@ -1118,9 +1123,23 @@ private SpawnedAgent tryRestoreFromState( } private static String textOf(Msg msg) { + requireNonSuspendedReply(msg); return msg != null ? msg.getTextContent() : ""; } + private static Msg requireNonSuspendedReply(Msg msg) { + if (msg != null && msg.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) { + List tools = + msg.getContentBlocks(ToolUseBlock.class).stream() + .map(ToolUseBlock::getName) + .distinct() + .toList(); + throw new IllegalStateException( + "Subagent suspended awaiting external tool execution: " + tools); + } + return msg; + } + private static String formatTimeoutPromoted(String taskId, long timeoutMs) { return String.format( """ @@ -1646,8 +1665,9 @@ private Mono execSpawnTask( spec = new TaskRunSpec.LocalTaskRunSpec( () -> { + Msg reply; try { - Msg reply = + reply = manager.invokeAgent( spawned.agent(), spawned.sessionId(), @@ -1655,13 +1675,13 @@ private Mono execSpawnTask( capturedTask, runtimeContext) .block(); - return reply != null ? reply.getTextContent() : ""; } catch (RuntimeException e) { return "Error: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); } + return textOf(reply); }); } taskRepository.putTask( diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerToolRequestConfigTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerToolRequestConfigTest.java new file mode 100644 index 0000000000..00f9ce6b03 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerToolRequestConfigTest.java @@ -0,0 +1,221 @@ +/* + * 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.harness.agent.subagent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolResultState; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.SchemaOnlyTool; +import io.agentscope.core.tool.ToolMergeMode; +import io.agentscope.core.tool.ToolRequestConfig; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.memory.MemoryConfig; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +class DefaultAgentManagerToolRequestConfigTest { + + @TempDir Path workspace; + + static Stream invocationModes() { + return Stream.of(ToolMergeMode.MERGE_EXTERNAL_PRIORITY, ToolMergeMode.EXTERNAL_ONLY) + .flatMap( + mode -> + Stream.of(false, true) + .flatMap( + streaming -> + Stream.of(false, true) + .map( + harness -> + Arguments.of( + mode, + streaming, + harness)))); + } + + @ParameterizedTest + @MethodSource("invocationModes") + void childUsesItsOwnTools(ToolMergeMode mode, boolean streaming, boolean harness) { + Map parameters = Map.of("type", "object", "properties", Map.of()); + ToolRequestConfig config = + new ToolRequestConfig( + Map.of( + "choose_x", + new SchemaOnlyTool("choose_x", "Client choice", parameters), + "server_tool", + new SchemaOnlyTool("server_tool", "Client override", parameters)), + mode); + RuntimeContext parent = + RuntimeContext.builder() + .sessionId("parent") + .userId("user") + .runId("parent-run") + .toolRequestConfig(config) + .build(); + Toolkit toolkit = new Toolkit(); + AgentTool serverTool = mock(AgentTool.class); + when(serverTool.getName()).thenReturn("server_tool"); + when(serverTool.getDescription()).thenReturn("Server tool"); + when(serverTool.getParameters()).thenReturn(parameters); + when(serverTool.callAsync(any())) + .thenReturn( + Mono.just( + ToolResultBlock.of( + TextBlock.builder().text("server result").build()))); + toolkit.registerTool(serverTool); + AtomicInteger turns = new AtomicInteger(); + MockModel model = + new MockModel( + messages -> { + int turn = turns.getAndIncrement(); + if (turn < 2) { + String name = turn == 0 ? "server_tool" : "choose_x"; + return List.of( + ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .id(name) + .name(name) + .input(Map.of()) + .content("{}") + .build())) + .build()); + } + return List.of( + ChatResponse.builder() + .content( + List.of( + TextBlock.builder() + .text("child done") + .build())) + .build()); + }); + AtomicReference observed = new AtomicReference<>(); + MiddlewareBase recorder = + new MiddlewareBase() { + @Override + public Mono onSystemPrompt( + Agent agent, RuntimeContext ctx, String prompt) { + observed.set(ctx); + return Mono.just(prompt); + } + }; + Agent child = + harness + ? HarnessAgent.builder() + .name("child") + .model(model) + .toolkit(toolkit) + .workspace(workspace) + .stateStore(new InMemoryAgentStateStore()) + .memory( + MemoryConfig.builder() + .flushTrigger(MemoryConfig.FlushTrigger.never()) + .build()) + .middlewares(List.of(recorder)) + .build() + : ReActAgent.builder() + .name("child") + .model(model) + .toolkit(toolkit) + .middlewares(List.of(recorder)) + .build(); + DefaultAgentManager manager = new DefaultAgentManager(List.of(), null); + try { + Msg reply = + streaming + ? manager.invokeAgentStream( + child, + "child-session", + "user", + "go", + null, + StreamOptions.defaults(), + parent) + .filter( + event -> + event.isLast() + && event.getType() + == EventType.AGENT_RESULT) + .map(event -> event.getMessage()) + .last() + .block(Duration.ofSeconds(10)) + : manager.invokeAgent(child, "child-session", "user", "go", parent) + .block(Duration.ofSeconds(10)); + assertNotNull(reply); + assertEquals("child done", reply.getTextContent()); + assertEquals(3, model.getCallCount()); + List names = model.getLastTools().stream().map(ToolSchema::getName).toList(); + assertTrue(names.contains("server_tool")); + assertFalse(names.contains("choose_x")); + verify(serverTool).callAsync(any()); + assertTrue( + model.getLastMessages().stream() + .flatMap(msg -> msg.getContentBlocks(ToolResultBlock.class).stream()) + .anyMatch( + result -> + "choose_x".equals(result.getId()) + && result.getState() == ToolResultState.ERROR)); + assertNotNull(observed.get()); + assertNull(observed.get().getToolRequestConfig()); + assertEquals("child-session", observed.get().getSessionId()); + assertEquals("user", observed.get().getUserId()); + assertEquals("parent-run", observed.get().getRunId()); + assertSame(config, parent.getToolRequestConfig()); + assertEquals("parent", parent.getSessionId()); + } finally { + if (child instanceof HarnessAgent harnessAgent) { + harnessAgent.close(); + } + } + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java new file mode 100644 index 0000000000..30ded7b1e3 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java @@ -0,0 +1,155 @@ +/* + * 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.harness.agent.tool; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.harness.agent.middleware.SubagentEntry; +import io.agentscope.harness.agent.subagent.DefaultAgentManager; +import io.agentscope.harness.agent.subagent.SubagentDeclaration; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.subagent.task.TaskRunSpec; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +class AgentSpawnToolSuspensionTest { + + private final RuntimeContext context = + RuntimeContext.builder().sessionId("parent").userId("user").build(); + private final TaskRepository repository = mock(TaskRepository.class); + private final Agent child = mock(Agent.class); + private final AgentSpawnTool tool = + new AgentSpawnTool( + new DefaultAgentManager( + List.of( + new SubagentEntry( + "worker", + "Worker", + rc -> child, + SubagentDeclaration.builder() + .name("worker") + .description("Worker") + .inlineAgentsBody("Worker") + .persistSession(true) + .build())), + null), + repository, + 0); + + @ParameterizedTest + @ValueSource(strings = {"spawn", "send", "reuse"}) + void suspendedChildReportsError(String operation) { + when(child.call(anyList())).thenReturn(Mono.just(suspended())); + String result = invoke(operation, 30).block(Duration.ofSeconds(5)); + assertNotNull(result); + assertTrue(result.contains("status: error"), result); + assertTrue(result.contains("choose_x"), result); + assertFalse(result.contains("status: ok"), result); + } + + @ParameterizedTest + @ValueSource(strings = {"spawn", "send", "reuse"}) + void suspendedBackgroundChildFailsTask(String operation) { + when(child.call(anyList())).thenReturn(Mono.just(suspended())); + String result = invoke(operation, 0).block(Duration.ofSeconds(5)); + assertNotNull(result); + assertTrue(result.contains("task_id:"), result); + TaskRunSpec.LocalTaskRunSpec spec = + assertInstanceOf(TaskRunSpec.LocalTaskRunSpec.class, capturedTask()); + IllegalStateException error = + assertThrows(IllegalStateException.class, spec.execution()::get); + assertTrue(error.getMessage().contains("choose_x")); + } + + @Test + void suspendedPromotedChildFailsFuture() throws Exception { + Sinks.One completion = Sinks.one(); + when(child.call(anyList())).thenReturn(completion.asMono()); + String result = invoke("spawn", 1).block(Duration.ofSeconds(5)); + assertNotNull(result); + assertTrue(result.contains("status: timeout_promoted"), result); + TaskRunSpec.AdoptedTaskRunSpec spec = + assertInstanceOf(TaskRunSpec.AdoptedTaskRunSpec.class, capturedTask()); + completion.tryEmitValue(suspended()); + ExecutionException error = + assertThrows( + ExecutionException.class, () -> spec.future().get(5, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, error.getCause()); + assertTrue(error.getCause().getMessage().contains("choose_x")); + } + + private Mono invoke(String operation, int timeout) { + if (!"spawn".equals(operation)) { + tool.agentSpawn(context, null, "worker", null, "existing", 30, null) + .block(Duration.ofSeconds(5)); + } + if ("send".equals(operation)) { + return tool.agentSend(context, null, null, "existing", "go", timeout); + } + return tool.agentSpawn( + context, + null, + "worker", + "go", + "reuse".equals(operation) ? "existing" : null, + timeout, + null); + } + + private TaskRunSpec capturedTask() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TaskRunSpec.class); + verify(repository).putTask(any(), anyString(), anyString(), anyString(), captor.capture()); + return captor.getValue(); + } + + private static Msg suspended() { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .generateReason(GenerateReason.TOOL_SUSPENDED) + .content( + ToolUseBlock.builder() + .id("choice") + .name("choose_x") + .input(Map.of()) + .build()) + .build(); + } +} diff --git a/docs/v2/en/docs/harness/subagent.md b/docs/v2/en/docs/harness/subagent.md index 4c927b29e3..dcf3afc32d 100644 --- a/docs/v2/en/docs/harness/subagent.md +++ b/docs/v2/en/docs/harness/subagent.md @@ -106,7 +106,19 @@ behavior. Remote agents and custom factories configure recovery themselves. ### Built-in `general-purpose` -No spec file needed; always available. Its role is "generic fallback" — it mirrors the parent's capability (same model, tools, skills) and shares the parent's workspace. Useful when the parent wants to isolate context for a sub-task without writing a dedicated spec. +No spec file needed; always available. Its role is "generic fallback" — it mirrors the parent's capability (same model, backend tools, skills) and shares the parent's workspace. Useful when the parent wants to isolate context for a sub-task without writing a dedicated spec. + +### Tool execution boundary + +Local subagent calls do not inherit the parent's per-call `ToolRequestConfig`. Client-declared +frontend tools, including those supplied through AG-UI, require the parent's client to execute +them. They are not offered to the child, and the parent's tool merge mode does not hide or +override the child's backend tools. This applies to synchronous, streaming, and background calls. + +If a local child still returns `TOOL_SUSPENDED` (for example, its own toolkit contains a +schema-only tool), `agent_spawn` and `agent_send` report an error naming the pending tools +instead of a successful empty reply. Background tasks, including tasks promoted after a +synchronous timeout, fail with the same diagnostic. ## ISOLATED vs SHARED diff --git a/docs/v2/zh/docs/harness/subagent.md b/docs/v2/zh/docs/harness/subagent.md index 76485f9c17..fc9ee43103 100644 --- a/docs/v2/zh/docs/harness/subagent.md +++ b/docs/v2/zh/docs/harness/subagent.md @@ -104,7 +104,17 @@ HarnessAgent.builder() ### 内置 `general-purpose` -不需要写声明文件,总是可用。它的角色是"通用兜底"——能力和主 agent 一致(同样的模型、工具、技能),共享主工作区。适合"主 agent 想隔离上下文跑一个子任务但又懒得专门写 spec"。 +不需要写声明文件,总是可用。它的角色是"通用兜底"——能力和主 agent 一致(同样的模型、后端工具、技能),共享主工作区。适合"主 agent 想隔离上下文跑一个子任务但又懒得专门写 spec"。 + +### 工具执行边界 + +调用本地子 agent 时,不会继承父调用的 `ToolRequestConfig`。客户端声明的前端工具 +(包括通过 AG-UI 传入的工具)需要由父 agent 的客户端执行,因此不会提供给子 agent。 +父调用的工具合并模式也不会隐藏或覆盖子 agent 的后端工具。同步、流式和后台调用均遵循此规则。 + +如果本地子 agent 仍返回 `TOOL_SUSPENDED`(例如其自身工具集中注册了只有 schema 的工具), +`agent_spawn` 和 `agent_send` 会返回错误并列出等待执行的工具,不再把空回复报告为成功。 +后台任务以及同步超时后转为后台执行的任务,会以相同的错误信息标记为失败。 ## ISOLATED vs SHARED From dcf3f51dd4acce6d43b181ed277776674972a3af Mon Sep 17 00:00:00 2001 From: wyi184246-creator Date: Tue, 29 Sep 2026 15:14:51 +0800 Subject: [PATCH 2/2] test(harness): cover persisted subagent suspension failures --- .../tool/AgentSpawnToolSuspensionTest.java | 115 +++++++++++++++--- docs/v2/en/docs/others/release-notes.md | 11 ++ docs/v2/zh/docs/others/release-notes.md | 11 ++ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java index 30ded7b1e3..819d2a2c4d 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolSuspensionTest.java @@ -15,9 +15,11 @@ */ package io.agentscope.harness.agent.tool; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -36,15 +38,25 @@ import io.agentscope.harness.agent.middleware.SubagentEntry; import io.agentscope.harness.agent.subagent.DefaultAgentManager; import io.agentscope.harness.agent.subagent.SubagentDeclaration; +import io.agentscope.harness.agent.subagent.task.BackgroundTask; +import io.agentscope.harness.agent.subagent.task.TaskRecord; import io.agentscope.harness.agent.subagent.task.TaskRepository; import io.agentscope.harness.agent.subagent.task.TaskRunSpec; +import io.agentscope.harness.agent.subagent.task.TaskStatus; +import io.agentscope.harness.agent.subagent.task.WorkspaceTaskRepository; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; import java.time.Duration; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; @@ -56,23 +68,26 @@ class AgentSpawnToolSuspensionTest { RuntimeContext.builder().sessionId("parent").userId("user").build(); private final TaskRepository repository = mock(TaskRepository.class); private final Agent child = mock(Agent.class); - private final AgentSpawnTool tool = - new AgentSpawnTool( - new DefaultAgentManager( - List.of( - new SubagentEntry( - "worker", - "Worker", - rc -> child, - SubagentDeclaration.builder() - .name("worker") - .description("Worker") - .inlineAgentsBody("Worker") - .persistSession(true) - .build())), - null), - repository, - 0); + private final AgentSpawnTool tool = createTool(repository); + + private AgentSpawnTool createTool(TaskRepository repository) { + return new AgentSpawnTool( + new DefaultAgentManager( + List.of( + new SubagentEntry( + "worker", + "Worker", + rc -> child, + SubagentDeclaration.builder() + .name("worker") + .description("Worker") + .inlineAgentsBody("Worker") + .persistSession(true) + .build())), + null), + repository, + 0); + } @ParameterizedTest @ValueSource(strings = {"spawn", "send", "reuse"}) @@ -116,7 +131,73 @@ void suspendedPromotedChildFailsFuture() throws Exception { assertTrue(error.getCause().getMessage().contains("choose_x")); } + @ParameterizedTest + @CsvSource({"spawn,0", "send,0", "reuse,0", "spawn,1", "send,1", "reuse,1"}) + void suspendedTaskPersistsFailureVisibleToTaskOutput( + String operation, int timeout, @TempDir Path directory) throws Exception { + WorkspaceManager workspace = new WorkspaceManager(directory); + WorkspaceTaskRepository writer = + WorkspaceTaskRepository.forTests(workspace, "parent-agent"); + WorkspaceTaskRepository reader = + WorkspaceTaskRepository.forTests(new WorkspaceManager(directory), "parent-agent"); + CountDownLatch persisted = new CountDownLatch(1); + writer.setCompletionCallback( + (rc, taskId, agentId, sessionId, result) -> persisted.countDown()); + Sinks.One completion = Sinks.one(); + when(child.call(anyList())).thenReturn(completion.asMono()); + try { + String result = + invoke(createTool(writer), operation, timeout).block(Duration.ofSeconds(5)); + assertNotNull(result); + assertTrue(result.contains("task_id:"), result); + if (timeout > 0) { + assertTrue(result.contains("status: timeout_promoted"), result); + } + Collection tasks = writer.listTasks(context, "parent", null); + assertEquals(1, tasks.size()); + String taskId = tasks.iterator().next().getTaskId(); + BackgroundTask live = writer.getTask(context, "parent", taskId); + assertNotNull(live); + + completion.tryEmitValue(suspended()); + assertTrue(persisted.await(5, TimeUnit.SECONDS), "Task completion was not persisted"); + assertTrue(live.waitForCompletion(5_000)); + assertEquals(TaskStatus.FAILED, live.getTaskStatus()); + assertInstanceOf(IllegalStateException.class, live.getError()); + String diagnostic = "Subagent suspended awaiting external tool execution: [choose_x]"; + assertEquals(diagnostic, live.getError().getMessage()); + + TaskRecord record = + workspace + .readTaskRecord(context, "parent-agent", "parent", taskId) + .orElseThrow(); + assertEquals(TaskStatus.FAILED, record.getStatus()); + assertNull(record.getResult()); + assertEquals(diagnostic, record.getErrorMessage()); + + // The second repository has no local future and must recover the persisted failure. + for (TaskRepository source : List.of(writer, reader)) { + BackgroundTask observed = source.getTask(context, "parent", taskId); + assertNotNull(observed); + assertEquals(TaskStatus.FAILED, observed.getTaskStatus()); + assertNull(observed.getResult()); + String output = new TaskTool(source).taskOutput(context, taskId, false, null); + assertTrue(output.contains("status: Failed: " + diagnostic), output); + assertTrue(output.contains("Error:\n" + diagnostic), output); + assertFalse(output.contains("Result:\n"), output); + assertFalse(output.contains("Task completed with no result."), output); + } + } finally { + writer.shutdown(); + reader.shutdown(); + } + } + private Mono invoke(String operation, int timeout) { + return invoke(tool, operation, timeout); + } + + private Mono invoke(AgentSpawnTool tool, String operation, int timeout) { if (!"spawn".equals(operation)) { tool.agentSpawn(context, null, "worker", null, "existing", 30, null) .block(Duration.ofSeconds(5)); diff --git a/docs/v2/en/docs/others/release-notes.md b/docs/v2/en/docs/others/release-notes.md index 60dec356fc..65fdceeae2 100644 --- a/docs/v2/en/docs/others/release-notes.md +++ b/docs/v2/en/docs/others/release-notes.md @@ -8,6 +8,17 @@ This page tracks per-version changes for AgentScope Java 2.0. For the overall mi --- +## Unreleased + +### Compatibility + +- Local subagents no longer inherit the parent's per-call `ToolRequestConfig`, including AG-UI frontend tools, so the parent's tool merge mode no longer hides or overrides the child's backend tools ([#3357](https://github.com/agentscope-ai/agentscope-java/pull/3357)). +- A child reply with `TOOL_SUSPENDED` now makes `agent_spawn` and `agent_send` report an error naming the pending tools instead of a successful empty reply; background tasks, including calls promoted after a synchronous timeout, are marked `FAILED`. + +**Migration:** Keep client-side interactions in the parent agent, or register backend-executable tools in the child; local subagents cannot delegate tool execution to the parent's client. See [Subagents](/v2/en/docs/harness/subagent#tool-execution-boundary) for details. + +--- + ## 2.0.1 > Released: 2026-08-05 diff --git a/docs/v2/zh/docs/others/release-notes.md b/docs/v2/zh/docs/others/release-notes.md index f982e77836..c70199c2b7 100644 --- a/docs/v2/zh/docs/others/release-notes.md +++ b/docs/v2/zh/docs/others/release-notes.md @@ -8,6 +8,17 @@ en_link: /v2/en/docs/others/release-notes --- +## 未发布 + +### 兼容性说明 + +- 本地子 agent 不再继承父调用的 `ToolRequestConfig`(包括 AG-UI 前端工具),父调用的工具合并模式也不会再隐藏或覆盖子 agent 的后端工具([#3357](https://github.com/agentscope-ai/agentscope-java/pull/3357))。 +- 子 agent 返回 `TOOL_SUSPENDED` 时,`agent_spawn` 和 `agent_send` 会报错并列出等待执行的工具,不再将空回复报告为成功;后台任务以及同步超时后转入后台的任务会标记为 `FAILED`。 + +**迁移建议:**将客户端交互保留在父 agent 中,或为子 agent 注册可在后端执行的工具;本地子 agent 无法将工具调用交给父 agent 的客户端执行,详见[子 Agent](/v2/zh/docs/harness/subagent#工具执行边界)。 + +--- + ## 2.0.1 > 发布日期:2026-08-05