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 @@ -126,7 +126,9 @@ public void setActivatedGroups(List<String> groups) {

@JsonProperty("spawn_registry")
public Map<String, SpawnEntry> getSpawnRegistry() {
return Map.copyOf(spawnRegistry);
synchronized (spawnRegistry) {
return Map.copyOf(spawnRegistry);
}
}

/**
Expand All @@ -137,7 +139,9 @@ public Map<String, SpawnEntry> getSpawnRegistry() {
*/
public void putSpawnEntry(String key, SpawnEntry entry) {
if (key != null && entry != null) {
this.spawnRegistry.put(key, entry);
synchronized (spawnRegistry) {
this.spawnRegistry.put(key, entry);
}
}
}

Expand All @@ -146,7 +150,9 @@ public void putSpawnEntry(String key, SpawnEntry entry) {
*/
public void removeSpawnEntry(String key) {
if (key != null) {
this.spawnRegistry.remove(key);
synchronized (spawnRegistry) {
this.spawnRegistry.remove(key);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import io.agentscope.harness.agent.gateway.HarnessGateway;
import io.agentscope.harness.agent.gateway.SubagentGatewayBridge;
import io.agentscope.harness.agent.gateway.channel.Channel;
import io.agentscope.harness.agent.gateway.channel.OutboundAddress;
import io.agentscope.harness.agent.memory.MemoryConfig;
import io.agentscope.harness.agent.memory.MemoryConsolidator;
import io.agentscope.harness.agent.memory.MemoryFlushManager;
Expand Down Expand Up @@ -461,7 +462,11 @@ public void close() {
// race with resource cleanup (e.g., temp workspace deletion in tests).
io.agentscope.harness.agent.memory.MemoryBackgroundTasks.awaitQuiescence(
5, java.util.concurrent.TimeUnit.SECONDS);
shutdownTaskRepository();
try {
shutdownTaskRepository();
} finally {
closeOwnedAgentSpawnTool();
}
} finally {
try {
if (ownedWorkspaceIndex != null) {
Expand All @@ -485,6 +490,14 @@ private void shutdownTaskRepository() {
}
}

private void closeOwnedAgentSpawnTool() {
if (subagentMiddleware instanceof SubagentsMiddleware sm) {
sm.closeOwnedAgentSpawnTool();
} else if (subagentMiddleware instanceof DynamicSubagentsMiddleware dsm) {
dsm.closeOwnedAgentSpawnTool();
}
}

// ==================== Agent interface delegation ====================

/** Returns the wrapped {@link ReActAgent}. */
Expand Down Expand Up @@ -631,9 +644,21 @@ private synchronized void ensureGateway() {
gw.bindMainAgent(this);

SubagentGatewayBridge bridge =
(agentId, sessionId, agent, replyTo) -> {
String subagentId = gw.exposeSubagent(agentId, sessionId, agent, replyTo);
return new SubagentGatewayBridge.ExposeResult(subagentId);
new SubagentGatewayBridge() {
@Override
public ExposeResult expose(
String agentId,
String sessionId,
Agent agent,
OutboundAddress replyTo) {
String subagentId = gw.exposeSubagent(agentId, sessionId, agent, replyTo);
return new ExposeResult(subagentId);
}

@Override
public void revoke(String subagentId) {
gw.revokeSubagent(subagentId);
}
};
io.agentscope.harness.agent.subagent.DefaultAgentManager agentManager = null;
if (subagentMiddleware instanceof SubagentsMiddleware sm) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import io.agentscope.harness.agent.gateway.channel.Channel;
import io.agentscope.harness.agent.gateway.channel.ChannelConfig;
import io.agentscope.harness.agent.gateway.channel.ChannelRuntimeContextResolver;
import io.agentscope.harness.agent.gateway.channel.OutboundAddress;
import io.agentscope.harness.agent.gateway.channel.chatui.ChatUiChannel;
import io.agentscope.harness.agent.subagent.DefaultAgentManager;
import java.util.ArrayList;
Expand Down Expand Up @@ -132,9 +133,18 @@ public String mainAgentId() {
* the {@code expose_to_user} parameter on {@code agent_spawn}.
*/
public SubagentGatewayBridge gatewayBridge() {
return (agentId, sessionId, agent, replyTo) -> {
String subagentId = gateway.exposeSubagent(agentId, sessionId, agent, replyTo);
return new SubagentGatewayBridge.ExposeResult(subagentId);
return new SubagentGatewayBridge() {
@Override
public ExposeResult expose(
String agentId, String sessionId, Agent agent, OutboundAddress replyTo) {
String subagentId = gateway.exposeSubagent(agentId, sessionId, agent, replyTo);
return new ExposeResult(subagentId);
}

@Override
public void revoke(String subagentId) {
gateway.revokeSubagent(subagentId);
}
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,15 @@ record ExposeResult(String subagentId) {}
* @return the expose result containing the subagentId handle
*/
ExposeResult expose(String agentId, String sessionId, Agent agent, OutboundAddress replyTo);

/**
* Revokes a previously exposed subagent handle.
*
* <p>The default no-op preserves compatibility with existing expose-only bridge
* implementations. Gateway-backed bridges should override this method so releasing a
* subagent also makes its public handle unaddressable.
*
* @param subagentId the user-visible handle returned by {@link #expose}
*/
default void revoke(String subagentId) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public class DynamicSubagentsMiddleware implements HarnessRuntimeMiddleware {
private final Function<SubagentDeclaration, SubagentFactory> factoryBuilder;
private final DefaultAgentManager agentManager;
private volatile Object subagentTool;
private volatile AgentSpawnTool ownedAgentSpawnTool;
private final TaskTool taskTool;
private final TaskRepository taskRepository;

Expand All @@ -94,10 +95,14 @@ public DynamicSubagentsMiddleware(
this.agentManager = agentManager;
java.util.Objects.requireNonNull(taskRepository, "taskRepository");
this.taskRepository = taskRepository;
this.subagentTool =
subagentTool != null
? subagentTool
: new AgentSpawnTool(agentManager, taskRepository, 0);
if (subagentTool != null) {
this.subagentTool = subagentTool;
this.ownedAgentSpawnTool = null;
} else {
AgentSpawnTool spawnTool = new AgentSpawnTool(agentManager, taskRepository, 0);
this.subagentTool = spawnTool;
this.ownedAgentSpawnTool = spawnTool;
}
this.taskTool = new TaskTool(taskRepository);
}

Expand All @@ -118,11 +123,27 @@ public DynamicSubagentsMiddleware setGatewayBridge(
if (this.subagentTool instanceof AgentSpawnTool ast) {
ast.setGatewayBridge(bridge);
} else {
this.subagentTool = new AgentSpawnTool(agentManager, taskRepository, 0, bridge);
AgentSpawnTool spawnTool = new AgentSpawnTool(agentManager, taskRepository, 0, bridge);
this.subagentTool = spawnTool;
this.ownedAgentSpawnTool = spawnTool;
}
return this;
}

/** Returns the internally-created spawn tool, or {@code null} for an injected tool. */
public AgentSpawnTool getOwnedAgentSpawnTool() {
return ownedAgentSpawnTool;
}

/** Closes the internally-created spawn tool without touching an injected external tool. */
public synchronized void closeOwnedAgentSpawnTool() {
AgentSpawnTool spawnTool = ownedAgentSpawnTool;
if (spawnTool != null) {
ownedAgentSpawnTool = null;
spawnTool.close();
}
}

/**
* Returns the internal {@link DefaultAgentManager} that can re-materialize subagents, or
* {@code null} when none is owned. Used to wire a gateway materializer for cross-node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public class SubagentsMiddleware implements HarnessRuntimeMiddleware {
## Subagents

You have access to subagent tools for spawning and coordinating isolated subagents.
Subagents are ephemeral — they live only for the duration of the task and return a single result.
Spawned subagents remain retained and addressable until explicitly released or until the parent agent shuts down.

### Agent Tools

Expand All @@ -112,7 +112,9 @@ public class SubagentsMiddleware implements HarnessRuntimeMiddleware {
- `message` (required): content to send
- `timeout_seconds`: 0=fire-and-forget, >0=wait for reply (default: 30)

**`%s`** — List active subagents
**`%s`** — List retained, addressable subagents

%s

### Task Tools (for async/background operations)

Expand Down Expand Up @@ -167,13 +169,15 @@ public class SubagentsMiddleware implements HarnessRuntimeMiddleware {
- **Mixed short/long work**: Wait for short prerequisite tasks first, continue reasoning with those results, and merge long-running async results later through `task_output` or `wait_async_results`
- **Sync delegation**: Use default timeout for simple one-shot delegation when one result is needed before the next reasoning step
- **Persistent session**: Spawn without a task, then use send for multi-turn interaction
%s
- **Cancel stale work**: Use task_cancel to stop background tasks that are no longer needed
- Subagent results are NOT visible to the user — always summarize them in your response
""";
// @formatter:on

private final List<SubagentEntry> baseEntries;
private volatile Object subagentTool;
private volatile AgentSpawnTool ownedAgentSpawnTool;
private final TaskTool taskTool;
private final TaskRepository taskRepository;
private final boolean isSessionMode;
Expand Down Expand Up @@ -211,7 +215,9 @@ public SubagentsMiddleware(
this.workspaceManager = workspaceManager;
java.util.Objects.requireNonNull(taskRepository, "taskRepository");
this.taskRepository = taskRepository;
this.subagentTool = new AgentSpawnTool(dam, taskRepository, 0);
AgentSpawnTool spawnTool = new AgentSpawnTool(dam, taskRepository, 0);
this.subagentTool = spawnTool;
this.ownedAgentSpawnTool = spawnTool;
this.taskTool = new TaskTool(taskRepository);
this.filesystem = filesystem;
this.mainWorkspace = mainWorkspace;
Expand Down Expand Up @@ -240,6 +246,7 @@ public SubagentsMiddleware(
this.agentManager = null;
this.workspaceManager = null;
this.subagentTool = externalSubagentTool;
this.ownedAgentSpawnTool = null;
java.util.Objects.requireNonNull(taskRepository, "taskRepository");
this.taskRepository = taskRepository;
this.taskTool = new TaskTool(taskRepository);
Expand Down Expand Up @@ -356,11 +363,27 @@ public SubagentsMiddleware setGatewayBridge(
if (this.subagentTool instanceof AgentSpawnTool ast) {
ast.setGatewayBridge(bridge);
} else {
this.subagentTool = new AgentSpawnTool(agentManager, taskRepository, 0, bridge);
AgentSpawnTool spawnTool = new AgentSpawnTool(agentManager, taskRepository, 0, bridge);
this.subagentTool = spawnTool;
this.ownedAgentSpawnTool = spawnTool;
}
return this;
}

/** Returns the internally-created spawn tool, or {@code null} in external session mode. */
public AgentSpawnTool getOwnedAgentSpawnTool() {
return ownedAgentSpawnTool;
}

/** Closes the internally-created spawn tool without touching an injected external tool. */
public synchronized void closeOwnedAgentSpawnTool() {
AgentSpawnTool spawnTool = ownedAgentSpawnTool;
if (spawnTool != null) {
ownedAgentSpawnTool = null;
spawnTool.close();
}
}

/**
* Returns the internal {@link DefaultAgentManager} that can re-materialize subagents, or
* {@code null} in session mode (external tool). Used to wire a gateway materializer for
Expand Down Expand Up @@ -655,7 +678,29 @@ public static String renderSubagentSection(List<SubagentEntry> entries, boolean
String spawnName = isSessionMode ? "sessions_spawn" : "agent_spawn";
String sendName = isSessionMode ? "sessions_send" : "agent_send";
String listName = isSessionMode ? "sessions_list" : "agent_list";
return String.format(SUBAGENT_SECTION_TEMPLATE, spawnName, sendName, listName, agentList);
String releaseSection =
isSessionMode
? ""
: """
**`agent_release`** — Release a retained subagent that is no longer needed
- `agent_key`: copy the full opaque handle returned by `agent_spawn`
- Release is rejected while the subagent has running work; wait for or cancel that work, then retry
- Success invalidates the `agent_key` and revokes any user-addressable Gateway handle
""";
String releaseUsage =
isSessionMode
? ""
: "- **Release retained agents**: Once a subagent is no longer needed and"
+ " has no running work, call `agent_release`; a successful release"
+ " permanently invalidates its `agent_key`";
return String.format(
SUBAGENT_SECTION_TEMPLATE,
spawnName,
sendName,
listName,
releaseSection,
agentList,
releaseUsage);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;

/**
* Wraps a {@link CompletableFuture} to track background subagent task execution with status,
Expand Down Expand Up @@ -149,4 +150,25 @@ public boolean cancel(boolean mayInterruptIfRunning) {
this.cancelled = true;
return future.cancel(mayInterruptIfRunning);
}

/**
* Runs {@code callback} exactly once when the underlying execution reaches any terminal state.
* If the task is already complete, the callback runs immediately on the calling thread.
*
* <p>This hook intentionally exposes no mutable future state. It is used by resource owners to
* tie an execution lease to the real task lifetime, including cancellation and exceptional
* completion.
*/
public void onCompletion(Runnable callback) {
if (callback == null) {
return;
}
AtomicBoolean invoked = new AtomicBoolean();
future.whenComplete(
(ignored, error) -> {
if (invoked.compareAndSet(false, true)) {
callback.run();
}
});
}
}
Loading
Loading