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 66b3792fac..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
@@ -19,6 +19,15 @@
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.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
/**
* Configuration for sub-agent registration.
@@ -27,14 +36,34 @@
* 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.
- *
{@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.
*
*
+ *
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. 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. 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:
*
*
@@ -50,11 +79,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();
* }
*/
@@ -65,6 +95,10 @@ public class SubAgentConfig {
private final boolean forwardEvents;
private final StreamOptions streamOptions;
private final Session session;
+ 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;
@@ -72,6 +106,11 @@ private SubAgentConfig(Builder builder) {
this.forwardEvents = builder.forwardEvents;
this.streamOptions = builder.streamOptions;
this.session = builder.session != null ? builder.session : new InMemorySession();
+ this.customParameters = copyCustomParameters(builder.customParameters);
+ this.requiredCustomParameters =
+ Collections.unmodifiableList(new ArrayList<>(builder.requiredCustomParameters));
+ this.systemParameters =
+ Collections.unmodifiableSet(new LinkedHashSet<>(builder.systemParameters));
}
/**
@@ -148,6 +187,126 @@ public Session getSession() {
return session;
}
+ /**
+ * 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() {
+ return customParameters;
+ }
+
+ /**
+ * 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() {
+ return 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;
+ }
+
+ /**
+ * 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()) {
+ 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) {
+ Objects.requireNonNull(source, "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