factory) {
/**
* Adds a fully custom subagent factory for a given agent id, with a description shown to
* the orchestrator. When {@code description} is null or blank, the name is used.
+ *
+ * Use {@link #subagentFactory(SubagentDeclaration, SubagentFactory)} when the factory
+ * needs the parent's {@link RuntimeContext} or declaration options such as
+ * {@code persistSession}.
*/
public Builder subagentFactory(
String name, String description, Function factory) {
this.customSubagentFactories.add(
new HarnessAgentBuilderSupport.SubagentFactoryEntry(
- name, description, factory));
+ name, description, rc -> factory.apply(name), null));
+ return this;
+ }
+
+ /**
+ * Adds a subagent whose instances are built by {@code factory} and whose spawn-time
+ * behavior comes from {@code declaration}. Use this when a subagent needs a fully custom
+ * instance (for example a {@link HarnessAgent} with its own middleware, tools, or model)
+ * together with declaration options such as {@code persistSession}.
+ *
+ * The subagent is registered under the declaration's name and description. Only the
+ * spawn-time options are honored: {@code persistSession}, {@code mode}, {@code hidden},
+ * {@code exposeToUser}, and {@code inheritParentPermissions}. Options that describe how to
+ * build the instance ({@code model}, {@code tools}, {@code skills}, {@code workspace},
+ * {@code steps}, ...) are ignored; configure those inside the factory, which receives the
+ * parent's {@link RuntimeContext} when a new instance is needed. Persistent reuse does not
+ * invoke the factory again.
+ *
+ * @throws IllegalArgumentException if {@code declaration} is a remote subagent ({@code
+ * url} set), which is invoked over HTTP and cannot use a local factory
+ */
+ public Builder subagentFactory(SubagentDeclaration declaration, SubagentFactory factory) {
+ java.util.Objects.requireNonNull(declaration, "declaration");
+ java.util.Objects.requireNonNull(factory, "factory");
+ if (declaration.isRemote()) {
+ throw new IllegalArgumentException(
+ "Remote subagent '"
+ + declaration.getName()
+ + "' cannot be combined with a custom factory");
+ }
+ this.customSubagentFactories.add(
+ new HarnessAgentBuilderSupport.SubagentFactoryEntry(
+ declaration.getName(),
+ declaration.getDescription(),
+ factory,
+ declaration));
return this;
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgentBuilderSupport.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgentBuilderSupport.java
index 8921d7b3da..3d6ddad9c2 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgentBuilderSupport.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgentBuilderSupport.java
@@ -15,7 +15,6 @@
*/
package io.agentscope.harness.agent;
-import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.middleware.MiddlewareBase;
@@ -129,10 +128,15 @@ static String buildSubagentSysPrompt(String basePrompt) {
}
/**
- * Custom-supplied subagent factory entry: name + optional description + factory function from
- * name to Agent.
+ * Custom-supplied subagent factory entry: name + optional description + factory, plus an
+ * optional {@link SubagentDeclaration} whose spawn-time options (e.g. {@code persistSession})
+ * apply to the custom instances.
*/
- record SubagentFactoryEntry(String name, String description, Function factory) {
+ record SubagentFactoryEntry(
+ String name,
+ String description,
+ SubagentFactory factory,
+ SubagentDeclaration declaration) {
/** Description shown to the orchestrator, falling back to the name when unset. */
String displayDescription() {
@@ -230,12 +234,8 @@ static List buildSubagentEntries(
new SubagentEntry(
custom.name(),
custom.displayDescription(),
- // custom factory uses Function — pre-B-0 signature
- // doesn't accept RuntimeContext. Bridge by ignoring rc here; users
- // that need parent-aware isolation should register a programmatic
- // SubagentEntry directly with a B-0 SubagentFactory lambda.
- (rc) -> custom.factory().apply(custom.name()),
- null));
+ custom.factory(),
+ custom.declaration()));
}
return entries;
@@ -273,12 +273,8 @@ static List buildStaticSubagentEntries(
new SubagentEntry(
custom.name(),
custom.displayDescription(),
- // custom factory uses Function — pre-B-0 signature
- // doesn't accept RuntimeContext. Bridge by ignoring rc here; users
- // that need parent-aware isolation should register a programmatic
- // SubagentEntry directly with a B-0 SubagentFactory lambda.
- (rc) -> custom.factory().apply(custom.name()),
- null));
+ custom.factory(),
+ custom.declaration()));
}
return entries;
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
index 10264f86c3..d6a8785e1a 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
@@ -266,7 +266,7 @@ public boolean isHidden() {
/**
* Whether the subagent's session state should persist across parent calls. When {@code true},
- * the spawn key is derived deterministically from (parentSessionId, agentId, label), enabling
+ * the spawn key is derived deterministically from (userId, parentSessionId, agentId, label), enabling
* state recovery after process restarts. When {@code false} (default), a random UUID is used.
*/
public boolean isPersistSession() {
@@ -543,7 +543,7 @@ public Builder hidden(boolean hidden) {
/**
* When {@code true}, the subagent's spawn key is derived deterministically from
- * (parentSessionId, agentId, label), enabling state recovery across parent calls and
+ * (userId, parentSessionId, agentId, label), enabling state recovery across parent calls and
* process restarts. Defaults to {@code false}.
*/
public Builder persistSession(boolean persistSession) {
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
index 2968cd6940..d7b36589e2 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
@@ -61,6 +61,7 @@
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -230,7 +231,10 @@ private record SpawnedAgent(
String key, String agentId, String sessionId, String label, Agent agent, int depth) {}
private final ConcurrentHashMap agentsByKey = new ConcurrentHashMap<>();
- private final ConcurrentHashMap labelToKey = new ConcurrentHashMap<>();
+
+ private record LabelKey(String userId, String parentSessionId, String label) {}
+
+ private final ConcurrentHashMap labelToKey = new ConcurrentHashMap<>();
/**
* Creates an {@code AgentSpawnTool} that derives the active user-id from each tool call's
@@ -351,21 +355,16 @@ public Mono agentSpawn(
String canonLabel = label != null && !label.isBlank() ? label.trim() : null;
DefaultAgentManager manager = managerFor(runtimeContext);
- Optional agentOpt = manager.createAgentIfPresent(agentId, runtimeContext);
- if (agentOpt.isEmpty()) {
- if (manager.isPrimaryOnly(agentId)) {
- return Mono.just(
- ToolResultBlock.error(
- "agent_id '"
- + agentId
- + "' is PRIMARY-only and cannot be spawned as a"
- + " subagent."));
- }
- log.warn("agent_spawn unknown agentId={}, known={}", agentId, manager);
+ if (manager.isPrimaryOnly(agentId)) {
+ return Mono.just(
+ ToolResultBlock.error(
+ "agent_id '"
+ + agentId
+ + "' is PRIMARY-only and cannot be spawned as a subagent."));
+ }
+ if (!manager.hasAgent(agentId)) {
return Mono.just(ToolResultBlock.error("Unknown agent_id: " + agentId));
}
- log.debug("agent_spawn resolved: agentId={}", agentId);
- Agent agent = agentOpt.get();
String currentUserId = runtimeContext != null ? runtimeContext.getUserId() : null;
String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null;
var declOpt = manager.getDeclaration(agentId);
@@ -374,47 +373,54 @@ public Mono agentSpawn(
String key;
String sessionId;
if (persist) {
- String hash = deterministicHash(parentSessionId, agentId, canonLabel);
+ String hash = deterministicHash(currentUserId, parentSessionId, agentId, canonLabel);
key = "agent:" + agentId + ":" + hash;
sessionId = "sub-" + hash;
- // Reuse existing agent if same deterministic key was already spawned.
- SpawnedAgent existing = agentsByKey.get(key);
- if (existing != null) {
- propagatePlanMode(
- parentState, currentUserId, existing.sessionId(), existing.agent());
- propagateParentDenyRules(
- parentState,
- currentUserId,
- existing.sessionId(),
- existing.agent(),
- declOpt);
- String spawnInfo = formatSpawnInfo(key, agentId, sessionId, null);
- boolean hasTask = task != null && !task.isBlank();
- if (!hasTask) {
- return Mono.just(
- ToolResultBlock.success(spawnInfo + "\nstatus: accepted (reused)"));
- }
- return execSpawnTask(
- existing,
- runtimeContext,
- parentState,
- spawnInfo,
- task,
- timeoutSeconds,
- declOpt);
- }
} else {
key = "agent:" + agentId + ":" + UUID.randomUUID();
sessionId = "sub-" + UUID.randomUUID();
}
- // Label uniqueness check — skipped above for persist=true reuse path (already returned).
- if (canonLabel != null && labelToKey.containsKey(canonLabel.toLowerCase())) {
+ LabelKey labelKey = canonLabel != null ? labelKey(runtimeContext, canonLabel) : null;
+ String labeledKey = labelKey != null ? labelToKey.get(labelKey) : null;
+ if (labeledKey != null && (!persist || !labeledKey.equals(key))) {
return Mono.just(ToolResultBlock.error("Label already in use: " + canonLabel));
}
+ // Create only on a cache miss, including concurrent spawns of the same identity.
+ AtomicBoolean created = new AtomicBoolean();
SpawnedAgent spawned =
- new SpawnedAgent(key, agentId, sessionId, canonLabel, agent, nextDepth);
+ agentsByKey.computeIfAbsent(
+ key,
+ ignored -> {
+ Optional agentOpt =
+ manager.createAgentIfPresent(agentId, runtimeContext);
+ if (agentOpt.isEmpty()) {
+ return null;
+ }
+ created.set(true);
+ return new SpawnedAgent(
+ key, agentId, sessionId, canonLabel, agentOpt.get(), nextDepth);
+ });
+ if (spawned == null) {
+ return Mono.just(ToolResultBlock.error("Unknown agent_id: " + agentId));
+ }
+ Agent agent = spawned.agent();
+ if (labelKey != null) {
+ labelToKey.put(labelKey, key);
+ }
+ persistSpawnEntry(parentState, key, agentId, sessionId, canonLabel, nextDepth);
+ if (!created.get()) {
+ propagatePlanMode(parentState, currentUserId, sessionId, agent);
+ propagateParentDenyRules(parentState, currentUserId, sessionId, agent, declOpt);
+ String spawnInfo = formatSpawnInfo(key, agentId, sessionId, null);
+ if (task == null || task.isBlank()) {
+ return Mono.just(
+ ToolResultBlock.success(spawnInfo + "\nstatus: accepted (reused)"));
+ }
+ return execSpawnTask(
+ spawned, runtimeContext, parentState, spawnInfo, task, timeoutSeconds, declOpt);
+ }
var recorder = io.agentscope.core.session.SessionRecorder.from(runtimeContext);
if (recorder != null)
recorder.recordNow(
@@ -435,11 +441,6 @@ public Mono agentSpawn(
.build())
.agentId()
: agentId));
- agentsByKey.put(key, spawned);
- if (canonLabel != null) {
- labelToKey.put(canonLabel.toLowerCase(), key);
- }
- persistSpawnEntry(parentState, key, agentId, sessionId, canonLabel, nextDepth);
// Propagate plan mode: if parent is in plan mode, force child into read-only mode too.
propagatePlanMode(parentState, currentUserId, sessionId, agent);
@@ -622,7 +623,7 @@ public Mono agentSend(
if (hasKey) {
key = agentKey.trim();
} else {
- key = labelToKey.get(label.trim().toLowerCase());
+ key = labelToKey.get(labelKey(runtimeContext, label.trim()));
if (key == null) {
key = tryResolveLabelFromState(parentState, label.trim());
}
@@ -1127,10 +1128,11 @@ private String tryResolveLabelFromState(AgentState parentState, String label) {
if (parentState == null) {
return null;
}
- String lowerLabel = label.toLowerCase();
+ String lowerLabel = label.toLowerCase(Locale.ROOT);
for (io.agentscope.core.state.ToolContextState.SpawnEntry entry :
parentState.getToolContext().getSpawnRegistry().values()) {
- if (entry.label() != null && entry.label().toLowerCase().equals(lowerLabel)) {
+ if (entry.label() != null
+ && entry.label().toLowerCase(Locale.ROOT).equals(lowerLabel)) {
return entry.key();
}
}
@@ -1165,7 +1167,7 @@ private SpawnedAgent tryRestoreFromState(
entry.depth());
agentsByKey.put(key, restored);
if (entry.label() != null) {
- labelToKey.put(entry.label().toLowerCase(), key);
+ labelToKey.put(labelKey(runtimeContext, entry.label()), key);
}
log.info(
"Restored subagent from persisted state: key={}, agentId={}", key, entry.agentId());
@@ -1756,15 +1758,29 @@ private Mono execSpawnTask(
forceSync);
}
+ private static LabelKey labelKey(RuntimeContext runtimeContext, String label) {
+ return new LabelKey(
+ runtimeContext != null ? runtimeContext.getUserId() : null,
+ runtimeContext != null ? runtimeContext.getSessionId() : null,
+ label.toLowerCase(Locale.ROOT));
+ }
+
/**
- * Derives a deterministic 12-char hex hash from (parentSessionId, agentId, label). Same inputs
- * always produce the same key, enabling subagent state recovery across parent calls.
+ * Derives a deterministic 12-char hex hash from (userId, parentSessionId, agentId, label).
+ * Length prefixes distinguish null values and embedded separators without changing the
+ * external key format.
*/
- static String deterministicHash(String parentSessionId, String agentId, String label) {
- String parent = parentSessionId != null ? parentSessionId : "anon";
- return label != null
- ? SessionIdUtils.deterministicHash(parent, agentId, label)
- : SessionIdUtils.deterministicHash(parent, agentId);
+ static String deterministicHash(
+ String userId, String parentSessionId, String agentId, String label) {
+ return SessionIdUtils.deterministicHash(
+ identityPart(userId),
+ identityPart(parentSessionId),
+ identityPart(agentId),
+ identityPart(label));
+ }
+
+ private static String identityPart(String value) {
+ return value == null ? "-1:" : value.length() + ":" + value;
}
private static void propagateParentDenyRules(
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/SubagentFactoryDeclarationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/SubagentFactoryDeclarationTest.java
new file mode 100644
index 0000000000..f374185d20
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/SubagentFactoryDeclarationTest.java
@@ -0,0 +1,400 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.harness.agent;
+
+import static io.agentscope.core.message.ToolResultState.SUCCESS;
+import static io.agentscope.harness.agent.tool.ToolResultAssertions.assertText;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.agent.test.MockModel;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.middleware.AgentInput;
+import io.agentscope.core.middleware.MiddlewareBase;
+import io.agentscope.core.model.ChatResponse;
+import io.agentscope.core.state.AgentState;
+import io.agentscope.core.state.InMemoryAgentStateStore;
+import io.agentscope.harness.agent.middleware.SubagentEntry;
+import io.agentscope.harness.agent.subagent.DefaultAgentManager;
+import io.agentscope.harness.agent.subagent.SubagentDeclaration;
+import io.agentscope.harness.agent.testing.HarnessQuiescence;
+import io.agentscope.harness.agent.tool.AgentSpawnTool;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import reactor.core.publisher.Flux;
+
+/**
+ * Covers {@link HarnessAgent.Builder#subagentFactory(SubagentDeclaration,
+ * io.agentscope.harness.agent.subagent.SubagentFactory)}: a custom subagent instance combined with
+ * declaration options such as {@code persistSession} (issue #3440).
+ */
+@HarnessQuiescence
+class SubagentFactoryDeclarationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(30);
+
+ @TempDir Path workspace;
+
+ private final List createdAgents = new ArrayList<>();
+
+ @AfterEach
+ void closeAgents() {
+ createdAgents.forEach(HarnessAgent::close);
+ createdAgents.clear();
+ }
+
+ @Test
+ void declaredFactory_registersDeclarationWithCustomFactory() {
+ Agent child = mock(Agent.class);
+ AtomicReference seenParentRc = new AtomicReference<>();
+ SubagentDeclaration declaration =
+ SubagentDeclaration.builder()
+ .name("note-taker")
+ .description("Keeps notes across spawns")
+ .persistSession(true)
+ .build();
+ HarnessAgent.Builder builder =
+ HarnessAgent.builder()
+ .model(new MockModel("unused"))
+ .workspace(workspace)
+ .subagentFactory(
+ declaration,
+ parentRc -> {
+ seenParentRc.set(parentRc);
+ return child;
+ });
+
+ RuntimeContext parentRc = RuntimeContext.builder().userId("u").sessionId("s").build();
+ for (List entries :
+ List.of(
+ builder.buildSubagentEntries(workspace),
+ HarnessAgentBuilderSupport.buildStaticSubagentEntries(
+ builder, workspace, null))) {
+ SubagentEntry entry =
+ entries.stream()
+ .filter(e -> "note-taker".equals(e.name()))
+ .findFirst()
+ .orElseThrow();
+ assertEquals("Keeps notes across spawns", entry.description());
+ assertSame(declaration, entry.declaration());
+
+ seenParentRc.set(null);
+ assertSame(child, entry.factory().create(parentRc));
+ assertSame(parentRc, seenParentRc.get(), "factory must receive the parent context");
+ }
+ }
+
+ @Test
+ void declaredFactory_persistSessionReusesCustomHarnessAgent() {
+ List> childModelInputs = new CopyOnWriteArrayList<>();
+ MockModel childModel = new MockModel(recordingReply(childModelInputs, "noted"));
+ AtomicInteger privateMiddlewareCalls = new AtomicInteger();
+ AtomicInteger factoryCalls = new AtomicInteger();
+ MiddlewareBase privateMiddleware =
+ new MiddlewareBase() {
+ @Override
+ public Flux onAgent(
+ Agent agent,
+ RuntimeContext ctx,
+ AgentInput input,
+ Function> next) {
+ privateMiddlewareCalls.incrementAndGet();
+ return next.apply(input);
+ }
+ };
+ SubagentDeclaration declaration =
+ SubagentDeclaration.builder()
+ .name("note-taker")
+ .description("Keeps notes across spawns")
+ .persistSession(true)
+ .build();
+ List entries =
+ HarnessAgent.builder()
+ .model(new MockModel("unused"))
+ .workspace(workspace)
+ .subagentFactory(
+ declaration,
+ parentRc -> {
+ factoryCalls.incrementAndGet();
+ HarnessAgent child =
+ HarnessAgent.builder()
+ .name("note-taker")
+ .model(childModel)
+ .workspace(workspace)
+ .stateStore(new InMemoryAgentStateStore())
+ .middleware(privateMiddleware)
+ .build();
+ createdAgents.add(child);
+ return child;
+ })
+ .buildSubagentEntries(workspace);
+ AgentSpawnTool tool = new AgentSpawnTool(new DefaultAgentManager(entries, null), null, 0);
+ RuntimeContext ctx =
+ RuntimeContext.builder().userId("user").sessionId("parent-session").build();
+
+ String first =
+ tool.agentSpawn(
+ ctx,
+ null,
+ "note-taker",
+ "Remember the code word: heron.",
+ null,
+ 30,
+ null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ AgentState nextParentState = AgentState.builder().build();
+ String second =
+ tool.agentSpawn(
+ ctx,
+ nextParentState,
+ "note-taker",
+ "What is the code word?",
+ null,
+ 30,
+ null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+
+ assertTrue(first.contains("status: ok"), first);
+ assertTrue(second.contains("status: ok"), second);
+ assertEquals(
+ lineValue(first, "agent_key: "),
+ lineValue(second, "agent_key: "),
+ "persistSession must reuse the deterministic key");
+ assertTrue(
+ childModelInputs.stream()
+ .anyMatch(
+ messages ->
+ containsText(messages, "code word: heron")
+ && containsText(
+ messages, "What is the code word?")),
+ "the second spawn must continue the first spawn's conversation");
+ assertEquals(2, privateMiddlewareCalls.get(), "custom middleware runs on both spawns");
+ assertEquals(
+ 1, factoryCalls.get(), "persistent reuse must not construct a discarded child");
+ assertTrue(
+ nextParentState
+ .getToolContext()
+ .getSpawnRegistry()
+ .containsKey(lineValue(second, "agent_key: ")),
+ "a reused child must be registered before returning to the current parent call");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", "notes"})
+ void declaredFactory_persistSessionIsolatesUsers(String label) {
+ AtomicInteger factoryCalls = new AtomicInteger();
+ SubagentDeclaration declaration =
+ SubagentDeclaration.builder()
+ .name("note-taker")
+ .description("Keeps notes")
+ .persistSession(true)
+ .build();
+ List entries =
+ HarnessAgent.builder()
+ .model(new MockModel("unused"))
+ .workspace(workspace)
+ .subagentFactory(
+ declaration,
+ parentRc -> {
+ factoryCalls.incrementAndGet();
+ String owner = parentRc.getUserId();
+ HarnessAgent child =
+ HarnessAgent.builder()
+ .name("note-taker")
+ .model(new MockModel("child bound to " + owner))
+ .workspace(workspace.resolve(owner))
+ .stateStore(new InMemoryAgentStateStore())
+ .build();
+ createdAgents.add(child);
+ return child;
+ })
+ .buildSubagentEntries(workspace);
+ AgentSpawnTool tool = new AgentSpawnTool(new DefaultAgentManager(entries, null), null, 0);
+ RuntimeContext alice = RuntimeContext.builder().userId("alice").sessionId("s1").build();
+ RuntimeContext bob = RuntimeContext.builder().userId("bob").sessionId("s1").build();
+
+ String aliceFirst =
+ tool.agentSpawn(alice, null, "note-taker", "first", label, 30, null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ String bobFirst =
+ tool.agentSpawn(bob, null, "note-taker", "first", label, 30, null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ String aliceAgain =
+ tool.agentSpawn(alice, null, "note-taker", "again", label, 30, null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ String bobAgain =
+ tool.agentSpawn(bob, null, "note-taker", "again", label, 30, null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+
+ assertTrue(aliceFirst.contains("child bound to alice"), aliceFirst);
+ assertTrue(aliceAgain.contains("child bound to alice"), aliceAgain);
+ assertTrue(bobFirst.contains("child bound to bob"), bobFirst);
+ assertTrue(bobAgain.contains("child bound to bob"), bobAgain);
+ assertNotEquals(lineValue(aliceFirst, "agent_key: "), lineValue(bobFirst, "agent_key: "));
+ assertNotEquals(lineValue(aliceFirst, "session_id: "), lineValue(bobFirst, "session_id: "));
+ assertEquals(lineValue(aliceFirst, "agent_key: "), lineValue(aliceAgain, "agent_key: "));
+ assertEquals(lineValue(bobFirst, "agent_key: "), lineValue(bobAgain, "agent_key: "));
+ assertEquals(2, factoryCalls.get(), "one child must be constructed per user");
+ if (!label.isBlank()) {
+ String aliceSend =
+ tool.agentSend(alice, null, null, label, "follow-up", 30)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ String bobSend =
+ tool.agentSend(bob, null, null, label, "follow-up", 30)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ assertTrue(aliceSend.contains("child bound to alice"), aliceSend);
+ assertTrue(bobSend.contains("child bound to bob"), bobSend);
+ }
+ }
+
+ @Test
+ void declaredFactory_concurrentPersistentSpawnsCreateOneChild() throws Exception {
+ AtomicInteger factoryCalls = new AtomicInteger();
+ SubagentDeclaration declaration =
+ SubagentDeclaration.builder()
+ .name("worker")
+ .description("Worker")
+ .persistSession(true)
+ .build();
+ List entries =
+ HarnessAgent.builder()
+ .model(new MockModel("unused"))
+ .workspace(workspace)
+ .subagentFactory(
+ declaration,
+ rc -> {
+ factoryCalls.incrementAndGet();
+ return mock(Agent.class);
+ })
+ .buildSubagentEntries(workspace);
+ AgentSpawnTool tool = new AgentSpawnTool(new DefaultAgentManager(entries, null), null, 0);
+ RuntimeContext ctx = RuntimeContext.builder().userId("user").sessionId("s1").build();
+ int callers = 8;
+ CountDownLatch ready = new CountDownLatch(callers);
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(callers);
+ try {
+ List> results = new ArrayList<>();
+ for (int i = 0; i < callers; i++) {
+ results.add(
+ executor.submit(
+ () -> {
+ ready.countDown();
+ assertTrue(start.await(10, TimeUnit.SECONDS));
+ return tool.agentSpawn(
+ ctx, null, "worker", null, "notes", 30, null)
+ .map(result -> assertText(result, SUCCESS))
+ .block(TIMEOUT);
+ }));
+ }
+ assertTrue(ready.await(10, TimeUnit.SECONDS));
+ start.countDown();
+ String expectedKey = null;
+ for (Future result : results) {
+ String response = result.get(30, TimeUnit.SECONDS);
+ assertTrue(response.contains("status: accepted"), response);
+ String key = lineValue(response, "agent_key: ");
+ if (expectedKey == null) {
+ expectedKey = key;
+ }
+ assertEquals(expectedKey, key);
+ }
+ assertEquals(1, factoryCalls.get(), "concurrent cache misses must construct one child");
+ } finally {
+ start.countDown();
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void declaredFactory_rejectsRemoteDeclaration() {
+ SubagentDeclaration remote =
+ SubagentDeclaration.builder()
+ .name("remote-worker")
+ .description("Runs elsewhere")
+ .url("http://localhost:8080")
+ .build();
+ HarnessAgent.Builder builder = HarnessAgent.builder();
+
+ IllegalArgumentException error =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> builder.subagentFactory(remote, rc -> mock(Agent.class)));
+ assertTrue(error.getMessage().contains("remote-worker"), error.getMessage());
+ }
+
+ private static Function, List> recordingReply(
+ List> inputs, String text) {
+ return messages -> {
+ inputs.add(List.copyOf(messages));
+ return List.of(
+ new ChatResponse(
+ "child-reply",
+ List.of(TextBlock.builder().text(text).build()),
+ null,
+ Map.of(),
+ "stop"));
+ };
+ }
+
+ private static boolean containsText(List messages, String text) {
+ return messages.stream()
+ .map(Msg::getTextContent)
+ .anyMatch(content -> content != null && content.contains(text));
+ }
+
+ private static String lineValue(String result, String prefix) {
+ return result.lines()
+ .filter(line -> line.startsWith(prefix))
+ .map(line -> line.substring(prefix.length()))
+ .findFirst()
+ .orElseThrow();
+ }
+}
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java
index 3c46e325f2..82ad6d3ac7 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java
@@ -253,56 +253,71 @@ public boolean cancelTask(RuntimeContext rc, String sessionId, String taskId) {
@Test
void sameInputs_produceSameHash() {
- String h1 = AgentSpawnTool.deterministicHash("session-1", "code-reviewer", "review");
- String h2 = AgentSpawnTool.deterministicHash("session-1", "code-reviewer", "review");
+ String h1 =
+ AgentSpawnTool.deterministicHash("user", "session-1", "code-reviewer", "review");
+ String h2 =
+ AgentSpawnTool.deterministicHash("user", "session-1", "code-reviewer", "review");
assertEquals(h1, h2);
}
+ @Test
+ void userIdentity_hasUnambiguousBoundaries() {
+ assertNotEquals(
+ AgentSpawnTool.deterministicHash("alice", "s1", "worker", null),
+ AgentSpawnTool.deterministicHash("bob", "s1", "worker", null));
+ assertNotEquals(
+ AgentSpawnTool.deterministicHash("a/b", "c", "worker", null),
+ AgentSpawnTool.deterministicHash("a", "b/c", "worker", null));
+ for (String literal : List.of("anon", "null", "")) {
+ assertNotEquals(
+ AgentSpawnTool.deterministicHash(null, "s1", "worker", null),
+ AgentSpawnTool.deterministicHash(literal, "s1", "worker", null));
+ }
+ }
+
@Test
void hashIs12HexChars() {
- String h = AgentSpawnTool.deterministicHash("s1", "worker", null);
+ String h = AgentSpawnTool.deterministicHash("user", "s1", "worker", null);
assertEquals(12, h.length());
assertTrue(h.matches("[0-9a-f]{12}"));
}
@Test
void differentParentSession_differentHash() {
- String h1 = AgentSpawnTool.deterministicHash("session-A", "worker", null);
- String h2 = AgentSpawnTool.deterministicHash("session-B", "worker", null);
+ String h1 = AgentSpawnTool.deterministicHash("user", "session-A", "worker", null);
+ String h2 = AgentSpawnTool.deterministicHash("user", "session-B", "worker", null);
assertNotEquals(h1, h2);
}
@Test
void differentAgentId_differentHash() {
- String h1 = AgentSpawnTool.deterministicHash("s1", "code-reviewer", null);
- String h2 = AgentSpawnTool.deterministicHash("s1", "test-runner", null);
+ String h1 = AgentSpawnTool.deterministicHash("user", "s1", "code-reviewer", null);
+ String h2 = AgentSpawnTool.deterministicHash("user", "s1", "test-runner", null);
assertNotEquals(h1, h2);
}
@Test
void differentLabel_differentHash() {
- String h1 = AgentSpawnTool.deterministicHash("s1", "worker", "alpha");
- String h2 = AgentSpawnTool.deterministicHash("s1", "worker", "beta");
+ String h1 = AgentSpawnTool.deterministicHash("user", "s1", "worker", "alpha");
+ String h2 = AgentSpawnTool.deterministicHash("user", "s1", "worker", "beta");
assertNotEquals(h1, h2);
}
@Test
void nullLabel_differentFromNonNullLabel() {
- String h1 = AgentSpawnTool.deterministicHash("s1", "worker", null);
- String h2 = AgentSpawnTool.deterministicHash("s1", "worker", "label");
+ String h1 = AgentSpawnTool.deterministicHash("user", "s1", "worker", null);
+ String h2 = AgentSpawnTool.deterministicHash("user", "s1", "worker", "label");
assertNotEquals(h1, h2);
}
@Test
- void nullParentSession_usesAnonFallback() {
- String h1 = AgentSpawnTool.deterministicHash(null, "worker", null);
- String h2 = AgentSpawnTool.deterministicHash(null, "worker", null);
+ void nullParentSession_isDistinctFromLiteralAnon() {
+ String h1 = AgentSpawnTool.deterministicHash("user", null, "worker", null);
+ String h2 = AgentSpawnTool.deterministicHash("user", null, "worker", null);
assertEquals(h1, h2);
- // "anon" seed differs from a real session id
- String h3 = AgentSpawnTool.deterministicHash("anon", "worker", null);
- // null → "anon" so these are the same
- assertEquals(h1, h3);
+ String h3 = AgentSpawnTool.deterministicHash("user", "anon", "worker", null);
+ assertNotEquals(h1, h3);
}
@Test
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolPlanModeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolPlanModeTest.java
index 41b2849848..b6ef13bb13 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolPlanModeTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolPlanModeTest.java
@@ -66,7 +66,10 @@ void persistentSpawnReuse_reappliesParentPlanModeBeforeReturningOrInvoking() {
String childSessionId =
"sub-"
+ AgentSpawnTool.deterministicHash(
- fixture.context().getSessionId(), "worker", "persistent-worker");
+ fixture.context().getUserId(),
+ fixture.context().getSessionId(),
+ "worker",
+ "persistent-worker");
assertFalse(reused.isPlanModeActive(fixture.context().getUserId(), childSessionId));
fixture.parentState().getPlanModeContext().setPlanActive(true);
diff --git a/docs/v2/en/docs/harness/subagent.md b/docs/v2/en/docs/harness/subagent.md
index 4c927b29e3..494ab901d6 100644
--- a/docs/v2/en/docs/harness/subagent.md
+++ b/docs/v2/en/docs/harness/subagent.md
@@ -104,6 +104,42 @@ results, including calls loaded from a failed session. Pending permission confir
require confirmation; empty-input resume and caller-supplied tool results retain their existing
behavior. Remote agents and custom factories configure recovery themselves.
+### Custom factories
+
+When a subagent needs an instance that a declaration cannot describe — its own middleware, tools
+the parent does not have, or a `Model` object — build it yourself in a factory:
+
+```java
+HarnessAgent.builder()
+ .name("orchestrator")
+ .model(model)
+ .workspace(workspace)
+ .subagentFactory(
+ SubagentDeclaration.builder()
+ .name("note-taker")
+ .description("Keeps notes across turns")
+ .persistSession(true)
+ .build(),
+ parentRc -> HarnessAgent.builder()
+ .name("note-taker")
+ .model(noteModel)
+ .workspace(noteWorkspace)
+ .toolkit(noteToolkit)
+ .middleware(auditMiddleware)
+ .build())
+ .build();
+```
+
+The factory receives the parent's `RuntimeContext` when a new instance is needed. A persistent
+cache hit reuses the existing instance without calling the factory. The declaration
+supplies the name, the description, and the spawn-time options `persistSession`, `mode`, `hidden`,
+`exposeToUser`, and `inheritParentPermissions`; fields that describe how to build the instance
+(`model`, `tools`, `skills`, `workspace`, `steps`, ...) are ignored. Remote declarations
+(`url(...)`) cannot be combined with a factory.
+
+`subagentFactory(name, description, factory)` still covers the simple case. It takes no
+declaration, so every spawn starts a fresh session.
+
### Built-in `general-purpose`
No spec file needed; always available. Its role is "generic fallback" — it mirrors the parent's capability (same model, tools, skills) and shares the parent's workspace. Useful when the parent wants to isolate context for a sub-task without writing a dedicated spec.
@@ -213,7 +249,13 @@ By default every `agent_spawn` creates a fresh subagent with a new session — n
.build())
```
-When `persistSession` is on, the framework derives a deterministic key from `(parentSessionId, agentId, label)`. If `agent_spawn` is called again with the same combination, the existing agent instance is reused — its conversation history and state are preserved.
+When `persistSession` is on, the framework derives a deterministic key from `(userId, parentSessionId, agentId, label)`. If `agent_spawn` is called again with the same combination, the existing agent instance is reused — its conversation history and state are preserved. Different users get separate instances even when their parent session IDs and labels match. Labels used by `agent_spawn` and `agent_send` are scoped to the user and parent session.
+
+Persistent keys now include user identity. Keys generated by older versions differ from newly spawned keys; saved entries can still be addressed with their original `agent_key` through `agent_send`.
+
+Subagents built by a custom factory get the same behavior when they are registered with
+`subagentFactory(declaration, factory)` and the declaration sets `persistSession(true)` (see
+Custom factories above).
## Exposing subagents to the user
diff --git a/docs/v2/zh/docs/harness/subagent.md b/docs/v2/zh/docs/harness/subagent.md
index 76485f9c17..c464a6c8fb 100644
--- a/docs/v2/zh/docs/harness/subagent.md
+++ b/docs/v2/zh/docs/harness/subagent.md
@@ -102,6 +102,41 @@ HarnessAgent.builder()
也适用于从失败会话中重新加载的工具调用。等待人工审批的工具仍须提供审批结果;空输入恢复执行、
调用方补交工具结果的行为保持原有语义。远端 agent 和自定义工厂需自行配置恢复策略。
+### 自定义工厂
+
+子 agent 需要声明描述不了的实例时——比如自己的 middleware、父 agent 没有的工具、直接传
+`Model` 对象——可以在工厂里自己构建:
+
+```java
+HarnessAgent.builder()
+ .name("orchestrator")
+ .model(model)
+ .workspace(workspace)
+ .subagentFactory(
+ SubagentDeclaration.builder()
+ .name("note-taker")
+ .description("跨轮次积累笔记")
+ .persistSession(true)
+ .build(),
+ parentRc -> HarnessAgent.builder()
+ .name("note-taker")
+ .model(noteModel)
+ .workspace(noteWorkspace)
+ .toolkit(noteToolkit)
+ .middleware(auditMiddleware)
+ .build())
+ .build();
+```
+
+需要新实例时才调用工厂,参数是父 agent 的 `RuntimeContext`。持久化缓存命中时直接复用已有实例,
+不会再次调用工厂。声明负责名称、描述,以及 spawn
+时生效的选项:`persistSession`、`mode`、`hidden`、`exposeToUser`、`inheritParentPermissions`;
+描述"怎么构建实例"的字段(`model`、`tools`、`skills`、`workspace`、`steps` 等)会被忽略。
+远程声明(`url(...)`)不能和工厂一起用。
+
+简单场景仍可用 `subagentFactory(name, description, factory)`。它不带声明,所以每次 spawn
+都是新会话。
+
### 内置 `general-purpose`
不需要写声明文件,总是可用。它的角色是"通用兜底"——能力和主 agent 一致(同样的模型、工具、技能),共享主工作区。适合"主 agent 想隔离上下文跑一个子任务但又懒得专门写 spec"。
@@ -211,7 +246,12 @@ agent_send label="pr-reviewer" message="顺便也看下 schema 变更"
.build())
```
-开启后,框架会根据 `(parentSessionId, agentId, label)` 生成确定性的 key。如果再次 spawn 同样的组合,就会复用已存在的 agent 实例——对话历史和状态都保留。
+开启后,框架会根据 `(userId, parentSessionId, agentId, label)` 生成确定性的 key。如果再次 spawn 同样的组合,就会复用已存在的 agent 实例——对话历史和状态都保留。不同用户即使父会话 ID 和 label 相同,也会使用各自的实例。`agent_spawn` 和 `agent_send` 的 label 按用户和父会话隔离。
+
+持久化 key 现在包含用户身份,因此旧版本生成的 key 与新 spawn 的 key 不同;已保存的条目仍可通过 `agent_send` 使用原有的 `agent_key` 访问。
+
+自定义工厂构建的子 agent 也一样:用 `subagentFactory(declaration, factory)` 注册,并在声明里设
+`persistSession(true)` 即可(见上文「自定义工厂」)。
## 向用户暴露子 Agent