Skip to content

Multi-Agent

Agents work together in one of two ways, and each maps to a different use of sessions:

How the agents work together Use Why
Delegation: an orchestrator hands a sub-agent a task and gets a result back (agent-as-tool) A session per sub-agent The sub-agent sees only its task and its own history. It compacts on its own, sized to its own model, and only its result goes back to the parent
Handoff: several agents take turns in one conversation One shared session Every agent should see the whole conversation, so no filtering is needed

A session per sub-agent is how most agent frameworks isolate sub-agents, for example Claude Code subagents, Microsoft Agent Framework's agent-as-tool, Google ADK's AgentTool and the OpenAI Agents SDK's as_tool. It also follows the common context engineering advice to give every sub-agent the minimum context it needs.

Branches were removed in 0.10.0

Earlier versions isolated sub-agents inside one session with ADK-style SessionEvent.branch labels. That support was deprecated in 0.9.0 and removed in 0.10.0; see Upgrading to 0.10.0.


Session per sub-agent

The orchestrator exposes the sub-agent as a tool. When it is called, the tool:

  1. derives the sub-agent's session id from the orchestrator's session id;
  2. creates that session on first use, linked to the parent through metadata;
  3. calls the sub-agent's own ChatClient, which has its own SessionMemoryAdvisor and compaction settings;
  4. returns only the sub-agent's final answer to the orchestrator.
public class ResearcherTool {

    // Metadata convention linking a sub-agent session to its parent
    public static final String PARENT_SESSION_ID = "parentSessionId";

    public static final String AGENT = "agent";

    private final SessionService sessionService;

    private final ChatClient researcher;

    public ResearcherTool(SessionService sessionService, ChatClient.Builder chatClientBuilder) {
        this.sessionService = sessionService;
        // The sub-agent has its own memory advisor, with compaction sized for its own model
        this.researcher = chatClientBuilder.defaultSystem("You are a researcher. Cite every source.")
            .defaultAdvisors(SessionMemoryAdvisor.builder(sessionService)
                .compactionTrigger(new TurnCountTrigger(10))
                .compactionStrategy(SlidingWindowCompactionStrategy.builder().maxEvents(20).build())
                .build())
            .build();
    }

    @Tool(description = "Delegate a research task to the researcher agent and return its findings")
    public String research(@ToolParam(description = "The research task, with all needed context") String task,
            ToolContext toolContext) {

        String parentId = (String) toolContext.getContext().get(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY);

        // Stable id: the researcher remembers earlier tasks of this conversation.
        // Use parentId + ":researcher:" + UUID.randomUUID() for a fresh context per task.
        String childId = parentId + ":researcher";

        if (this.sessionService.findById(childId) == null) {
            Session parent = this.sessionService.findById(parentId);
            try {
                this.sessionService.create(CreateSessionRequest.builder()
                    .id(childId)
                    .userId(parent.userId())
                    .metadata(PARENT_SESSION_ID, parentId)
                    .metadata(AGENT, "researcher")
                    .build());
            }
            catch (IllegalStateException alreadyCreated) {
                // a concurrent delegation created it first
            }
        }

        // Only the task goes in and only the final answer comes back to the orchestrator
        return this.researcher.prompt()
            .user(task)
            .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, childId))
            .call()
            .content();
    }

}

The orchestrator is an ordinary ChatClient call. It passes its session id to its own advisor and, through the tool context, to the tool:

orchestrator.prompt()
    .user(question)
    .tools(researcherTool)
    .toolContext(Map.of(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId))
    .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId))
    .call()
    .content();

To find a conversation's sub-agent sessions, filter the user's sessions on the metadata key:

List<Session> children = sessionService.findByUserId(userId)
    .stream()
    .filter(s -> parentId.equals(s.metadata().get(ResearcherTool.PARENT_SESSION_ID)))
    .toList();

Stable or per-task session ids. - Stable id (parentId + ":researcher"): the sub-agent remembers its earlier tasks in the same conversation, and you can resume it later. - Per-task id (add a random suffix): every delegation starts with a fresh context. The orchestrator must then put everything the sub-agent needs into the task.

What you get: - the sub-agent's prompt holds only its task and its own history, not the orchestrator's conversation or other sub-agents' work; - compaction runs per session, with triggers and budgets sized to each agent's model; - each sub-agent has its own audit trail, searchable with Recall Storage; - parallel sub-agents never contend for the same session's optimistic-concurrency version.

Things to know: - Parent link. The link between parent and child sessions is only the metadata convention above, not a library API. Finding a parent's children means scanning findByUserId. - Lifecycle. Sub-agent sessions are not deleted with the parent. Give them the same time to live as the parent (CreateSessionRequest.builder().timeToLive(...)), or delete them yourself. - Recall. Cross-Session Recall searches every session of a user, including sub-agent sessions. - Timeline. There is no single merged timeline of a multi-agent run. Merge the sessions' events by timestamp if you need one.