Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1518,7 +1519,9 @@ public Builder teamsMode(
* {@link #abstractFilesystem(AbstractFilesystem)},
* {@link #environmentMemory(String)}</li>
* <li>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)}</li>
* <li>Skill governance: {@link #skillRepository(AgentSkillRepository)},
* {@link #projectGlobalSkillsDir(Path)},
Expand Down Expand Up @@ -2126,12 +2129,51 @@ public Builder subagentFactory(String name, Function<String, Agent> 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.
*
* <p>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<String, Agent> 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}.
*
* <p>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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Agent> 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() {
Expand Down Expand Up @@ -230,12 +234,8 @@ static List<SubagentEntry> buildSubagentEntries(
new SubagentEntry(
custom.name(),
custom.displayDescription(),
// custom factory uses Function<String, Agent> — 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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this custom factory registered alongside a persistSession=true declaration, it now enters the persistent reuse path in AgentSpawnTool.
manager.createAgentIfPresent() invokes the factory before agentsByKey.get(key). On a cache hit, the newly created child is discarded without being used or closed. A custom factory may allocate resources such as MCP clients or workspace indexes, so repeated persistent spawns can leave those resources unreleased.
Please check for an existing persistent child before creating a new instance. Extend declaredFactory_persistSessionReusesCustomHarnessAgent to assert that the factory is called once for two sequential spawns with the same persistent identity. Its current createdAgents cleanup closes every constructed child in the test, so it does not detect the discarded instance in production.

custom.declaration()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AgentSpawnTool.agentSpawn() derives the key from (parentSessionId, agentId, label) without userId. When a shared parent agent handles (alice, s1) and (bob, s1) with the same agent ID and label, the second spawn retrieves Alice’s cached child, even if the factory created a child configured for Bob. Passing Bob’s RuntimeContext does not replace tools, credentials, or workspace resources already bound to Alice’s instance.
Please include user identity in the persistent cache key and add a regression test using the same AgentSpawnTool for two users with the same parent session ID. It should verify separate child instances across users and reuse within the same user.

}

return entries;
Expand Down Expand Up @@ -273,12 +273,8 @@ static List<SubagentEntry> buildStaticSubagentEntries(
new SubagentEntry(
custom.name(),
custom.displayDescription(),
// custom factory uses Function<String, Agent> — 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -230,7 +231,10 @@ private record SpawnedAgent(
String key, String agentId, String sessionId, String label, Agent agent, int depth) {}

private final ConcurrentHashMap<String, SpawnedAgent> agentsByKey = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, String> labelToKey = new ConcurrentHashMap<>();

private record LabelKey(String userId, String parentSessionId, String label) {}

private final ConcurrentHashMap<LabelKey, String> labelToKey = new ConcurrentHashMap<>();

/**
* Creates an {@code AgentSpawnTool} that derives the active user-id from each tool call's
Expand Down Expand Up @@ -351,21 +355,16 @@ public Mono<ToolResultBlock> agentSpawn(
String canonLabel = label != null && !label.isBlank() ? label.trim() : null;
DefaultAgentManager manager = managerFor(runtimeContext);

Optional<Agent> 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);
Expand All @@ -374,47 +373,54 @@ public Mono<ToolResultBlock> 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<Agent> 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(
Expand All @@ -435,11 +441,6 @@ public Mono<ToolResultBlock> 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);
Expand Down Expand Up @@ -622,7 +623,7 @@ public Mono<ToolResultBlock> 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());
}
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1756,15 +1758,29 @@ private Mono<ToolResultBlock> 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(
Expand Down
Loading
Loading