diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index d066b0d55a..485a62e6b7 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -120,6 +120,7 @@ import io.agentscope.harness.agent.skill.curator.SkillVisibilityFilter; import io.agentscope.harness.agent.skill.runtime.ShellPathPolicy; import io.agentscope.harness.agent.subagent.SubagentDeclaration; +import io.agentscope.harness.agent.subagent.SubagentFactory; import io.agentscope.harness.agent.subagent.task.TaskRepository; import io.agentscope.harness.agent.tool.ArtifactDeliveryTool; import io.agentscope.harness.agent.tool.FilesystemTool; @@ -1518,7 +1519,9 @@ public Builder teamsMode( * {@link #abstractFilesystem(AbstractFilesystem)}, * {@link #environmentMemory(String)} *
  • Subagents: {@link #subagent(SubagentDeclaration)}, {@link #subagents(List)}, - * {@link #subagentFactory(String, Function)}, {@link #externalSubagentTool(Object)}, + * {@link #subagentFactory(String, Function)}, + * {@link #subagentFactory(SubagentDeclaration, SubagentFactory)}, + * {@link #externalSubagentTool(Object)}, * {@link #taskRepository(TaskRepository)}, {@link #modelResolver(Function)}
  • *
  • Skill governance: {@link #skillRepository(AgentSkillRepository)}, * {@link #projectGlobalSkillsDir(Path)}, @@ -2126,12 +2129,51 @@ public Builder subagentFactory(String name, Function 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