From f0b2b5bc499289c4688ca2829c093f5c377a5097 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 20 Apr 2026 16:08:51 +0800 Subject: [PATCH 1/6] feat(subagent): support passing user-defined fields in SubAgentTool --- .../core/tool/subagent/SubAgentConfig.java | 73 ++++++++++++++++- .../core/tool/subagent/SubAgentTool.java | 25 +++++- .../tool/subagent/SubAgentConfigTest.java | 63 +++++++++++++++ .../core/tool/subagent/SubAgentToolTest.java | 78 +++++++++++++++++++ docs/en/multi-agent/subagent.md | 35 +++++++++ docs/zh/multi-agent/subagent.md | 35 +++++++++ 6 files changed, 307 insertions(+), 2 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index 66b3792fac..ce0ce0d2bb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -19,6 +19,10 @@ import io.agentscope.core.session.InMemorySession; import io.agentscope.core.session.JsonSession; import io.agentscope.core.session.Session; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Configuration for sub-agent registration. @@ -31,7 +35,7 @@ * * * @@ -65,6 +69,8 @@ public class SubAgentConfig { private final boolean forwardEvents; private final StreamOptions streamOptions; private final Session session; + private final Map> customParameters; + private final List requiredCustomParameters; private SubAgentConfig(Builder builder) { this.toolName = builder.toolName; @@ -72,6 +78,12 @@ private SubAgentConfig(Builder builder) { this.forwardEvents = builder.forwardEvents; this.streamOptions = builder.streamOptions; this.session = builder.session != null ? builder.session : new InMemorySession(); + this.customParameters = + builder.customParameters != null ? builder.customParameters : new HashMap<>(); + this.requiredCustomParameters = + builder.requiredCustomParameters != null + ? builder.requiredCustomParameters + : new ArrayList<>(); } /** @@ -148,6 +160,24 @@ public Session getSession() { return session; } + /** + * Gets the custom parameters defined for the sub-agent tool. + * + * @return A map of parameter names to their JSON schema definitions + */ + public Map> getCustomParameters() { + return customParameters; + } + + /** + * Gets the list of required custom parameter names. + * + * @return A list containing the names of required parameters + */ + public List getRequiredCustomParameters() { + return requiredCustomParameters; + } + /** Builder for SubAgentConfig. */ public static class Builder { private String toolName; @@ -155,6 +185,8 @@ public static class Builder { private boolean forwardEvents = true; private StreamOptions streamOptions; private Session session; + private Map> customParameters = new HashMap<>(); + private List requiredCustomParameters = new ArrayList<>(); private Builder() {} @@ -229,6 +261,45 @@ public Builder session(Session session) { return this; } + /** + * Adds a simple custom parameter to the tool's JSON schema. + * + *

This is a convenience method for adding basic parameters. For complex schemas + * (e.g., enums, arrays), use {@link #addParameter(String, Map, boolean)}. + * + * @param name The name of the parameter + * @param type The type of the parameter (e.g., "string", "integer") + * @param description The description of the parameter + * @param required true if the parameter is required, false otherwise + * @return This builder + */ + public Builder addParameter( + String name, String type, String description, boolean required) { + Map prop = new HashMap<>(); + prop.put("type", type); + prop.put("description", description); + return addParameter(name, prop, required); + } + + /** + * Adds a custom parameter with a fully defined JSON schema. + * + *

This method allows for advanced JSON schema features like enums, nested objects, + * or arrays, enabling precise control over how the language model understands the parameter. + * + * @param name The name of the parameter + * @param schema The JSON schema map definition for the parameter + * @param required true if the parameter is required, false otherwise + * @return This builder + */ + public Builder addParameter(String name, Map schema, boolean required) { + this.customParameters.put(name, schema); + if (required) { + this.requiredCustomParameters.add(name); + } + return this; + } + /** * Builds the configuration. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index 068e44c658..f9dd77bb69 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -29,6 +29,7 @@ import io.agentscope.core.tool.ToolEmitter; import io.agentscope.core.util.JsonUtils; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -142,6 +143,15 @@ private Mono executeConversation(ToolCallParam param) { return Mono.just(ToolResultBlock.error("Message is required")); } + // Extract custom external parameters (filter out system built-in keys) + Map customMetadata = new HashMap<>(); + for (Map.Entry entry : input.entrySet()) { + String key = entry.getKey(); + if (!PARAM_SESSION_ID.equals(key) && !PARAM_MESSAGE.equals(key)) { + customMetadata.put(key, entry.getValue()); + } + } + // Create agent for this session final String finalSessionId = sessionId; Agent agent = agentProvider.provide(); @@ -156,6 +166,7 @@ private Mono executeConversation(ToolCallParam param) { Msg.builder() .role(MsgRole.USER) .content(TextBlock.builder().text(message).build()) + .metadata(customMetadata) .build(); logger.debug( @@ -381,8 +392,20 @@ private Map buildSchema() { messageProp.put("description", "Message to send to the agent"); properties.put(PARAM_MESSAGE, messageProp); + // Custom parameters + Map> customParams = config.getCustomParameters(); + if (customParams != null && !customParams.isEmpty()) { + properties.putAll(customParams); + } + schema.put("properties", properties); - schema.put("required", List.of(PARAM_MESSAGE)); + + List requiredFields = new ArrayList<>(); + requiredFields.add(PARAM_MESSAGE); + if (config.getRequiredCustomParameters() != null) { + requiredFields.addAll(config.getRequiredCustomParameters()); + } + schema.put("required", requiredFields); return schema; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index 15b5ab7ab9..16bd3d9e52 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool.subagent; 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; @@ -25,6 +26,9 @@ import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.session.InMemorySession; import io.agentscope.core.session.Session; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -156,6 +160,49 @@ void testDefaultSessionWhenNotSpecified() { assertInstanceOf(InMemorySession.class, config.getSession()); } + @Test + @DisplayName("Should build config with simple custom parameter") + void testSimpleCustomParameter() { + SubAgentConfig config = + SubAgentConfig.builder() + .addParameter("userId", "string", "The user ID", true) + .build(); + + assertNotNull(config.getCustomParameters()); + assertEquals(1, config.getCustomParameters().size()); + + Map paramConfig = config.getCustomParameters().get("userId"); + assertNotNull(paramConfig); + assertEquals("string", paramConfig.get("type")); + assertEquals("The user ID", paramConfig.get("description")); + + assertNotNull(config.getRequiredCustomParameters()); + assertTrue(config.getRequiredCustomParameters().contains("userId")); + } + + @Test + @DisplayName("Should build config with schema custom parameter") + void testSchemaCustomParameter() { + Map enumSchema = new HashMap<>(); + enumSchema.put("type", "string"); + enumSchema.put("enum", List.of("admin", "user")); + enumSchema.put("description", "The user role"); + + SubAgentConfig config = + SubAgentConfig.builder().addParameter("role", enumSchema, false).build(); + + assertNotNull(config.getCustomParameters()); + assertEquals(1, config.getCustomParameters().size()); + + Map paramConfig = config.getCustomParameters().get("role"); + assertNotNull(paramConfig); + assertEquals("string", paramConfig.get("type")); + assertTrue(paramConfig.containsKey("enum")); + + assertNotNull(config.getRequiredCustomParameters()); + assertFalse(config.getRequiredCustomParameters().contains("role")); + } + @Test @DisplayName("Builder should be chainable") void testBuilderChaining() { @@ -208,6 +255,22 @@ void testGetSessionNeverNull() { SubAgentConfig configWithNullSession = SubAgentConfig.builder().session(null).build(); assertNotNull(configWithNullSession.getSession()); } + + @Test + @DisplayName("getCustomParameters() should return empty map when not set") + void testGetCustomParametersEmpty() { + SubAgentConfig config = SubAgentConfig.builder().build(); + assertNotNull(config.getCustomParameters()); + assertTrue(config.getCustomParameters().isEmpty()); + } + + @Test + @DisplayName("getRequiredCustomParameters() should return empty list when not set") + void testGetRequiredCustomParametersEmpty() { + SubAgentConfig config = SubAgentConfig.builder().build(); + assertNotNull(config.getRequiredCustomParameters()); + assertTrue(config.getRequiredCustomParameters().isEmpty()); + } } @Nested diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index 198e046307..e3cb2a3f26 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -44,6 +44,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -479,6 +480,83 @@ void testToolNameGenerationTruncation() { assertFalse(generatedName.contains("__")); } + @Test + @DisplayName("Should generate schema containing custom parameters") + void testSchemaWithCustomParameters() { + Agent mockAgent = createMockAgent("TestAgent", "Test"); + + SubAgentConfig config = + SubAgentConfig.builder() + .addParameter("userId", "string", "The user ID", true) + .addParameter("tenantId", "string", "The tenant ID", false) + .build(); + + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map schema = tool.getParameters(); + + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + + // Custom params should exist + assertTrue(properties.containsKey("userId")); + assertTrue(properties.containsKey("tenantId")); + + @SuppressWarnings("unchecked") + List required = (List) schema.get("required"); + + // Check required fields + assertTrue(required.contains("message")); + assertTrue(required.contains("userId")); // Custom required + assertFalse(required.contains("tenantId")); // Custom optional + } + + @Test + @DisplayName("Should extract custom parameters and inject them into Msg metadata") + void testCustomParametersInjectedIntoMetadata() { + Agent mockAgent = mock(Agent.class); + when(mockAgent.getName()).thenReturn("TestAgent"); + when(mockAgent.getDescription()).thenReturn("Test"); + + when(mockAgent.call(any(List.class))) + .thenReturn( + Mono.just( + Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("Response").build()) + .build())); + + SubAgentConfig config = SubAgentConfig.builder().forwardEvents(false).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = new HashMap<>(); + input.put("message", "Hello"); + input.put("injectedUserId", "1001"); + input.put("injectedRole", "admin"); + + ToolUseBlock toolUse = + ToolUseBlock.builder().id("1").name("call_testagent").input(input).build(); + tool.callAsync(ToolCallParam.builder().toolUseBlock(toolUse).input(input).build()).block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockAgent).call(captor.capture()); + + List capturedMessages = captor.getValue(); + assertFalse(capturedMessages == null || capturedMessages.isEmpty()); + + Msg sentMsg = capturedMessages.get(0); + Map metadata = sentMsg.getMetadata(); + + // Verify if custom parameters are injected correctly + // and if system parameters are filtered + assertNotNull(metadata, "Metadata should not be null"); + assertEquals("1001", metadata.get("injectedUserId"), "injectedUserId should match"); + assertEquals("admin", metadata.get("injectedRole"), "injectedRole should match"); + assertFalse(metadata.containsKey("message"), "message should NOT be in metadata"); + assertFalse(metadata.containsKey("session_id"), "session_id should NOT be in metadata"); + } + // Helper methods private Agent createMockAgent(String name, String description) { diff --git a/docs/en/multi-agent/subagent.md b/docs/en/multi-agent/subagent.md index 8c620bc708..433b039c88 100644 --- a/docs/en/multi-agent/subagent.md +++ b/docs/en/multi-agent/subagent.md @@ -108,6 +108,41 @@ The example uses **TaskToolsBuilder** to create the **Task** and **TaskOutput** **Synchronous vs background**: By default, the Task tool runs the sub-agent and returns its reply. If you pass a flag like `run_in_background=true`, the tool returns a `task_id` instead; the orchestrator (or user) can later call **TaskOutput** with that `task_id` to get the result. This requires a **TaskRepository** to store in-flight tasks. +## Passing Custom Context / Parameters + +In real-world applications, you may need to securely pass system context (e.g., `userId`, `tenantId`) to a sub-agent without hardcoding them into the natural language prompt. + +`SubAgentTool` supports declaring and receiving custom parameters. These parameters can be actively generated by the LLM (if exposed in the tool schema) or securely injected by the system via `ToolExecutionContext`. They are ultimately attached to the `metadata` of the message received by the sub-agent. + +**1. Declare parameters (Configuration)** +Expose custom parameters via `SubAgentConfig` (supports simple types or full JSON Schema): + +```java +SubAgentConfig config = SubAgentConfig.builder() + // Declare a required simple parameter + .addParameter("userId", "string", "The ID of the current user", true) + // Declare a complex enum parameter + .addParameter("role", Map.of("type", "string", "enum", List.of("admin", "user")), false) + .build(); + +SubAgentTool tool = new SubAgentTool(agentProvider, config); +``` + +**2. Retrieve parameters in the sub-agent** +When the sub-agent is invoked, custom parameters are safely mounted in the `metadata` of the input message: + +```java +public Mono call(List messages) { + Msg userMsg = messages.get(messages.size() - 1); + + // Safely retrieve injected context from metadata + Map metadata = userMsg.getMetadata(); + String userId = (String) metadata.get("userId"); + + // ... execute specific logic based on userId ... +} +``` + ## Example: Tech Due Diligence Assistant The AgentScope example implements a **Tech Due Diligence Assistant**: one orchestrator that delegates to four sub-agents. diff --git a/docs/zh/multi-agent/subagent.md b/docs/zh/multi-agent/subagent.md index fa83a4d31b..777d4d2f83 100644 --- a/docs/zh/multi-agent/subagent.md +++ b/docs/zh/multi-agent/subagent.md @@ -108,6 +108,41 @@ TaskToolsBuilder.builder() **同步与后台**:默认 Task 工具会运行子智能体并返回其回复。若传入类似 `run_in_background=true` 的标志,工具会返回 `task_id`;编排智能体(或用户)之后可用 **TaskOutput** 传入该 `task_id` 获取结果。这需要 **TaskRepository** 存储进行中的任务。 +## 传递自定义上下文与参数 + +在实际业务中,您可能需要向子智能体安全地透传外部系统上下文(如 `userId`、`tenantId`),而不是将它们硬编码到自然语言的 prompt 中。 + +`SubAgentTool` 支持声明和接收自定义参数。这些参数可以通过大模型主动生成(如果暴露在 Schema 中),也可以通过 `ToolExecutionContext` 由系统外部安全注入,并最终存放在子智能体收到消息的 `metadata` 中。 + +**1. 声明参数 (配置项)** +通过 `SubAgentConfig` 暴露自定义参数(支持简单类型或完整的 JSON Schema): + +```java +SubAgentConfig config = SubAgentConfig.builder() + // 声明一个必填的简单参数 + .addParameter("userId", "string", "当前操作用户的 ID", true) + // 声明一个复杂的枚举参数 + .addParameter("role", Map.of("type", "string", "enum", List.of("admin", "user")), false) + .build(); + +SubAgentTool tool = new SubAgentTool(agentProvider, config); +``` + +**2. 在子智能体中读取参数** +当子智能体被调用时,自定义参数会被安全地挂载到输入消息的 `metadata` 中。您可以在子智能体的逻辑中这样提取: + +```java +public Mono call(List messages) { + Msg userMsg = messages.get(messages.size() - 1); + + // 从 metadata 中安全获取透传的上下文 + Map metadata = userMsg.getMetadata(); + String userId = (String) metadata.get("userId"); + + // ... 基于 userId 执行特定逻辑 ... +} +``` + ## 示例:技术尽调助手 AgentScope 示例实现了一个**技术尽调助手**:一个编排智能体委托给四个子智能体。 From b566723a60dde462bd0a63bb66c569792423bbdb Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 20 Apr 2026 19:11:39 +0800 Subject: [PATCH 2/6] fix: copilot review --- .../core/tool/subagent/SubAgentConfig.java | 29 +++++++- .../core/tool/subagent/SubAgentTool.java | 73 ++++++++++++++++--- .../tool/subagent/SubAgentConfigTest.java | 31 ++++++++ .../core/tool/subagent/SubAgentToolTest.java | 66 ++++++++++++++++- docs/en/multi-agent/subagent.md | 3 + docs/zh/multi-agent/subagent.md | 3 + 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index ce0ce0d2bb..62e287070b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -20,6 +20,7 @@ import io.agentscope.core.session.JsonSession; import io.agentscope.core.session.Session; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -31,7 +32,7 @@ * supports smart defaults that derive tool name and description from the agent itself. * *

Sub-agents operate in conversation mode, supporting multi-turn dialogue with session - * management. The tool exposes two parameters: + * management. The tool exposes the following built-in parameters: * *

    *
  • {@code message} - Required. The message to send to the agent. @@ -39,6 +40,9 @@ * an existing one. *
* + *

Users can also define additional custom parameters to be passed to the sub-agent via + * the {@link Builder#addParameter} methods. + * *

Default Behavior: * *

    @@ -54,11 +58,12 @@ * // Minimal configuration - uses all defaults * SubAgentConfig config = SubAgentConfig.defaults(); * - * // Custom configuration with persistent session + * // Custom configuration with persistent session and custom parameters * SubAgentConfig config = SubAgentConfig.builder() * .toolName("ask_expert") * .description("Ask the expert a question") * .session(new JsonSession(Path.of("sessions"))) + * .addParameter("userId", "string", "The user ID", true) * .build(); * } */ @@ -166,7 +171,9 @@ public Session getSession() { * @return A map of parameter names to their JSON schema definitions */ public Map> getCustomParameters() { - return customParameters; + return customParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(customParameters); } /** @@ -175,7 +182,9 @@ public Map> getCustomParameters() { * @return A list containing the names of required parameters */ public List getRequiredCustomParameters() { - return requiredCustomParameters; + return requiredCustomParameters == null + ? Collections.emptyList() + : Collections.unmodifiableList(requiredCustomParameters); } /** Builder for SubAgentConfig. */ @@ -291,8 +300,20 @@ public Builder addParameter( * @param schema The JSON schema map definition for the parameter * @param required true if the parameter is required, false otherwise * @return This builder + * @throws IllegalArgumentException If the {@code name} is null, empty, or a reserved + * system parameter (e.g., "message" or "session_id"). */ public Builder addParameter(String name, Map schema, boolean required) { + if ("message".equals(name) || "session_id".equals(name)) { + throw new IllegalArgumentException( + "Cannot use reserved parameter name: '" + + name + + "'. This is a built-in system parameter."); + } + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Parameter name cannot be null or empty."); + } + this.customParameters.put(name, schema); if (required) { this.requiredCustomParameters.add(name); diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index f9dd77bb69..8b0d0f8681 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -27,13 +27,16 @@ import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.ToolEmitter; +import io.agentscope.core.tool.ToolExecutionContext; import io.agentscope.core.util.JsonUtils; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,13 +51,17 @@ *

    Thread safety is ensured by using {@link SubAgentProvider} to create a fresh agent instance * for each new session. * - *

    The tool exposes two parameters: + *

    By default, the tool exposes two core parameters: * *

      *
    • {@code session_id} - Optional. Omit to start a new session, provide to continue an * existing one. *
    • {@code message} - Required. The message to send to the agent. *
    + * + *

    Additionally, custom parameters defined in {@link SubAgentConfig} are exposed in the + * tool schema. At runtime, these parameters are extracted (prioritizing {@link ToolExecutionContext} + * over LLM input) and securely injected into the sub-agent's message {@code metadata}. */ public class SubAgentTool implements AgentTool { @@ -117,6 +124,8 @@ public Mono callAsync(ToolCallParam param) { *

      *
    • Session ID generation for new conversations *
    • Agent state loading for continued sessions + *
    • Extraction of custom parameters from ToolExecutionContext and LLM input + *
    • Secure injection of custom parameters into the message metadata *
    • Message execution (streaming or non-streaming based on config) *
    • Agent state persistence after execution *
    @@ -144,11 +153,49 @@ private Mono executeConversation(ToolCallParam param) { } // Extract custom external parameters (filter out system built-in keys) - Map customMetadata = new HashMap<>(); - for (Map.Entry entry : input.entrySet()) { - String key = entry.getKey(); - if (!PARAM_SESSION_ID.equals(key) && !PARAM_MESSAGE.equals(key)) { - customMetadata.put(key, entry.getValue()); + Map finalMetadata = new HashMap<>(); + Map> declaredCustomParams = + config.getCustomParameters(); + + if (declaredCustomParams != null && !declaredCustomParams.isEmpty()) { + for (String paramName : declaredCustomParams.keySet()) { + boolean injectedFromContext = false; + + // Prioritize obtaining from ToolExecutionContext + ToolExecutionContext context = param.getContext(); + if (context != null) { + // sequentially try the standard JSON types that could be passed + // to the LLM + Object ctxValue = context.get(paramName, String.class); + if (ctxValue == null) { + ctxValue = context.get(paramName, Integer.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Boolean.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Double.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Map.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, List.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Object.class); + } + + if (ctxValue != null) { + finalMetadata.put(paramName, ctxValue); + injectedFromContext = true; + } + } + + // Attempt to retrieve from input + if (!injectedFromContext && input.containsKey(paramName)) { + finalMetadata.put(paramName, input.get(paramName)); + } } } @@ -166,7 +213,7 @@ private Mono executeConversation(ToolCallParam param) { Msg.builder() .role(MsgRole.USER) .content(TextBlock.builder().text(message).build()) - .metadata(customMetadata) + .metadata(finalMetadata) .build(); logger.debug( @@ -361,13 +408,17 @@ private ToolResultBlock buildResult(Msg response, String sessionId) { /** * Builds the JSON schema for tool parameters. * - *

    Creates a schema with two properties: + *

    Creates a schema with two core properties: * *

      *
    • {@code session_id} - Optional string for continuing existing conversations *
    • {@code message} - Required string containing the message to send *
    * + *

    If custom parameters are defined in the configuration, they are merged into + * the properties map, and their required status is updated accordingly. + * The schema strictly disables {@code additionalProperties}. + * * @return A map representing the JSON schema for tool parameters */ private Map buildSchema() { @@ -399,13 +450,15 @@ private Map buildSchema() { } schema.put("properties", properties); + schema.put("additionalProperties", false); - List requiredFields = new ArrayList<>(); + // Prevent duplicate elements from appearing in the required array + Set requiredFields = new LinkedHashSet<>(); requiredFields.add(PARAM_MESSAGE); if (config.getRequiredCustomParameters() != null) { requiredFields.addAll(config.getRequiredCustomParameters()); } - schema.put("required", requiredFields); + schema.put("required", new ArrayList<>(requiredFields)); return schema; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index 16bd3d9e52..d9654a6a6e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -20,6 +20,7 @@ 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 io.agentscope.core.agent.EventType; @@ -160,6 +161,36 @@ void testDefaultSessionWhenNotSpecified() { assertInstanceOf(InMemorySession.class, config.getSession()); } + @Test + @DisplayName("Should throw exception when adding reserved parameter name") + void testAddReservedParameterThrowsException() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addParameter("message", "string", "Invalid param", true), + "Should throw IllegalArgumentException for reserved 'message'"); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addParameter("session_id", "string", "Invalid param", false), + "Should throw IllegalArgumentException for reserved 'session_id'"); + } + + @Test + @DisplayName("Should throw exception when parameter name is empty") + void testAddEmptyParameterNameThrowsException() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addParameter("", "string", "Invalid param", true)); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addParameter(null, "string", "Invalid param", true)); + } + @Test @DisplayName("Should build config with simple custom parameter") void testSimpleCustomParameter() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index e3cb2a3f26..83bc0fd8c6 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -36,6 +36,7 @@ import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.ToolEmitter; +import io.agentscope.core.tool.ToolExecutionContext; import io.agentscope.core.tool.Toolkit; import java.util.ArrayList; import java.util.HashMap; @@ -512,7 +513,7 @@ void testSchemaWithCustomParameters() { } @Test - @DisplayName("Should extract custom parameters and inject them into Msg metadata") + @DisplayName("Should extract declared custom parameters and inject into Msg metadata") void testCustomParametersInjectedIntoMetadata() { Agent mockAgent = mock(Agent.class); when(mockAgent.getName()).thenReturn("TestAgent"); @@ -526,13 +527,21 @@ void testCustomParametersInjectedIntoMetadata() { .content(TextBlock.builder().text("Response").build()) .build())); - SubAgentConfig config = SubAgentConfig.builder().forwardEvents(false).build(); + SubAgentConfig config = + SubAgentConfig.builder() + .forwardEvents(false) + .addParameter("injectedUserId", "string", "User ID", true) + .addParameter("injectedRole", "string", "Role", false) + .build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); Map input = new HashMap<>(); input.put("message", "Hello"); input.put("injectedUserId", "1001"); input.put("injectedRole", "admin"); + // Mixing an undeclared field + input.put("maliciousField", "hacked"); ToolUseBlock toolUse = ToolUseBlock.builder().id("1").name("call_testagent").input(input).build(); @@ -553,10 +562,63 @@ void testCustomParametersInjectedIntoMetadata() { assertNotNull(metadata, "Metadata should not be null"); assertEquals("1001", metadata.get("injectedUserId"), "injectedUserId should match"); assertEquals("admin", metadata.get("injectedRole"), "injectedRole should match"); + + assertFalse( + metadata.containsKey("maliciousField"), + "Undeclared parameters MUST be filtered out"); assertFalse(metadata.containsKey("message"), "message should NOT be in metadata"); assertFalse(metadata.containsKey("session_id"), "session_id should NOT be in metadata"); } + @Test + @DisplayName("Should prioritize ToolExecutionContext over LLM input for custom parameters") + void testToolExecutionContextPriorityOverLlmInput() { + Agent mockAgent = createMockAgent("PriorityAgent", "Test priority"); + + SubAgentConfig config = + SubAgentConfig.builder() + .forwardEvents(false) + .addParameter("tenantId", "string", "Tenant ID", true) + .build(); + + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + // Simulate LLM attempting to pass forged tenantId + Map llmInput = new HashMap<>(); + llmInput.put("message", "Do action"); + llmInput.put("tenantId", "FAKE_TENANT_FROM_LLM"); + + ToolExecutionContext systemContext = + ToolExecutionContext.builder() + .register("tenantId", "REAL_SYSTEM_TENANT_ID") + .build(); + + ToolUseBlock toolUse = + ToolUseBlock.builder().id("1").name("call_priorityagent").input(llmInput).build(); + + // Simultaneously input LLM input and system context + tool.callAsync( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .input(llmInput) + .context(systemContext) + .build()) + .block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockAgent).call(captor.capture()); + + Msg sentMsg = captor.getValue().get(0); + Map metadata = sentMsg.getMetadata(); + + assertNotNull(metadata); + assertEquals( + "REAL_SYSTEM_TENANT_ID", + metadata.get("tenantId"), + "System Context value MUST override LLM input"); + } + // Helper methods private Agent createMockAgent(String name, String description) { diff --git a/docs/en/multi-agent/subagent.md b/docs/en/multi-agent/subagent.md index 433b039c88..c26f2fdbe9 100644 --- a/docs/en/multi-agent/subagent.md +++ b/docs/en/multi-agent/subagent.md @@ -128,6 +128,9 @@ SubAgentConfig config = SubAgentConfig.builder() SubAgentTool tool = new SubAgentTool(agentProvider, config); ``` +**🔒 Precedence ** +When resolving custom parameters at runtime, the framework strictly prioritizes the **System Context** over the **LLM Input**. If a parameter (e.g., `userId`) is securely injected via `ToolExecutionContext`, it will **override** any value hallucinated or generated by the LLM. + **2. Retrieve parameters in the sub-agent** When the sub-agent is invoked, custom parameters are safely mounted in the `metadata` of the input message: diff --git a/docs/zh/multi-agent/subagent.md b/docs/zh/multi-agent/subagent.md index 777d4d2f83..2cc5d3f77a 100644 --- a/docs/zh/multi-agent/subagent.md +++ b/docs/zh/multi-agent/subagent.md @@ -128,6 +128,9 @@ SubAgentConfig config = SubAgentConfig.builder() SubAgentTool tool = new SubAgentTool(agentProvider, config); ``` +**🔒 优先级注入** +在运行时解析自定义参数时,框架会严格遵循 **“系统上下文优先于大模型输入”** 的原则。如果通过 `ToolExecutionContext` 在系统外部注入了参数(如 `userId`),它将**强制覆盖**大模型生成或幻觉出的同名参数。 + **2. 在子智能体中读取参数** 当子智能体被调用时,自定义参数会被安全地挂载到输入消息的 `metadata` 中。您可以在子智能体的逻辑中这样提取: From b51693fdde3cab84772dfd2bef3a510cdc52fe8d Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 20 Apr 2026 22:59:25 +0800 Subject: [PATCH 3/6] refactor --- .../core/tool/subagent/SubAgentConfig.java | 61 ++++++++- .../core/tool/subagent/SubAgentTool.java | 104 ++++++++++----- .../tool/subagent/SubAgentConfigTest.java | 36 +++++ .../core/tool/subagent/SubAgentToolTest.java | 123 ++++++++---------- docs/en/multi-agent/subagent.md | 67 +++++++--- docs/zh/multi-agent/subagent.md | 65 ++++++--- 6 files changed, 319 insertions(+), 137 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index 62e287070b..ccc404d792 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -22,8 +22,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * Configuration for sub-agent registration. @@ -40,8 +42,16 @@ * an existing one. *

* - *

Users can also define additional custom parameters to be passed to the sub-agent via - * the {@link Builder#addParameter} methods. + *

Users can also define additional parameters to be passed to the sub-agent. The framework + * strictly separates these into two categories for security: + * + *

    + *
  • Custom Parameters: Added via {@link Builder#addParameter}. These are exposed to the + * LLM in the tool schema, allowing the LLM to infer and generate values based on the conversation. + *
  • System Parameters: Added via {@link Builder#addSystemParameter}. These are strictly + * injected by the system via {@code ToolExecutionContext}. They are completely transparent + * (invisible) to the LLM, preventing prompt injection. + *
* *

Default Behavior: * @@ -76,6 +86,7 @@ public class SubAgentConfig { private final Session session; private final Map> customParameters; private final List requiredCustomParameters; + private final Set systemParameters; private SubAgentConfig(Builder builder) { this.toolName = builder.toolName; @@ -89,6 +100,8 @@ private SubAgentConfig(Builder builder) { builder.requiredCustomParameters != null ? builder.requiredCustomParameters : new ArrayList<>(); + this.systemParameters = + builder.systemParameters != null ? builder.systemParameters : new LinkedHashSet<>(); } /** @@ -187,6 +200,20 @@ public List getRequiredCustomParameters() { : Collections.unmodifiableList(requiredCustomParameters); } + /** + * Gets the system parameters defined for the sub-agent tool. + * + *

System parameters are injected transparently via ToolExecutionContext + * and are NOT exposed to the LLM in the JSON schema. + * + * @return A set containing the names of system parameters + */ + public Set getSystemParameters() { + return systemParameters == null + ? Collections.emptySet() + : Collections.unmodifiableSet(systemParameters); + } + /** Builder for SubAgentConfig. */ public static class Builder { private String toolName; @@ -196,6 +223,7 @@ public static class Builder { private Session session; private Map> customParameters = new HashMap<>(); private List requiredCustomParameters = new ArrayList<>(); + private Set systemParameters = new LinkedHashSet<>(); private Builder() {} @@ -321,6 +349,35 @@ public Builder addParameter(String name, Map schema, boolean req return this; } + /** + * Adds a system parameter (e.g., userId) to be injected securely. + * + *

Unlike custom parameters, system parameters are completely invisible to the + * language model and will NOT be included in the generated JSON schema. They are + * extracted strictly from the {@link io.agentscope.core.tool.ToolExecutionContext} + * at runtime, preventing prompt injection attacks and LLM hallucination. + * + * @param name The name of the system parameter + * @return This builder + * @throws IllegalArgumentException If the {@code name} is null, empty, or a reserved + * system parameter (e.g., "message" or "session_id"). + */ + public Builder addSystemParameter(String name) { + if ("message".equals(name) || "session_id".equals(name)) { + throw new IllegalArgumentException( + "Cannot use reserved parameter name: '" + + name + + "'. This is a built-in parameter."); + } + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException( + "System parameter name cannot be null or empty."); + } + + this.systemParameters.add(name); + return this; + } + /** * Builds the configuration. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index 8b0d0f8681..cd4b96f392 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -59,9 +59,15 @@ *

  • {@code message} - Required. The message to send to the agent. * * - *

    Additionally, custom parameters defined in {@link SubAgentConfig} are exposed in the - * tool schema. At runtime, these parameters are extracted (prioritizing {@link ToolExecutionContext} - * over LLM input) and securely injected into the sub-agent's message {@code metadata}. + *

    Additionally, the tool supports two categories of external parameters defined in + * {@link SubAgentConfig}: + *

      + *
    • System Parameters: Extracted strictly from {@link ToolExecutionContext}. These are + * invisible to the LLM and completely tamper-proof. + *
    • Custom Parameters: Exposed in the tool schema for LLM inference. At runtime, these + * are extracted with {@link ToolExecutionContext} taking precedence over LLM input. + *
    + * Both types of parameters are securely injected into the sub-agent's message {@code metadata}. */ public class SubAgentTool implements AgentTool { @@ -124,7 +130,8 @@ public Mono callAsync(ToolCallParam param) { *
      *
    • Session ID generation for new conversations *
    • Agent state loading for continued sessions - *
    • Extraction of custom parameters from ToolExecutionContext and LLM input + *
    • Extraction of external parameters with strict priority: + * {@link ToolExecutionContext} (highest) > LLM input (lowest) *
    • Secure injection of custom parameters into the message metadata *
    • Message execution (streaming or non-streaming based on config) *
    • Agent state persistence after execution @@ -152,47 +159,41 @@ private Mono executeConversation(ToolCallParam param) { return Mono.just(ToolResultBlock.error("Message is required")); } - // Extract custom external parameters (filter out system built-in keys) + // Extract external parameters Map finalMetadata = new HashMap<>(); + ToolExecutionContext context = param.getContext(); + + // 1. Extract system parameters (Securely injected, invisible to LLM) + Set declaredSystemParams = config.getSystemParameters(); + if (declaredSystemParams != null && !declaredSystemParams.isEmpty()) { + for (String paramName : declaredSystemParams) { + Object ctxValue = extractFromContext(context, paramName); + if (ctxValue != null) { + finalMetadata.put(paramName, ctxValue); + } else { + logger.warn( + "System parameter '{}' was declared but not provided in" + + " ToolExecutionContext", + paramName); + } + } + } + + // 2. Extract custom parameters (LLM inferred, Context overrides LLM) Map> declaredCustomParams = config.getCustomParameters(); - if (declaredCustomParams != null && !declaredCustomParams.isEmpty()) { for (String paramName : declaredCustomParams.keySet()) { boolean injectedFromContext = false; // Prioritize obtaining from ToolExecutionContext - ToolExecutionContext context = param.getContext(); - if (context != null) { - // sequentially try the standard JSON types that could be passed - // to the LLM - Object ctxValue = context.get(paramName, String.class); - if (ctxValue == null) { - ctxValue = context.get(paramName, Integer.class); - } - if (ctxValue == null) { - ctxValue = context.get(paramName, Boolean.class); - } - if (ctxValue == null) { - ctxValue = context.get(paramName, Double.class); - } - if (ctxValue == null) { - ctxValue = context.get(paramName, Map.class); - } - if (ctxValue == null) { - ctxValue = context.get(paramName, List.class); - } - if (ctxValue == null) { - ctxValue = context.get(paramName, Object.class); - } - - if (ctxValue != null) { - finalMetadata.put(paramName, ctxValue); - injectedFromContext = true; - } + Object ctxValue = extractFromContext(context, paramName); + if (ctxValue != null) { + finalMetadata.put(paramName, ctxValue); + injectedFromContext = true; } - // Attempt to retrieve from input + // Attempt to retrieve from LLM input if not injected by context if (!injectedFromContext && input.containsKey(paramName)) { finalMetadata.put(paramName, input.get(paramName)); } @@ -248,6 +249,39 @@ private Mono executeConversation(ToolCallParam param) { }); } + /** + * Extracts a value from the ToolExecutionContext by trying standard JSON types. + * + * @param context The execution context + * @param paramName The name of the parameter to extract + * @return The extracted value, or null if not found + */ + private Object extractFromContext(ToolExecutionContext context, String paramName) { + if (context == null) { + return null; + } + Object ctxValue = context.get(paramName, String.class); + if (ctxValue == null) { + ctxValue = context.get(paramName, Integer.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Boolean.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Double.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Map.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, List.class); + } + if (ctxValue == null) { + ctxValue = context.get(paramName, Object.class); + } + return ctxValue; + } + /** * Loads agent state from the session storage. * diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index d9654a6a6e..cbed87d543 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -234,6 +234,32 @@ void testSchemaCustomParameter() { assertFalse(config.getRequiredCustomParameters().contains("role")); } + @Test + @DisplayName("Should throw exception when adding reserved system parameter name") + void testAddReservedSystemParameterThrowsException() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addSystemParameter("message"), + "Should throw IllegalArgumentException for reserved 'message'"); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addSystemParameter("session_id"), + "Should throw IllegalArgumentException for reserved 'session_id'"); + } + + @Test + @DisplayName("Should throw exception when system parameter name is empty") + void testAddEmptySystemParameterNameThrowsException() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + assertThrows(IllegalArgumentException.class, () -> builder.addSystemParameter("")); + + assertThrows(IllegalArgumentException.class, () -> builder.addSystemParameter(null)); + } + @Test @DisplayName("Builder should be chainable") void testBuilderChaining() { @@ -246,6 +272,8 @@ void testBuilderChaining() { .forwardEvents(true) .streamOptions(null) .session(new InMemorySession()) + .addParameter("param", null, true) + .addSystemParameter("sysParam") .build(); assertNotNull(config); @@ -302,6 +330,14 @@ void testGetRequiredCustomParametersEmpty() { assertNotNull(config.getRequiredCustomParameters()); assertTrue(config.getRequiredCustomParameters().isEmpty()); } + + @Test + @DisplayName("getSystemParameters() should return empty set when not set") + void testGetSystemParametersEmpty() { + SubAgentConfig config = SubAgentConfig.builder().build(); + assertNotNull(config.getSystemParameters()); + assertTrue(config.getSystemParameters().isEmpty()); + } } @Nested diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index 83bc0fd8c6..1348de0a32 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -482,7 +482,7 @@ void testToolNameGenerationTruncation() { } @Test - @DisplayName("Should generate schema containing custom parameters") + @DisplayName("Should generate schema containing custom parameters but NOT system parameters") void testSchemaWithCustomParameters() { Agent mockAgent = createMockAgent("TestAgent", "Test"); @@ -490,10 +490,10 @@ void testSchemaWithCustomParameters() { SubAgentConfig.builder() .addParameter("userId", "string", "The user ID", true) .addParameter("tenantId", "string", "The tenant ID", false) + .addSystemParameter("secureSystemToken") // System parameter .build(); SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); - Map schema = tool.getParameters(); @SuppressWarnings("unchecked") @@ -502,101 +502,88 @@ void testSchemaWithCustomParameters() { // Custom params should exist assertTrue(properties.containsKey("userId")); assertTrue(properties.containsKey("tenantId")); + // System params MUST be invisible to LLM schema + assertFalse(properties.containsKey("secureSystemToken")); @SuppressWarnings("unchecked") List required = (List) schema.get("required"); - - // Check required fields assertTrue(required.contains("message")); - assertTrue(required.contains("userId")); // Custom required - assertFalse(required.contains("tenantId")); // Custom optional + assertTrue(required.contains("userId")); + assertFalse(required.contains("tenantId")); } @Test - @DisplayName("Should extract declared custom parameters and inject into Msg metadata") - void testCustomParametersInjectedIntoMetadata() { - Agent mockAgent = mock(Agent.class); - when(mockAgent.getName()).thenReturn("TestAgent"); - when(mockAgent.getDescription()).thenReturn("Test"); - - when(mockAgent.call(any(List.class))) - .thenReturn( - Mono.just( - Msg.builder() - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("Response").build()) - .build())); - + @DisplayName( + "Should extract custom parameters, prioritize Context override, and filter malicious" + + " fields") + void testCustomParameterExtractionAndPriority() { + Agent mockAgent = createMockAgent("TestAgent", "Test"); SubAgentConfig config = SubAgentConfig.builder() .forwardEvents(false) .addParameter("injectedUserId", "string", "User ID", true) - .addParameter("injectedRole", "string", "Role", false) + .addParameter("tenantId", "string", "Tenant ID", false) .build(); - SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); - Map input = new HashMap<>(); - input.put("message", "Hello"); - input.put("injectedUserId", "1001"); - input.put("injectedRole", "admin"); - // Mixing an undeclared field - input.put("maliciousField", "hacked"); + Map llmInput = new HashMap<>(); + llmInput.put("message", "Hello"); + llmInput.put("injectedUserId", "1001"); // Normal business parameters + llmInput.put("tenantId", "FAKE_TENANT_FROM_LLM"); // forge parameters + llmInput.put("maliciousField", "hacked"); // Undeclared malicious fields + + ToolExecutionContext systemContext = + ToolExecutionContext.builder() + .register("tenantId", "REAL_SYSTEM_TENANT_ID") + .build(); ToolUseBlock toolUse = - ToolUseBlock.builder().id("1").name("call_testagent").input(input).build(); - tool.callAsync(ToolCallParam.builder().toolUseBlock(toolUse).input(input).build()).block(); + ToolUseBlock.builder().id("1").name("call_testagent").input(llmInput).build(); + tool.callAsync( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .input(llmInput) + .context(systemContext) + .build()) + .block(); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(mockAgent).call(captor.capture()); + Map metadata = captor.getValue().get(0).getMetadata(); - List capturedMessages = captor.getValue(); - assertFalse(capturedMessages == null || capturedMessages.isEmpty()); - - Msg sentMsg = capturedMessages.get(0); - Map metadata = sentMsg.getMetadata(); - - // Verify if custom parameters are injected correctly - // and if system parameters are filtered - assertNotNull(metadata, "Metadata should not be null"); - assertEquals("1001", metadata.get("injectedUserId"), "injectedUserId should match"); - assertEquals("admin", metadata.get("injectedRole"), "injectedRole should match"); - - assertFalse( - metadata.containsKey("maliciousField"), - "Undeclared parameters MUST be filtered out"); - assertFalse(metadata.containsKey("message"), "message should NOT be in metadata"); - assertFalse(metadata.containsKey("session_id"), "session_id should NOT be in metadata"); + assertNotNull(metadata); + assertEquals("1001", metadata.get("injectedUserId")); + assertEquals( + "REAL_SYSTEM_TENANT_ID", + metadata.get("tenantId"), + "System Context MUST override LLM input"); + assertFalse(metadata.containsKey("maliciousField")); + assertFalse(metadata.containsKey("message")); } @Test - @DisplayName("Should prioritize ToolExecutionContext over LLM input for custom parameters") - void testToolExecutionContextPriorityOverLlmInput() { - Agent mockAgent = createMockAgent("PriorityAgent", "Test priority"); - + @DisplayName( + "Should securely inject system parameters and completely ignore LLM prompt injections") + void testSystemParameterSecurityAndInjectionDefense() { + Agent mockAgent = createMockAgent("DefenseAgent", "Test prompt injection defense"); SubAgentConfig config = SubAgentConfig.builder() .forwardEvents(false) - .addParameter("tenantId", "string", "Tenant ID", true) + .addSystemParameter("authUserId") // System parameters with real context + .addSystemParameter("adminBypassToken") // Forge system parameters by LLM .build(); - SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); - // Simulate LLM attempting to pass forged tenantId Map llmInput = new HashMap<>(); - llmInput.put("message", "Do action"); - llmInput.put("tenantId", "FAKE_TENANT_FROM_LLM"); + llmInput.put("message", "Hack system"); + llmInput.put("adminBypassToken", "FORGED_TOKEN"); ToolExecutionContext systemContext = - ToolExecutionContext.builder() - .register("tenantId", "REAL_SYSTEM_TENANT_ID") - .build(); + ToolExecutionContext.builder().register("authUserId", "sys_user_999").build(); ToolUseBlock toolUse = - ToolUseBlock.builder().id("1").name("call_priorityagent").input(llmInput).build(); - - // Simultaneously input LLM input and system context + ToolUseBlock.builder().id("1").name("call_defenseagent").input(llmInput).build(); tool.callAsync( ToolCallParam.builder() .toolUseBlock(toolUse) @@ -608,15 +595,13 @@ void testToolExecutionContextPriorityOverLlmInput() { @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(mockAgent).call(captor.capture()); - - Msg sentMsg = captor.getValue().get(0); - Map metadata = sentMsg.getMetadata(); + Map metadata = captor.getValue().get(0).getMetadata(); assertNotNull(metadata); - assertEquals( - "REAL_SYSTEM_TENANT_ID", - metadata.get("tenantId"), - "System Context value MUST override LLM input"); + assertEquals("sys_user_999", metadata.get("authUserId")); + assertFalse( + metadata.containsKey("adminBypassToken"), + "Forged system parameter from LLM MUST be ignored"); } // Helper methods diff --git a/docs/en/multi-agent/subagent.md b/docs/en/multi-agent/subagent.md index c26f2fdbe9..92aa7a34bf 100644 --- a/docs/en/multi-agent/subagent.md +++ b/docs/en/multi-agent/subagent.md @@ -108,41 +108,76 @@ The example uses **TaskToolsBuilder** to create the **Task** and **TaskOutput** **Synchronous vs background**: By default, the Task tool runs the sub-agent and returns its reply. If you pass a flag like `run_in_background=true`, the tool returns a `task_id` instead; the orchestrator (or user) can later call **TaskOutput** with that `task_id` to get the result. This requires a **TaskRepository** to store in-flight tasks. -## Passing Custom Context / Parameters +## Passing Custom Context and Parameters -In real-world applications, you may need to securely pass system context (e.g., `userId`, `tenantId`) to a sub-agent without hardcoding them into the natural language prompt. +In real-world applications, you may need to pass additional parameters (e.g., `userId`) to a sub-agent alongside the `message`. `SubAgentTool` supports two distinct parameter injection methods: -`SubAgentTool` supports declaring and receiving custom parameters. These parameters can be actively generated by the LLM (if exposed in the tool schema) or securely injected by the system via `ToolExecutionContext`. They are ultimately attached to the `metadata` of the message received by the sub-agent. +1. **LLM Dynamic Injection (Business Variables)**: Inferred by the LLM based on the user's chat context (e.g., target translation language, analysis depth). +2. **System Context Injection (Security Variables)**: Injected by the underlying system via `ToolExecutionContext`. This is completely transparent to the LLM and tamper-proof (e.g., `userId`, `tenantId`). -**1. Declare parameters (Configuration)** -Expose custom parameters via `SubAgentConfig` (supports simple types or full JSON Schema): +### 1. Declare parameters +First, declare custom parameters via `SubAgentConfig`. The framework strictly distinguishes between two types of parameters to ensure security and flexibility: ```java SubAgentConfig config = SubAgentConfig.builder() - // Declare a required simple parameter - .addParameter("userId", "string", "The ID of the current user", true) - // Declare a complex enum parameter - .addParameter("role", Map.of("type", "string", "enum", List.of("admin", "user")), false) + // 1. Declare a business variable (via addParameter: visible to the LLM, inferred by the LLM based on the conversation) + .addParameter("analysis_depth", Map.of("type", "string", "enum", List.of("basic", "detailed")), false) + // 2. Declare a security variable (via addSystemParameter: invisible to the LLM, strictly injected by the underlying system) + .addSystemParameter("userId") .build(); SubAgentTool tool = new SubAgentTool(agentProvider, config); ``` -**🔒 Precedence ** -When resolving custom parameters at runtime, the framework strictly prioritizes the **System Context** over the **LLM Input**. If a parameter (e.g., `userId`) is securely injected via `ToolExecutionContext`, it will **override** any value hallucinated or generated by the LLM. +### 2. Examples of injection methods -**2. Retrieve parameters in the sub-agent** -When the sub-agent is invoked, custom parameters are safely mounted in the `metadata` of the input message: +#### Method 1: LLM Dynamic Injection (Business Variables) +Suitable for **business properties**. Variables declared via `addParameter` (such as `analysis_depth`) will be rendered into the JSON Schema passed to the LLM. +When the user says: *"Help me do an extremely deep code review"*, the LLM will automatically infer and generate the following call: +```json +{ + "message": "Review the codebase", + "analysis_depth": "detailed" +} +``` + +💡 Backend Intervention (Fallback Mechanism): Although business variables are inferred by the LLM, the framework still allows the backend to inject a parameter with the same name via ToolExecutionContext. If the system is in a degraded mode or requires special overrides, the value injected by the underlying system will forcibly override the LLM's inference, ensuring absolute backend control. + +#### Method 2: System Context Injection (Security Variables) +Suitable for **sensitive security properties** (e.g., `userId`). Variables declared via `addSystemParameter` are **completely invisible** to the LLM. The system interceptor will securely inject them directly at runtime. +```java +// Register the context at the system entry point +ToolExecutionContext context = ToolExecutionContext.builder() + .register("userId", String.class, "user_123") // Explicitly specify the type as String.class + .build(); + +// Pass the context during execution +ToolCallParam param = ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(Map.of("message", "Check my order")) + .context(context) + .build(); + +tool.callAsync(param).subscribe(); +``` + +> **🔒 Security and Priority** +> Because system parameters (such as `userId`) are declared via `addSystemParameter`, they will not appear in the Schema sent to the LLM. At runtime, the framework strictly follows the principle of **"Absolute Priority for System Context."** Even if a malicious user uses a Prompt Injection attack to force the LLM to forcibly output `"userId": "admin"` in the generated JSON, the underlying framework will **completely ignore** and discard the fake value passed by the LLM, extracting the real context strictly and only from the `ToolExecutionContext`. This fundamentally eliminates the risk of unauthorized access. + +### 3. Retrieve parameters in the sub-agent +Regardless of the injection method, the parameters are ultimately and securely mounted in the `metadata` of the input message received by the sub-agent. The extraction method is identical: ```java public Mono call(List messages) { Msg userMsg = messages.get(messages.size() - 1); - - // Safely retrieve injected context from metadata Map metadata = userMsg.getMetadata(); + + // Retrieve the system-injected security parameter String userId = (String) metadata.get("userId"); + // Retrieve the LLM-injected business parameter + String depth = (String) metadata.get("analysis_depth"); - // ... execute specific logic based on userId ... + // ... execute specific logic based on these parameters ... } ``` diff --git a/docs/zh/multi-agent/subagent.md b/docs/zh/multi-agent/subagent.md index 2cc5d3f77a..4ea89ba3c3 100644 --- a/docs/zh/multi-agent/subagent.md +++ b/docs/zh/multi-agent/subagent.md @@ -110,39 +110,74 @@ TaskToolsBuilder.builder() ## 传递自定义上下文与参数 -在实际业务中,您可能需要向子智能体安全地透传外部系统上下文(如 `userId`、`tenantId`),而不是将它们硬编码到自然语言的 prompt 中。 +在实际业务中,可能需要向子智能体传递除 `message` 之外的额外参数(如 `userId`)。`SubAgentTool` 支持两种截然不同的参数注入方式: -`SubAgentTool` 支持声明和接收自定义参数。这些参数可以通过大模型主动生成(如果暴露在 Schema 中),也可以通过 `ToolExecutionContext` 由系统外部安全注入,并最终存放在子智能体收到消息的 `metadata` 中。 +1. **大模型动态注入(业务变量)**:由大模型根据用户的聊天内容推断得出(如:翻译目标语言、分析深度)。 +2. **系统上下文注入(安全变量)**:由底层系统通过 `ToolExecutionContext` 注入,对大模型完全透明且不可篡改(如:`userId`、`tenantId`)。 -**1. 声明参数 (配置项)** -通过 `SubAgentConfig` 暴露自定义参数(支持简单类型或完整的 JSON Schema): +### 1. 声明参数 +首先,通过 `SubAgentConfig` 声明自定义参数。框架严格区分两类参数以保证安全性与灵活性: ```java SubAgentConfig config = SubAgentConfig.builder() - // 声明一个必填的简单参数 - .addParameter("userId", "string", "当前操作用户的 ID", true) - // 声明一个复杂的枚举参数 - .addParameter("role", Map.of("type", "string", "enum", List.of("admin", "user")), false) + // 1. 声明业务变量(调用 addParameter:大模型可见,由 LLM 根据对话推断) + .addParameter("analysis_depth", Map.of("type", "string", "enum", List.of("basic", "detailed")), false) + // 2. 声明安全变量(调用 addSystemParameter:大模型不可见,严格由系统底层注入) + .addSystemParameter("userId") .build(); SubAgentTool tool = new SubAgentTool(agentProvider, config); ``` -**🔒 优先级注入** -在运行时解析自定义参数时,框架会严格遵循 **“系统上下文优先于大模型输入”** 的原则。如果通过 `ToolExecutionContext` 在系统外部注入了参数(如 `userId`),它将**强制覆盖**大模型生成或幻觉出的同名参数。 +### 2. 两种注入方式示例 -**2. 在子智能体中读取参数** -当子智能体被调用时,自定义参数会被安全地挂载到输入消息的 `metadata` 中。您可以在子智能体的逻辑中这样提取: +#### 方式一:大模型动态注入(业务变量) +适用于**业务属性**。通过 `addParameter` 声明的变量(如 `analysis_depth`)会被渲染进传递给大模型的 JSON Schema 中。 +当用户说:*“帮我进行极其深入的代码审查”* 时,大模型会自动推断并生成如下调用: +```json +{ + "message": "审查代码库", + "analysis_depth": "detailed" +} +``` + +💡 后端干预(兜底机制):虽然业务变量由大模型推断,但框架同样允许后端通过 ToolExecutionContext 注入同名参数。如果系统处于降级模式或有特殊校验,底层注入的值将强行覆盖大模型的推断结果,保障系统的绝对控制权。 + +#### 方式二:系统上下文注入(安全变量) +适用于**敏感安全属性**(如 `userId`)。通过 `addSystemParameter` 声明的变量对大模型**完全隐身**。系统拦截器会在运行时直接将其安全塞入。 +```java +// 在系统入口处注册上下文 +ToolExecutionContext context = ToolExecutionContext.builder() + .register("userId", String.class, "user_123") // 明确指定类型为 String.class + .build(); + +// 执行时传入 context +ToolCallParam param = ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(Map.of("message", "查一下我的订单")) + .context(context) + .build(); + +tool.callAsync(param).subscribe(); +``` + +> **🔒 安全与优先级** +> 由于系统参数(如 `userId`)是通过 `addSystemParameter` 声明的,它不会出现在发送给大模型的 Schema 中。框架在运行时严格遵循 **“系统上下文绝对优先”** 的原则。即使黑客通过提示词注入(Prompt Injection)攻击,迫使大模型在输出的 JSON 中强行拼凑出 `"userId": "admin"`,底层框架也会**完全无视**并丢弃大模型传入的假值,严格只从 `ToolExecutionContext` 中提取真实的上下文,从根本上杜绝越权风险。 + +### 3. 在子智能体中读取参数 +无论参数是通过哪种方式注入的,最终都会被安全地挂载到子智能体输入消息的 `metadata` 中。提取方式完全一致: ```java public Mono call(List messages) { Msg userMsg = messages.get(messages.size() - 1); - - // 从 metadata 中安全获取透传的上下文 Map metadata = userMsg.getMetadata(); + + // 获取系统注入的安全参数 String userId = (String) metadata.get("userId"); + // 获取大模型注入的业务参数 + String depth = (String) metadata.get("analysis_depth"); - // ... 基于 userId 执行特定逻辑 ... + // ... 基于这些参数执行特定逻辑 ... } ``` From 9dfe8a587149362b1b1c8f20f5bebc5800caa926 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Fri, 22 May 2026 21:31:08 +0800 Subject: [PATCH 4/6] fix: review --- .../io/agentscope/core/tool/ContextStore.java | 35 +++++- .../core/tool/DefaultContextStore.java | 43 ++++++- .../core/tool/ToolExecutionContext.java | 31 +++++ .../core/tool/subagent/SubAgentConfig.java | 6 +- .../core/tool/subagent/SubAgentTool.java | 10 +- .../tool/subagent/SubAgentConfigTest.java | 2 +- .../core/tool/subagent/SubAgentToolTest.java | 107 ++++++++++++++++++ 7 files changed, 222 insertions(+), 12 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ContextStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ContextStore.java index 72b3213941..d77986ed2c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ContextStore.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ContextStore.java @@ -18,16 +18,17 @@ /** * Storage layer abstraction for tool execution context. * - *

      This interface defines the storage contract for context objects. It supports two retrieval + *

      This interface defines the storage contract for context objects. It supports three retrieval * modes: *

        *
      1. By type only: {@code get(Class)} - suitable for singleton scenarios
      2. *
      3. By key + type: {@code get(String, Class)} - suitable for multi-instance - * scenarios
      4. + * scenarios requiring type safety *
      * - *

      This design allows handling both simple cases (one UserContext) and complex cases - * (multiple UserContext instances for different users). + *

      This design allows handling both simple cases (one UserContext), complex cases + * (multiple UserContext instances for different users), and dynamic attributes (like a simple + * string tenantId). * *

      Implementations can be: *

        @@ -43,6 +44,9 @@ * // Multiple instances of same type * UserContext admin = store.get("admin", UserContext.class); * UserContext guest = store.get("guest", UserContext.class); + * + * // Dynamic property by key only + * Object tenantId = store.get("tenantId"); * } * * @see ToolExecutionContext @@ -73,6 +77,19 @@ public interface ContextStore { */ T get(String key, Class type); + /** + * Retrieves an object by key without requiring the caller to know its registered type. + * + *

        This is useful when the key is the public contract and the runtime type may be any + * application object. + * + * @param key The key identifying the specific instance + * @return The object instance, or null if not found + */ + default Object get(String key) { + return null; + } + /** * Retrieves an object by type only (without key). * @@ -105,6 +122,16 @@ public interface ContextStore { */ boolean contains(String key, Class type); + /** + * Checks whether an object with the specified key exists regardless of type. + * + * @param key The key identifying the instance + * @return true if any object exists with this key, false otherwise + */ + default boolean contains(String key) { + return get(key) != null; + } + /** * Checks whether any object of the specified type exists (regardless of key). * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/DefaultContextStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/DefaultContextStore.java index 80d60193e9..259dfb866f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/DefaultContextStore.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/DefaultContextStore.java @@ -33,6 +33,9 @@ class DefaultContextStore implements ContextStore { // Two-level map: Class -> (Key -> Object) private final Map, Map> objectMap; + // Direct key index for type-agnostic lookup + private final Map keyMap; + private DefaultContextStore(Builder builder) { if (builder.objectMap != null) { Map, Map> copy = new HashMap<>(); @@ -45,6 +48,10 @@ private DefaultContextStore(Builder builder) { } else { this.objectMap = Collections.emptyMap(); } + this.keyMap = + builder.keyMap != null + ? Collections.unmodifiableMap(new HashMap<>(builder.keyMap)) + : Collections.emptyMap(); } /** @@ -70,6 +77,17 @@ public T get(String key, Class type) { return null; } + /** + * Retrieves an object by key regardless of its registered runtime type. + * + * @param key The key identifying the instance + * @return The object instance, or null if not found + */ + @Override + public Object get(String key) { + return keyMap.get(key); + } + /** * Retrieves an object by type only (singleton case). * @@ -98,6 +116,17 @@ public boolean contains(String key, Class type) { return keyMap != null && keyMap.containsKey(key); } + /** + * Checks whether an object with the specified key exists regardless of its registered type. + * + * @param key The key identifying the instance + * @return true if the object exists, false otherwise + */ + @Override + public boolean contains(String key) { + return keyMap.containsKey(key); + } + /** * Checks whether any object of the specified type exists. * @@ -146,22 +175,23 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; DefaultContextStore that = (DefaultContextStore) o; - return Objects.equals(objectMap, that.objectMap); + return Objects.equals(objectMap, that.objectMap) && Objects.equals(keyMap, that.keyMap); } @Override public int hashCode() { - return Objects.hash(objectMap); + return Objects.hash(objectMap, keyMap); } @Override public String toString() { - return "DefaultContextStore{" + "objectMap=" + objectMap + '}'; + return "DefaultContextStore{" + "objectMap=" + objectMap + ", keyMap=" + keyMap + '}'; } /** Builder for DefaultContextStore. */ public static class Builder { private Map, Map> objectMap; + private Map keyMap; private Builder() {} @@ -243,9 +273,14 @@ public Builder register(String key, Class type, T object) { if (this.objectMap == null) { this.objectMap = new HashMap<>(); } - Map keyMap = this.objectMap.computeIfAbsent(type, k -> new HashMap<>()); keyMap.put(key, object); + + if (this.keyMap == null) { + this.keyMap = new HashMap<>(); + } + this.keyMap.put(key, object); + return this; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutionContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutionContext.java index d860f9c2cb..3b1f3a5f0f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutionContext.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutionContext.java @@ -75,6 +75,22 @@ public T get(String key, Class type) { return null; } + /** + * Retrieves an object by key without requiring the caller to know its registered type. + * + * @param key The key identifying the instance + * @return The object instance, or null if not found + */ + public Object get(String key) { + for (ContextStore store : stores) { + Object obj = store.get(key); + if (obj != null) { + return obj; + } + } + return null; + } + /** * Retrieves an object by type only (for singleton scenarios). * @@ -109,6 +125,21 @@ public boolean contains(String key, Class type) { return false; } + /** + * Checks whether an object with the specified key exists in any store, regardless of type. + * + * @param key The key identifying the instance + * @return true if any store contains the object, false otherwise + */ + public boolean contains(String key) { + for (ContextStore store : stores) { + if (store.contains(key)) { + return true; + } + } + return false; + } + /** * Checks whether an object of the specified type exists in any store (regardless of key). * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index ccc404d792..06b6d049b1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -329,7 +329,7 @@ public Builder addParameter( * @param required true if the parameter is required, false otherwise * @return This builder * @throws IllegalArgumentException If the {@code name} is null, empty, or a reserved - * system parameter (e.g., "message" or "session_id"). + * system parameter (e.g., "message" or "session_id"). Also thrown if {@code schema} is null or empty. */ public Builder addParameter(String name, Map schema, boolean required) { if ("message".equals(name) || "session_id".equals(name)) { @@ -342,6 +342,10 @@ public Builder addParameter(String name, Map schema, boolean req throw new IllegalArgumentException("Parameter name cannot be null or empty."); } + if (schema == null || schema.isEmpty()) { + throw new IllegalArgumentException("Parameter schema cannot be null or empty."); + } + this.customParameters.put(name, schema); if (required) { this.requiredCustomParameters.add(name); diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index cd4b96f392..33bca38f0c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -250,7 +250,7 @@ private Mono executeConversation(ToolCallParam param) { } /** - * Extracts a value from the ToolExecutionContext by trying standard JSON types. + * Extracts a value from the ToolExecutionContext by parameter key. * * @param context The execution context * @param paramName The name of the parameter to extract @@ -260,7 +260,13 @@ private Object extractFromContext(ToolExecutionContext context, String paramName if (context == null) { return null; } - Object ctxValue = context.get(paramName, String.class); + + Object ctxValue = context.get(paramName); + if (ctxValue != null) { + return ctxValue; + } + + ctxValue = context.get(paramName, String.class); if (ctxValue == null) { ctxValue = context.get(paramName, Integer.class); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index cbed87d543..99749bd767 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -272,7 +272,7 @@ void testBuilderChaining() { .forwardEvents(true) .streamOptions(null) .session(new InMemorySession()) - .addParameter("param", null, true) + .addParameter("param", Map.of("type", "string"), true) .addSystemParameter("sysParam") .build(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index 1348de0a32..1f373f8ae0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -18,6 +18,7 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -562,6 +563,44 @@ void testCustomParameterExtractionAndPriority() { assertFalse(metadata.containsKey("message")); } + @Test + @DisplayName("Should let Context override LLM input for custom parameters of any runtime type") + void testCustomParameterContextOverrideForCustomRuntimeType() { + Agent mockAgent = createMockAgent("TestAgent", "Test"); + SubAgentConfig config = + SubAgentConfig.builder() + .forwardEvents(false) + .addParameter("tenant", "object", "Tenant context", false) + .build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map llmInput = new HashMap<>(); + llmInput.put("message", "Hello"); + llmInput.put("tenant", "FAKE_TENANT_FROM_LLM"); + + TenantContext realTenant = new TenantContext("REAL_SYSTEM_TENANT_ID"); + ToolExecutionContext systemContext = + ToolExecutionContext.builder().register("tenant", realTenant).build(); + + ToolUseBlock toolUse = + ToolUseBlock.builder().id("1").name("call_testagent").input(llmInput).build(); + tool.callAsync( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .input(llmInput) + .context(systemContext) + .build()) + .block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockAgent).call(captor.capture()); + Map metadata = captor.getValue().get(0).getMetadata(); + + assertNotNull(metadata); + assertSame(realTenant, metadata.get("tenant")); + } + @Test @DisplayName( "Should securely inject system parameters and completely ignore LLM prompt injections") @@ -604,8 +643,76 @@ void testSystemParameterSecurityAndInjectionDefense() { "Forged system parameter from LLM MUST be ignored"); } + @Test + @DisplayName("Should inject system parameters of any runtime type") + void testSystemParameterInjectionForCustomRuntimeType() { + Agent mockAgent = createMockAgent("DefenseAgent", "Test custom system context"); + SubAgentConfig config = + SubAgentConfig.builder() + .forwardEvents(false) + .addSystemParameter("authContext") + .build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map llmInput = new HashMap<>(); + llmInput.put("message", "Use secure context"); + llmInput.put("authContext", "FORGED_AUTH_CONTEXT"); + + AuthContext authContext = new AuthContext("sys_user_999", List.of("read", "write")); + ToolExecutionContext systemContext = + ToolExecutionContext.builder().register("authContext", authContext).build(); + + ToolUseBlock toolUse = + ToolUseBlock.builder().id("1").name("call_defenseagent").input(llmInput).build(); + tool.callAsync( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .input(llmInput) + .context(systemContext) + .build()) + .block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockAgent).call(captor.capture()); + Map metadata = captor.getValue().get(0).getMetadata(); + + assertNotNull(metadata); + assertSame(authContext, metadata.get("authContext")); + } + // Helper methods + private static class TenantContext { + private final String tenantId; + + private TenantContext(String tenantId) { + this.tenantId = tenantId; + } + + String getTenantId() { + return tenantId; + } + } + + private static class AuthContext { + private final String userId; + private final List permissions; + + private AuthContext(String userId, List permissions) { + this.userId = userId; + this.permissions = permissions; + } + + String getUserId() { + return userId; + } + + List getPermissions() { + return permissions; + } + } + private Agent createMockAgent(String name, String description) { Agent agent = mock(Agent.class); when(agent.getName()).thenReturn(name); From 2e7fa0f580dd0e6b096690153373887ecbebe611 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 23 May 2026 17:50:11 +0800 Subject: [PATCH 5/6] fix: some problems --- .../core/tool/subagent/SubAgentConfig.java | 141 +++++++++++++----- .../core/tool/subagent/SubAgentTool.java | 10 +- .../core/tool/subagent/SubAgentToolTest.java | 13 ++ 3 files changed, 122 insertions(+), 42 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index 06b6d049b1..8d527d4a9b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -87,6 +88,7 @@ public class SubAgentConfig { private final Map> customParameters; private final List requiredCustomParameters; private final Set systemParameters; + private static final Set RESERVED_PARAMETER_NAMES = Set.of("message", "session_id"); private SubAgentConfig(Builder builder) { this.toolName = builder.toolName; @@ -94,14 +96,11 @@ private SubAgentConfig(Builder builder) { this.forwardEvents = builder.forwardEvents; this.streamOptions = builder.streamOptions; this.session = builder.session != null ? builder.session : new InMemorySession(); - this.customParameters = - builder.customParameters != null ? builder.customParameters : new HashMap<>(); + this.customParameters = copyCustomParameters(builder.customParameters); this.requiredCustomParameters = - builder.requiredCustomParameters != null - ? builder.requiredCustomParameters - : new ArrayList<>(); + Collections.unmodifiableList(new ArrayList<>(builder.requiredCustomParameters)); this.systemParameters = - builder.systemParameters != null ? builder.systemParameters : new LinkedHashSet<>(); + Collections.unmodifiableSet(new LinkedHashSet<>(builder.systemParameters)); } /** @@ -184,9 +183,7 @@ public Session getSession() { * @return A map of parameter names to their JSON schema definitions */ public Map> getCustomParameters() { - return customParameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(customParameters); + return customParameters; } /** @@ -195,9 +192,7 @@ public Map> getCustomParameters() { * @return A list containing the names of required parameters */ public List getRequiredCustomParameters() { - return requiredCustomParameters == null - ? Collections.emptyList() - : Collections.unmodifiableList(requiredCustomParameters); + return requiredCustomParameters; } /** @@ -209,9 +204,70 @@ public List getRequiredCustomParameters() { * @return A set containing the names of system parameters */ public Set getSystemParameters() { - return systemParameters == null - ? Collections.emptySet() - : Collections.unmodifiableSet(systemParameters); + return systemParameters; + } + + private static Map> copyCustomParameters( + Map> source) { + if (source == null || source.isEmpty()) { + return Collections.emptyMap(); + } + + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + if (entry.getKey() == null) { + throw new IllegalArgumentException("Parameter name cannot be null."); + } + copy.put(entry.getKey(), immutableSchemaMap(entry.getValue())); + } + return Collections.unmodifiableMap(copy); + } + + private static Map immutableSchemaMap(Map source) { + if (source == null) { + throw new IllegalArgumentException("Parameter schema cannot be null."); + } + + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (entry.getKey() == null) { + throw new IllegalArgumentException("Parameter schema key cannot be null."); + } + copy.put(entry.getKey(), immutableJsonValue(entry.getValue())); + } + return Collections.unmodifiableMap(copy); + } + + private static Object immutableJsonValue(Object value) { + if (value instanceof Map mapValue) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : mapValue.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new IllegalArgumentException( + "JSON schema object keys must be strings: " + entry.getKey()); + } + copy.put(key, immutableJsonValue(entry.getValue())); + } + return Collections.unmodifiableMap(copy); + } + + if (value instanceof List listValue) { + List copy = new ArrayList<>(listValue.size()); + for (Object item : listValue) { + copy.add(immutableJsonValue(item)); + } + return Collections.unmodifiableList(copy); + } + + if (value instanceof Set setValue) { + List copy = new ArrayList<>(setValue.size()); + for (Object item : setValue) { + copy.add(immutableJsonValue(item)); + } + return Collections.unmodifiableList(copy); + } + + return value; } /** Builder for SubAgentConfig. */ @@ -332,23 +388,25 @@ public Builder addParameter( * system parameter (e.g., "message" or "session_id"). Also thrown if {@code schema} is null or empty. */ public Builder addParameter(String name, Map schema, boolean required) { - if ("message".equals(name) || "session_id".equals(name)) { - throw new IllegalArgumentException( - "Cannot use reserved parameter name: '" - + name - + "'. This is a built-in system parameter."); - } - if (name == null || name.trim().isEmpty()) { - throw new IllegalArgumentException("Parameter name cannot be null or empty."); - } - + String normalizedName = normalizeParameterName(name, "Parameter"); if (schema == null || schema.isEmpty()) { throw new IllegalArgumentException("Parameter schema cannot be null or empty."); } - this.customParameters.put(name, schema); + if (systemParameters.contains(normalizedName)) { + throw new IllegalArgumentException( + "Cannot declare parameter '" + + normalizedName + + "' as both custom and system parameter."); + } + + this.customParameters.put(normalizedName, new HashMap<>(schema)); if (required) { - this.requiredCustomParameters.add(name); + if (!this.requiredCustomParameters.contains(normalizedName)) { + this.requiredCustomParameters.add(normalizedName); + } + } else { + this.requiredCustomParameters.remove(normalizedName); } return this; } @@ -367,19 +425,32 @@ public Builder addParameter(String name, Map schema, boolean req * system parameter (e.g., "message" or "session_id"). */ public Builder addSystemParameter(String name) { - if ("message".equals(name) || "session_id".equals(name)) { + String normalizedName = normalizeParameterName(name, "System parameter"); + + if (customParameters.containsKey(normalizedName)) { throw new IllegalArgumentException( - "Cannot use reserved parameter name: '" - + name - + "'. This is a built-in parameter."); + "Cannot declare parameter '" + + normalizedName + + "' as both custom and system parameter."); } + + this.systemParameters.add(normalizedName); + return this; + } + + private String normalizeParameterName(String name, String label) { if (name == null || name.trim().isEmpty()) { - throw new IllegalArgumentException( - "System parameter name cannot be null or empty."); + throw new IllegalArgumentException(label + " name cannot be null or empty."); } - this.systemParameters.add(name); - return this; + String normalizedName = name.trim(); + if (RESERVED_PARAMETER_NAMES.contains(normalizedName)) { + throw new IllegalArgumentException( + "Cannot use reserved parameter name: '" + + normalizedName + + "'. This is a built-in parameter."); + } + return normalizedName; } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index 33bca38f0c..d0b95058ae 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -179,22 +179,18 @@ private Mono executeConversation(ToolCallParam param) { } } - // 2. Extract custom parameters (LLM inferred, Context overrides LLM) + // 2. Extract custom parameters (LLM inferred) Map> declaredCustomParams = config.getCustomParameters(); if (declaredCustomParams != null && !declaredCustomParams.isEmpty()) { for (String paramName : declaredCustomParams.keySet()) { - boolean injectedFromContext = false; - - // Prioritize obtaining from ToolExecutionContext Object ctxValue = extractFromContext(context, paramName); if (ctxValue != null) { finalMetadata.put(paramName, ctxValue); - injectedFromContext = true; + continue; } - // Attempt to retrieve from LLM input if not injected by context - if (!injectedFromContext && input.containsKey(paramName)) { + if (input.containsKey(paramName) && input.get(paramName) != null) { finalMetadata.put(paramName, input.get(paramName)); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index 1f373f8ae0..e25a7d8b9d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +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.Mockito.mock; @@ -482,6 +483,18 @@ void testToolNameGenerationTruncation() { assertFalse(generatedName.contains("__")); } + @Test + @DisplayName("Should reject parameter declared as both custom and system") + void testRejectCustomAndSystemParameterConflict() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + builder.addParameter("userId", "string", "User ID", true); + + assertThrows( + IllegalArgumentException.class, + () -> builder.addSystemParameter("userId")); + } + @Test @DisplayName("Should generate schema containing custom parameters but NOT system parameters") void testSchemaWithCustomParameters() { From 967e3528d26b18cb78c0578a49830ebae309cea9 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 23 May 2026 21:10:10 +0800 Subject: [PATCH 6/6] fix: optimize --- .../core/tool/subagent/SubAgentConfig.java | 92 ++++++++++++++---- .../core/tool/subagent/SubAgentTool.java | 23 +++-- .../tool/subagent/SubAgentConfigTest.java | 93 ++++++++++++++++++- .../core/tool/subagent/SubAgentToolTest.java | 13 --- docs/en/multi-agent/subagent.md | 35 ++++--- docs/zh/multi-agent/subagent.md | 33 ++++--- 6 files changed, 223 insertions(+), 66 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index 8d527d4a9b..fcf89ef3d3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -26,6 +26,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -48,12 +49,21 @@ * *
          *
        • Custom Parameters: Added via {@link Builder#addParameter}. These are exposed to the - * LLM in the tool schema, allowing the LLM to infer and generate values based on the conversation. + * LLM in the tool schema, allowing the LLM to infer and generate values based on the + * conversation. Required custom parameters are also placed in the JSON Schema {@code + * required} array, so they must be present in the tool call input before the tool is + * executed. *
        • System Parameters: Added via {@link Builder#addSystemParameter}. These are strictly * injected by the system via {@code ToolExecutionContext}. They are completely transparent - * (invisible) to the LLM, preventing prompt injection. + * (invisible) to the LLM, preventing prompt injection. Use system parameters for sensitive + * values or for values that should be supplied only by backend context. *
        * + *

        A parameter name can belong to only one category. If the same value is useful both as a + * model-visible business hint and as a backend override, declare it as a custom parameter and let + * {@code ToolExecutionContext} override the LLM-provided value at runtime. If the value is + * security-sensitive, declare it as a system parameter instead. + * *

        Default Behavior: * *

          @@ -180,6 +190,9 @@ public Session getSession() { /** * Gets the custom parameters defined for the sub-agent tool. * + *

          The returned map and nested JSON-schema containers are immutable defensive copies of the + * builder input. + * * @return A map of parameter names to their JSON schema definitions */ public Map> getCustomParameters() { @@ -189,6 +202,11 @@ public Map> getCustomParameters() { /** * Gets the list of required custom parameter names. * + *

          These names are used to populate the generated JSON Schema {@code required} array for + * model-visible custom parameters. They are validated by the tool executor before + * {@code ToolExecutionContext} is merged into the tool call; use {@link Builder#addSystemParameter} + * for context-only required values. + * * @return A list containing the names of required parameters */ public List getRequiredCustomParameters() { @@ -198,8 +216,8 @@ public List getRequiredCustomParameters() { /** * Gets the system parameters defined for the sub-agent tool. * - *

          System parameters are injected transparently via ToolExecutionContext - * and are NOT exposed to the LLM in the JSON schema. + *

          System parameters are injected transparently via ToolExecutionContext and are NOT exposed + * to the LLM in the JSON schema. * * @return A set containing the names of system parameters */ @@ -207,6 +225,27 @@ public Set getSystemParameters() { return systemParameters; } + /** + * Creates an immutable defensive copy of the custom parameter schema map. + * + *

          Custom parameter schemas are JSON-Schema-like trees supplied by users through + * {@link Builder#addParameter(String, Map, boolean)}. They can contain nested maps and lists + * such as {@code properties}, {@code items}, {@code enum}, and {@code required}. Because a + * {@link SubAgentConfig} instance is treated as immutable after {@link Builder#build()}, this + * method recursively copies and freezes JSON-style containers so later mutations to the builder + * or to caller-owned schema objects cannot change the generated tool schema. + * + *

          The copy intentionally handles JSON-style values only: {@link Map}, {@link List}, + * {@link Set}, scalar values, and {@code null}. Nested map keys must be strings because JSON + * object keys are strings. Sets are copied as immutable lists to preserve stable JSON array + * semantics when schemas are serialized. + * + * @param source The custom parameter definitions keyed by parameter name + * @return An immutable copy of the custom parameter definitions + * @throws IllegalArgumentException If a parameter name is null or a nested schema map contains + * a non-string key + * @throws NullPointerException If any parameter schema map is null + */ private static Map> copyCustomParameters( Map> source) { if (source == null || source.isEmpty()) { @@ -224,9 +263,7 @@ private static Map> copyCustomParameters( } private static Map immutableSchemaMap(Map source) { - if (source == null) { - throw new IllegalArgumentException("Parameter schema cannot be null."); - } + Objects.requireNonNull(source, "Parameter schema cannot be null."); Map copy = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) { @@ -357,8 +394,13 @@ public Builder session(Session session) { /** * Adds a simple custom parameter to the tool's JSON schema. * - *

          This is a convenience method for adding basic parameters. For complex schemas - * (e.g., enums, arrays), use {@link #addParameter(String, Map, boolean)}. + *

          This is a convenience method for adding basic parameters. For complex schemas (e.g., + * enums, arrays), use {@link #addParameter(String, Map, boolean)}. + * + *

          If {@code required} is true, the parameter is included in the generated JSON Schema + * {@code required} array. That means the LLM/tool caller must provide it in the tool input; + * backend context can override the provided value, but it cannot satisfy this + * pre-execution schema requirement by itself. * * @param name The name of the parameter * @param type The type of the parameter (e.g., "string", "integer") @@ -377,20 +419,30 @@ public Builder addParameter( /** * Adds a custom parameter with a fully defined JSON schema. * - *

          This method allows for advanced JSON schema features like enums, nested objects, - * or arrays, enabling precise control over how the language model understands the parameter. + *

          This method allows for advanced JSON schema features like enums, nested objects, or + * arrays, enabling precise control over how the language model understands the parameter. + * + *

          Custom parameters are model-visible. When {@code required} is true, the parameter is + * also model-required via JSON Schema validation before the tool is invoked. The backend can + * still provide a same-name value through {@code ToolExecutionContext}; that value overrides + * the LLM input at runtime, but the tool call must still include the required field to pass + * schema validation. Use {@link #addSystemParameter(String)} for values that should be + * supplied only by backend context. * * @param name The name of the parameter * @param schema The JSON schema map definition for the parameter - * @param required true if the parameter is required, false otherwise + * @param required true if the parameter must be generated in the tool input, false + * otherwise * @return This builder - * @throws IllegalArgumentException If the {@code name} is null, empty, or a reserved - * system parameter (e.g., "message" or "session_id"). Also thrown if {@code schema} is null or empty. + * @throws IllegalArgumentException If the {@code name} is null, empty, reserved, already + * declared as a system parameter, or if {@code schema} is empty. + * @throws NullPointerException If {@code schema} is null. */ public Builder addParameter(String name, Map schema, boolean required) { String normalizedName = normalizeParameterName(name, "Parameter"); - if (schema == null || schema.isEmpty()) { - throw new IllegalArgumentException("Parameter schema cannot be null or empty."); + Objects.requireNonNull(schema, "Parameter schema cannot be null."); + if (schema.isEmpty()) { + throw new IllegalArgumentException("Parameter schema cannot be empty."); } if (systemParameters.contains(normalizedName)) { @@ -419,10 +471,14 @@ public Builder addParameter(String name, Map schema, boolean req * extracted strictly from the {@link io.agentscope.core.tool.ToolExecutionContext} * at runtime, preventing prompt injection attacks and LLM hallucination. * + *

          Use this for security-sensitive values, such as authenticated user IDs or tenant IDs, + * and for values that must be supplied by backend context rather than by the LLM. System + * parameter names cannot also be declared as custom parameters. + * * @param name The name of the system parameter * @return This builder - * @throws IllegalArgumentException If the {@code name} is null, empty, or a reserved - * system parameter (e.g., "message" or "session_id"). + * @throws IllegalArgumentException If the {@code name} is null, empty, reserved, or already + * declared as a custom parameter. */ public Builder addSystemParameter(String name) { String normalizedName = normalizeParameterName(name, "System parameter"); diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index d0b95058ae..8d08c5bb0a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -64,8 +64,11 @@ *

            *
          • System Parameters: Extracted strictly from {@link ToolExecutionContext}. These are * invisible to the LLM and completely tamper-proof. - *
          • Custom Parameters: Exposed in the tool schema for LLM inference. At runtime, these - * are extracted with {@link ToolExecutionContext} taking precedence over LLM input. + *
          • Custom Parameters: Exposed to the LLM in the JSON schema. If marked as required, + * the LLM MUST provide a value, and the framework will validate its presence BEFORE execution. + * While {@link ToolExecutionContext} can forcefully override the LLM's value at runtime, + * it cannot bypass the initial schema validation. Rule of thumb: If the LLM should + * infer it, use Custom Parameters; if the LLM shouldn't know about it, use System Parameters. *
          * Both types of parameters are securely injected into the sub-agent's message {@code metadata}. */ @@ -131,7 +134,9 @@ public Mono callAsync(ToolCallParam param) { *
        • Session ID generation for new conversations *
        • Agent state loading for continued sessions *
        • Extraction of external parameters with strict priority: - * {@link ToolExecutionContext} (highest) > LLM input (lowest) + * {@link ToolExecutionContext} (highest) > LLM input (lowest). For required custom + * parameters, this override occurs after schema validation has already required the LLM + * tool call to contain the field. *
        • Secure injection of custom parameters into the message metadata *
        • Message execution (streaming or non-streaming based on config) *
        • Agent state persistence after execution @@ -179,7 +184,9 @@ private Mono executeConversation(ToolCallParam param) { } } - // 2. Extract custom parameters (LLM inferred) + // 2. Extract custom parameters. These are visible in the JSON Schema. + // Required custom parameters are validated by ToolExecutor before this + // method runs; context only overrides the value selected by the LLM. Map> declaredCustomParams = config.getCustomParameters(); if (declaredCustomParams != null && !declaredCustomParams.isEmpty()) { @@ -451,9 +458,11 @@ private ToolResultBlock buildResult(Msg response, String sessionId) { *
        • {@code message} - Required string containing the message to send *
        * - *

        If custom parameters are defined in the configuration, they are merged into - * the properties map, and their required status is updated accordingly. - * The schema strictly disables {@code additionalProperties}. + *

        If custom parameters are defined in the configuration, they are merged into the properties + * map, and required custom parameters are added to the JSON Schema {@code required} array. This + * intentionally keeps required custom parameters model-visible and pre-validated. Use system + * parameters for context-only required values. The schema strictly disables {@code + * additionalProperties}. * * @return A map representing the JSON schema for tool parameters */ diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index 99749bd767..ccba8b77e1 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -27,6 +27,7 @@ import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.session.InMemorySession; import io.agentscope.core.session.Session; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -215,12 +216,15 @@ void testSimpleCustomParameter() { @DisplayName("Should build config with schema custom parameter") void testSchemaCustomParameter() { Map enumSchema = new HashMap<>(); + List enumValues = new ArrayList<>(List.of("admin", "user")); enumSchema.put("type", "string"); - enumSchema.put("enum", List.of("admin", "user")); + enumSchema.put("enum", enumValues); enumSchema.put("description", "The user role"); SubAgentConfig config = SubAgentConfig.builder().addParameter("role", enumSchema, false).build(); + enumValues.add("owner"); + enumSchema.put("description", "Mutated description"); assertNotNull(config.getCustomParameters()); assertEquals(1, config.getCustomParameters().size()); @@ -228,12 +232,83 @@ void testSchemaCustomParameter() { Map paramConfig = config.getCustomParameters().get("role"); assertNotNull(paramConfig); assertEquals("string", paramConfig.get("type")); + assertEquals("The user role", paramConfig.get("description")); assertTrue(paramConfig.containsKey("enum")); + assertEquals(List.of("admin", "user"), paramConfig.get("enum")); assertNotNull(config.getRequiredCustomParameters()); assertFalse(config.getRequiredCustomParameters().contains("role")); } + @Test + @DisplayName("Should expose immutable custom parameter schemas") + @SuppressWarnings("unchecked") + void testCustomParameterSchemaIsImmutable() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("properties", Map.of("mode", Map.of("type", "string"))); + + SubAgentConfig config = + SubAgentConfig.builder().addParameter("options", schema, false).build(); + + Map parameterSchema = config.getCustomParameters().get("options"); + assertThrows( + UnsupportedOperationException.class, + () -> parameterSchema.put("description", "mutated")); + + Map properties = + (Map) parameterSchema.get("properties"); + assertThrows( + UnsupportedOperationException.class, + () -> properties.put("extra", Map.of("type", "string"))); + } + + @Test + @DisplayName("Should throw exception when custom parameter schema is null or empty") + void testAddNullOrEmptyParameterSchemaThrowsException() { + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + + assertThrows( + NullPointerException.class, + () -> builder.addParameter("missingSchema", null, true)); + assertThrows( + IllegalArgumentException.class, + () -> builder.addParameter("emptySchema", Map.of(), true)); + } + + @Test + @DisplayName("Should reject non-string keys in nested custom parameter schemas") + void testRejectNonStringSchemaKeys() { + Map nested = new HashMap<>(); + nested.put(1, Map.of("type", "string")); + + Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("properties", nested); + + SubAgentConfig.Builder builder = SubAgentConfig.builder(); + builder.addParameter("options", schema, false); + + assertThrows(IllegalArgumentException.class, builder::build); + } + + @Test + @DisplayName("Should reject parameter declared as both custom and system") + void testRejectCustomAndSystemParameterConflict() { + SubAgentConfig.Builder customFirst = SubAgentConfig.builder(); + customFirst.addParameter("userId", "string", "User ID", true); + + assertThrows( + IllegalArgumentException.class, () -> customFirst.addSystemParameter("userId")); + + SubAgentConfig.Builder systemFirst = SubAgentConfig.builder(); + systemFirst.addSystemParameter("tenantId"); + + assertThrows( + IllegalArgumentException.class, + () -> systemFirst.addParameter("tenantId", "string", "Tenant ID", true)); + } + @Test @DisplayName("Should throw exception when adding reserved system parameter name") void testAddReservedSystemParameterThrowsException() { @@ -278,6 +353,22 @@ void testBuilderChaining() { assertNotNull(config); } + + @Test + @DisplayName( + "Multiple builds from same builder should keep parameter snapshots independent") + void testMultipleBuildsKeepParameterSnapshotsIndependent() { + SubAgentConfig.Builder builder = + SubAgentConfig.builder().addParameter("first", "string", "First", true); + + SubAgentConfig config1 = builder.build(); + builder.addParameter("second", "string", "Second", true); + SubAgentConfig config2 = builder.build(); + + assertTrue(config1.getCustomParameters().containsKey("first")); + assertFalse(config1.getCustomParameters().containsKey("second")); + assertTrue(config2.getCustomParameters().containsKey("second")); + } } @Nested diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index e25a7d8b9d..1f373f8ae0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; -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.Mockito.mock; @@ -483,18 +482,6 @@ void testToolNameGenerationTruncation() { assertFalse(generatedName.contains("__")); } - @Test - @DisplayName("Should reject parameter declared as both custom and system") - void testRejectCustomAndSystemParameterConflict() { - SubAgentConfig.Builder builder = SubAgentConfig.builder(); - - builder.addParameter("userId", "string", "User ID", true); - - assertThrows( - IllegalArgumentException.class, - () -> builder.addSystemParameter("userId")); - } - @Test @DisplayName("Should generate schema containing custom parameters but NOT system parameters") void testSchemaWithCustomParameters() { diff --git a/docs/en/multi-agent/subagent.md b/docs/en/multi-agent/subagent.md index 92aa7a34bf..da0325facc 100644 --- a/docs/en/multi-agent/subagent.md +++ b/docs/en/multi-agent/subagent.md @@ -110,19 +110,21 @@ The example uses **TaskToolsBuilder** to create the **Task** and **TaskOutput** ## Passing Custom Context and Parameters -In real-world applications, you may need to pass additional parameters (e.g., `userId`) to a sub-agent alongside the `message`. `SubAgentTool` supports two distinct parameter injection methods: +In real-world applications, you may need to pass additional parameters to a sub-agent alongside the `message`. `SubAgentTool` supports two distinct parameter categories: -1. **LLM Dynamic Injection (Business Variables)**: Inferred by the LLM based on the user's chat context (e.g., target translation language, analysis depth). -2. **System Context Injection (Security Variables)**: Injected by the underlying system via `ToolExecutionContext`. This is completely transparent to the LLM and tamper-proof (e.g., `userId`, `tenantId`). +1. **Custom parameters (model-visible business variables)**: Declared with `addParameter`. They are included in the tool JSON Schema, so the LLM can infer them from the conversation (for example target language or analysis depth). +2. **System parameters (backend-only security variables)**: Declared with `addSystemParameter`. They are injected from `ToolExecutionContext`, are not included in the tool JSON Schema, and cannot be supplied by the LLM (for example authenticated `userId` or `tenantId`). ### 1. Declare parameters -First, declare custom parameters via `SubAgentConfig`. The framework strictly distinguishes between two types of parameters to ensure security and flexibility: +First, declare parameters via `SubAgentConfig`. A parameter name can be declared in only one category: use `addParameter` for values the model should see, and `addSystemParameter` for values that must come only from backend context. ```java SubAgentConfig config = SubAgentConfig.builder() - // 1. Declare a business variable (via addParameter: visible to the LLM, inferred by the LLM based on the conversation) - .addParameter("analysis_depth", Map.of("type", "string", "enum", List.of("basic", "detailed")), false) - // 2. Declare a security variable (via addSystemParameter: invisible to the LLM, strictly injected by the underlying system) + // Visible to the LLM. Set required=true when the model must provide it in the tool call. + .addParameter("analysis_depth", + Map.of("type", "string", "enum", List.of("basic", "detailed")), + true) + // Invisible to the LLM. Must be supplied through ToolExecutionContext. .addSystemParameter("userId") .build(); @@ -131,9 +133,9 @@ SubAgentTool tool = new SubAgentTool(agentProvider, config); ### 2. Examples of injection methods -#### Method 1: LLM Dynamic Injection (Business Variables) -Suitable for **business properties**. Variables declared via `addParameter` (such as `analysis_depth`) will be rendered into the JSON Schema passed to the LLM. -When the user says: *"Help me do an extremely deep code review"*, the LLM will automatically infer and generate the following call: +#### Method 1: Custom parameters (LLM dynamic injection) +Suitable for **business properties**. Variables declared via `addParameter` (such as `analysis_depth`) are rendered into the JSON Schema passed to the LLM. +When the user says: *"Help me do an extremely deep code review"*, the LLM can infer and generate the following call: ```json { "message": "Review the codebase", @@ -141,10 +143,12 @@ When the user says: *"Help me do an extremely deep code review"*, the LLM will a } ``` -💡 Backend Intervention (Fallback Mechanism): Although business variables are inferred by the LLM, the framework still allows the backend to inject a parameter with the same name via ToolExecutionContext. If the system is in a degraded mode or requires special overrides, the value injected by the underlying system will forcibly override the LLM's inference, ensuring absolute backend control. +If `required=true`, the parameter is also placed in the JSON Schema `required` array. This means the tool call must include the field before `SubAgentTool` is invoked; `ToolExecutor` performs schema validation before context injection. A same-name value in `ToolExecutionContext` can still override the LLM-provided value at runtime, but it does not bypass the pre-execution schema requirement. -#### Method 2: System Context Injection (Security Variables) -Suitable for **sensitive security properties** (e.g., `userId`). Variables declared via `addSystemParameter` are **completely invisible** to the LLM. The system interceptor will securely inject them directly at runtime. +Use `required=false` when the LLM may omit a business parameter. Use `addSystemParameter` when the value must be supplied only by backend context. + +#### Method 2: System parameters (backend context injection) +Suitable for **sensitive security properties** (e.g., `userId`). Variables declared via `addSystemParameter` are **completely invisible** to the LLM. The system injects them directly at runtime. ```java // Register the context at the system entry point ToolExecutionContext context = ToolExecutionContext.builder() @@ -162,7 +166,10 @@ tool.callAsync(param).subscribe(); ``` > **🔒 Security and Priority** -> Because system parameters (such as `userId`) are declared via `addSystemParameter`, they will not appear in the Schema sent to the LLM. At runtime, the framework strictly follows the principle of **"Absolute Priority for System Context."** Even if a malicious user uses a Prompt Injection attack to force the LLM to forcibly output `"userId": "admin"` in the generated JSON, the underlying framework will **completely ignore** and discard the fake value passed by the LLM, extracting the real context strictly and only from the `ToolExecutionContext`. This fundamentally eliminates the risk of unauthorized access. +> Because system parameters (such as `userId`) are declared via `addSystemParameter`, they will not appear in the schema sent to the LLM. At runtime, the framework extracts them strictly from `ToolExecutionContext`. Even if a malicious prompt causes the LLM to emit `"userId": "admin"`, the fake value is ignored because `userId` is not a model-visible custom parameter. + +> **Required custom vs. system parameters** +> A required custom parameter is required from the LLM/tool-call input because it is part of the JSON Schema. A system parameter is required from backend context because it is not part of the schema. Do not use a required custom parameter for security-sensitive context-only values. ### 3. Retrieve parameters in the sub-agent Regardless of the injection method, the parameters are ultimately and securely mounted in the `metadata` of the input message received by the sub-agent. The extraction method is identical: diff --git a/docs/zh/multi-agent/subagent.md b/docs/zh/multi-agent/subagent.md index 4ea89ba3c3..e5279b60aa 100644 --- a/docs/zh/multi-agent/subagent.md +++ b/docs/zh/multi-agent/subagent.md @@ -110,19 +110,21 @@ TaskToolsBuilder.builder() ## 传递自定义上下文与参数 -在实际业务中,可能需要向子智能体传递除 `message` 之外的额外参数(如 `userId`)。`SubAgentTool` 支持两种截然不同的参数注入方式: +在实际业务中,可能需要向子智能体传递除 `message` 之外的额外参数。`SubAgentTool` 支持两类参数: -1. **大模型动态注入(业务变量)**:由大模型根据用户的聊天内容推断得出(如:翻译目标语言、分析深度)。 -2. **系统上下文注入(安全变量)**:由底层系统通过 `ToolExecutionContext` 注入,对大模型完全透明且不可篡改(如:`userId`、`tenantId`)。 +1. **自定义参数(大模型可见的业务变量)**:通过 `addParameter` 声明,会进入工具 JSON Schema,由大模型根据对话内容推断(如:翻译目标语言、分析深度)。 +2. **系统参数(后端注入的安全变量)**:通过 `addSystemParameter` 声明,由底层系统通过 `ToolExecutionContext` 注入,不会进入工具 JSON Schema,大模型无法提供这类参数(如:已认证的 `userId`、`tenantId`)。 ### 1. 声明参数 -首先,通过 `SubAgentConfig` 声明自定义参数。框架严格区分两类参数以保证安全性与灵活性: +首先,通过 `SubAgentConfig` 声明参数。同一个参数名只能属于一类:需要让大模型看到和生成的值使用 `addParameter`;必须仅由后端上下文提供的值使用 `addSystemParameter`。 ```java SubAgentConfig config = SubAgentConfig.builder() - // 1. 声明业务变量(调用 addParameter:大模型可见,由 LLM 根据对话推断) - .addParameter("analysis_depth", Map.of("type", "string", "enum", List.of("basic", "detailed")), false) - // 2. 声明安全变量(调用 addSystemParameter:大模型不可见,严格由系统底层注入) + // 大模型可见。required=true 表示大模型必须在工具调用中提供该字段。 + .addParameter("analysis_depth", + Map.of("type", "string", "enum", List.of("basic", "detailed")), + true) + // 大模型不可见。必须通过 ToolExecutionContext 注入。 .addSystemParameter("userId") .build(); @@ -131,9 +133,9 @@ SubAgentTool tool = new SubAgentTool(agentProvider, config); ### 2. 两种注入方式示例 -#### 方式一:大模型动态注入(业务变量) +#### 方式一:自定义参数(大模型动态注入) 适用于**业务属性**。通过 `addParameter` 声明的变量(如 `analysis_depth`)会被渲染进传递给大模型的 JSON Schema 中。 -当用户说:*“帮我进行极其深入的代码审查”* 时,大模型会自动推断并生成如下调用: +当用户说:*“帮我进行极其深入的代码审查”* 时,大模型可以推断并生成如下调用: ```json { "message": "审查代码库", @@ -141,10 +143,12 @@ SubAgentTool tool = new SubAgentTool(agentProvider, config); } ``` -💡 后端干预(兜底机制):虽然业务变量由大模型推断,但框架同样允许后端通过 ToolExecutionContext 注入同名参数。如果系统处于降级模式或有特殊校验,底层注入的值将强行覆盖大模型的推断结果,保障系统的绝对控制权。 +如果 `required=true`,该参数还会进入 JSON Schema 的 `required` 数组。这意味着工具调用必须在进入 `SubAgentTool` 之前就包含这个字段;`ToolExecutor` 会先做 schema 校验,然后才会合并上下文。`ToolExecutionContext` 中的同名值仍然可以在运行时覆盖大模型提供的值,但它不会绕过这一步预执行 schema 校验。 -#### 方式二:系统上下文注入(安全变量) -适用于**敏感安全属性**(如 `userId`)。通过 `addSystemParameter` 声明的变量对大模型**完全隐身**。系统拦截器会在运行时直接将其安全塞入。 +如果业务参数允许大模型省略,请设置 `required=false`。如果某个值必须只由后端上下文提供,请使用 `addSystemParameter`。 + +#### 方式二:系统参数(后端上下文注入) +适用于**敏感安全属性**(如 `userId`)。通过 `addSystemParameter` 声明的变量对大模型**完全隐身**。系统会在运行时直接注入它们。 ```java // 在系统入口处注册上下文 ToolExecutionContext context = ToolExecutionContext.builder() @@ -162,7 +166,10 @@ tool.callAsync(param).subscribe(); ``` > **🔒 安全与优先级** -> 由于系统参数(如 `userId`)是通过 `addSystemParameter` 声明的,它不会出现在发送给大模型的 Schema 中。框架在运行时严格遵循 **“系统上下文绝对优先”** 的原则。即使黑客通过提示词注入(Prompt Injection)攻击,迫使大模型在输出的 JSON 中强行拼凑出 `"userId": "admin"`,底层框架也会**完全无视**并丢弃大模型传入的假值,严格只从 `ToolExecutionContext` 中提取真实的上下文,从根本上杜绝越权风险。 +> 由于系统参数(如 `userId`)是通过 `addSystemParameter` 声明的,它不会出现在发送给大模型的 schema 中。框架在运行时严格从 `ToolExecutionContext` 中提取它们。即使恶意提示词诱导大模型输出 `"userId": "admin"`,这个假值也会被忽略,因为 `userId` 不是大模型可见的自定义参数。 + +> **必填自定义参数与系统参数** +> 必填自定义参数是 JSON Schema 层面的必填字段,要求大模型/工具调用输入提供。系统参数则是后端上下文层面的参数,不属于 JSON Schema。不要把安全敏感、仅由上下文提供的值声明为必填自定义参数。 ### 3. 在子智能体中读取参数 无论参数是通过哪种方式注入的,最终都会被安全地挂载到子智能体输入消息的 `metadata` 中。提取方式完全一致: