{@code message} - Required. The message to send to the agent.
- *
{@code conversation_id} - Optional. Omit to start a new conversation, provide to continue
+ *
{@code session_id} - Optional. Omit to start a new conversation, provide to continue
* an existing one.
*
*
@@ -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:
*
*
By type only: {@code get(Class)} - suitable for singleton scenarios
*
By key + type: {@code get(String, Class)} - suitable for multi-instance
- * scenarios
+ * 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