Skip to content

Subagent toolset: SubagentManager and agent_session_* tool contracts #6

Description

@JoshuaRowePhantom

Summary

This issue implements the subagent toolset: a mechanism by which a parent agent session can create, message, monitor, and stop subordinate agent sessions (subagents) via explicit tool calls. It enables multi-agent orchestration patterns ΓÇö a parent agent dispatches work to subagents, polls for results, and coordinates across multiple concurrent subordinate sessions ΓÇö all while enforcing that subagents can never exceed the parent's trust profile.

Design doc: docs/design/subagent-toolset.md

Related: The snapshot/history model for surfacing subagent output in the chat editor and runtime browser is tracked in #34.


Current state

Already in the codebase

  • ISubAgentChatRegistry / ISubAgentTable / SubAgent.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ the existing subagent management infrastructure, both implemented by AgentChat. See Γò¼├┤Γö£ΓòóΓö¼Γò¥╬ô├╢┬╝╬ô├▓├ª2 below for details.
  • AgentChat.SubAgents ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ ReadOnlyObservableCollection<IRunningSubAgent> already exposed; RestoreSubAgentsAsync already called on session init
  • IAgentPersistenceStore.AddSubAgentLinkAsync / ReadSubAgentChildIdsAsync ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ persistence API for parent╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£├ª╬ô├╢┬úΓö£├æchild session links already defined and called
  • TrustProfileComposer.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ restrictive intersection of trust profiles already implemented
  • Trust/ITrustProfileProvider.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ trust profile resolution already available
  • ToolsetFactory.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ CreateNamedToolsetFactory / Combine pattern established; workspace-entity, current-session, filesystem, web-search kinds already wired
  • CurrentSessionContext.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ session context for self-introspection already exists
  • GUI: SubAgentBrowserViewModel, SubAgentSlotViewModel, SubAgentsContainerViewModel, RunningSubAgentDisplay, SubAgentActivityLine, RunningSubAgentsHtmlTransformer ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ subagent browser, subagent panel, and active-items cards fully implemented; "sub-agent" node kind is already reserved
  • Tests: AgentChatSubAgentRegistryTests, SubAgentTests, ISubAgentTableTests, CopilotSubAgentChatClientTests, CopilotSdkChatClientSubAgentRoutingTests, CopilotSdkChatClientSubAgentFactoryTests ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ Copilot SDK subagent path tests exist

Not yet implemented

  • AgentSessionToolsetFactory.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ does not exist
  • AgentSessionToolset.cs (the 7 agent_session_* tools) ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ does not exist
  • ToolsetFactory.CreateAgentSessionToolsetFactory ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ not yet wired
  • AgentFactory.CreateAgentChatAsync subagent wiring ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ AgentSessionToolsetFactory not yet constructed or registered
  • AgentSessionVisualizerFactory.cs ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ does not exist

Implementation scope

1. Schema

No new schema is required for this issue. Allowed agent definitions and trust profile constraints for subagents will come from the trust profile, to be designed in a future issue.


2. Existing subagent management infrastructure

The codebase already contains the subagent registry and lifecycle machinery. New code built in this issue should hook into it rather than replace it.

ISubAgentChatRegistry (implemented by AgentChat):

public interface ISubAgentChatRegistry
{
    Task<ISubAgentChat> GetOrCreateAsync(
        string agentId,
        AgentDefinition subAgentDefinition,
        string parentToolCallId,
        CancellationToken cancellationToken = default);

    ISubAgentChat? TryGet(string agentId);
}

Used by the Copilot SDK path: when CopilotSdkChatClient receives a SubagentStartedEvent, it calls GetOrCreateAsync with the Copilot-assigned AgentId and a synthesised AgentDefinition. AgentChat.GetOrCreateAsync creates a new child AgentChat backed by a SubAgentChatClient (a channel-based IChatClient). Subsequent events carrying that AgentId are routed to the SubAgentChatClient, which forwards them into the child AgentChat's turn. On SubagentCompletedEvent/SubagentFailedEvent, the registry calls Complete()/Fail() on the child client, ending its turn cleanly. AddSubAgentLinkAsync is called automatically to persist the parent→child relationship.

ISubAgentTable (implemented by AgentChat):

public interface ISubAgentTable
{
    SubAgent Add(AgentChat agentChat);
}

Used by AgentFactory for the explicit/tool-driven path: when a new AgentChat is created for a first-class subagent session, ISubAgentTable.Add registers it with the parent, adds it to AgentChat.SubAgents, and calls AddSubAgentLinkAsync. The returned SubAgent wrapper exposes the child AgentChat directly (eager path) or via AcquireLeaseAsync (lazy restoration path).

SubAgent: wraps a child AgentChat (or a lazy stub restored from persistence). Implements IRunningSubAgent so it can appear in the parent's SubAgents observable collection.

AgentChat.SubAgents: ReadOnlyObservableCollection<IRunningSubAgent> populated by both registry paths. The GUI sub-agent browser is already bound to this collection.

What AgentSessionToolset needs to build on top of this:

  • Call AgentFactory to create a new child AgentChat for each agent_session_create invocation
  • Register the result via ISubAgentTable.Add (already available as IServiceProvider service on the parent AgentChat)
  • Enumerate, query, and control sessions via the SubAgent wrappers in AgentChat.SubAgents

3. AgentSessionToolsetFactory + AgentSessionToolset

New files:

  • Phantom.Workspaces.Llm.Core/AgentSessionToolsetFactory.cs
  • Phantom.Workspaces.Llm.Core/AgentSessionToolset.cs

AgentSessionToolsetFactory implements IToolsetFactory. It is activated when tool.Kind == "agent-session".

public sealed class AgentSessionToolsetFactory : IToolsetFactory
{
    public AgentSessionToolsetFactory(
        ISubAgentTable subAgentTable,
        AgentChat parentChat,
        CurrentSessionContext currentSessionContext,
        IToolsetFactory? underlyingToolsetFactory = null) { ... }

    public Task<AIContextProvider?> CreateToolsetAsync(
        AgentSchema.Tool tool,
        AgentServices agentServices) { ... }
}

AgentSessionToolset exposes 7 tools:

Tool Description
agent_session_create Create and start a new subagent session
agent_session_list List all sessions owned by the current agent (filterable by status)
agent_session_get Get status + running items for a session
agent_session_send Inject a message into a session's input queue
agent_session_stop Interrupt a session; optionally dispose it
agent_session_read_events Read paginated event history with type/timestamp/search filters
agent_session_wait Block until session produces output or timeout elapses
agent_session_on_complete Register a push callback: when session_id becomes idle/stopped/error, enqueue a message on the parent's ImmediateQueue
agent_session_acquire Acquire a lease on an existing subagent session by session ID (for resume/query after restart)

Session ID resolution order:

  1. "." or omitted → self-introspection (caller uses CurrentSessionContext)
  2. Full session ID string (UUID) → direct lookup in parentChat.SubAgents; ownership enforced
  3. Unknown → error

agent_session_create

Parameter Type Notes
definition object optional Inline AgentDefinition (kind, model, instructions, tools). If omitted, the subagent uses the current agent's own definition — the same definition, manifest, and tool configuration the parent was started with.
initial_message string optional First user message to enqueue after creation

Returns: { "session_id": "string", "status": "running|idle|error", "created_at": "ISO 8601" }

Errors if trust profile unresolvable or host incompatible.

When definition is omitted: AgentSessionToolset reads the parent's resolved AgentDefinition from CurrentSessionContext.AgentDefinition and passes it to AgentChatFactory.CreateAsync. This allows a parent agent to spawn a peer sibling that runs the same instructions — useful for parallelisation patterns where identical agents work on separate subtasks.

agent_session_list

Parameter Type Notes
status string optional "running", "idle", "stopped", "error"

Returns array: [{ "session_id", "status", "created_at", "last_activity_at" }]

agent_session_get

Parameter Type Notes
session_id string required See session ID resolution

Returns: { "session_id", "status", "is_busy": bool, "running_items": [{ "role", "preview" }], "last_activity_at" }

agent_session_send

Parameter Type Notes
session_id string required
text string required Text to enqueue
immediacy string optional "immediate" or "queue" (default: "queue")

Calls AgentChat.EnqueueUserMessage. Returns { "ok": true } or error.

agent_session_stop

Parameter Type Notes
session_id string required
dispose bool optional If true, disposes and removes the session (default: false)

Returns { "ok": true } or error.

agent_session_read_events

Parameter Type Notes
session_id string required "." = self-introspection
after_timestamp string optional ISO 8601 cursor
event_types string[] optional "user", "assistant", "tool_call", "tool_result", "diagnostic"
search string optional Substring filter on event content
limit int optional Default 20, max 200

Returns: { "events": [{ "timestamp", "event_type", "role", "content_preview", "has_more_content" }], "total_matching": int, "next_cursor": "ISO 8601 | null" }

agent_session_wait

Parameter Type Notes
session_id string required
timeout_seconds int optional Default 30, max 300
wait_for_idle bool optional If true, returns only when is_busy becomes false

Returns: agent_session_get shape + "status": "idle|running|stopped|error|timeout" + "new_events": [...]

agent_session_on_complete

Parameter Type Notes
session_id string required Session to watch. Supports session ID resolution (full UUID or "." for self)
message string optional Message text to inject into the parent's ImmediateQueue when the session reaches a terminal state. Default: "Subagent <session_id> completed with status: <status>"

Returns: { "ok": true } on successful registration, or an error if session_id is unknown.

Behaviour:

  • When the target SubAgent's CompletionState transitions to any terminal state (Completed, Failed, Stopped), the toolset calls parentChat.EnqueueUserMessage(message, ImmediateQueue) so the parent's next turn is triggered immediately.
  • If the session is already in a terminal state when agent_session_on_complete is called, the message is enqueued immediately (no wait).
  • Multiple agent_session_on_complete registrations on the same session are allowed; each fires independently.
  • All pending registrations are cancelled and discarded when the AgentSessionToolset is disposed (i.e., when the parent session ends).

Implementation notes:

  • AgentSessionToolset holds a List<(IRunningSubAgent subagent, string message, CancellationTokenRegistration)> for active registrations.
  • Subscribe to subagent.CompletionState (or poll via IRunningSubAgent observable) to detect terminal transition.
  • Use CancellationToken from the toolset's own lifetime so registrations are cleaned up on disposal.
  • The enqueue call must be fire-and-forget (do not await inside the completion callback).

agent_session_acquire

Parameter Type Notes
session_id string required Session ID of the existing subagent session to acquire. Must be a session previously created by this agent (or a parent ancestor) and visible in agent_session_list.

Returns: same shape as agent_session_get{ "session_id", "status", "is_busy", "running_items", "last_activity_at" }.

Errors if session_id is unknown or not owned by the current agent.

Behaviour:

  • Calls AgentChatFactory.GetAsync(sessionId) to load the session from persistence if not already in memory, creating an AgentChat and incrementing the ref-count.
  • Stores the resulting RunningAgentChatLease in _leases[sessionId] so the toolset holds a reference for the session lifetime.
  • If the session is already in _leases (acquired or created earlier this session), returns the current status without acquiring a second lease.
  • After calling agent_session_acquire, all other agent_session_* tools can address the session by its session ID.

Typical use — resume after parent restart:

1. Parent restarts, calls agent_session_list to discover prior subagent sessions
2. For each relevant session: agent_session_acquire(session_id) → re-attaches
3. agent_session_get / agent_session_read_events to understand current state
4. agent_session_send if more work is needed, or agent_session_stop if done

Lease lifetime

AgentChatFactory is a ref-counted table of running AgentChat sessions (RunningAgentChatLease). Each call to CreateAsync/GetAsync/GetOrCreateAsync increments a ref-count and returns a RunningAgentChatLease. When the last lease for a session is disposed, the AgentChat is removed from RunningSessions and DisposeAsync() is called on it.

agent_session_create (lease acquisition):

// In AgentSessionToolset.AgentSessionCreateAsync:
var lease = await _agentChatFactory.CreateAsync(definition, newSessionId, services, ct);
_subAgentTable.Add(lease.AgentChat);           // registers with parent, persists link
_leases[newSessionId] = lease;                 // toolset holds the lease

The toolset stores one RunningAgentChatLease per created subagent in a Dictionary<AgentSessionId, RunningAgentChatLease> _leases. This is the toolset's ownership stake in each subagent session.

agent_session_stop(dispose: false) (interrupt only):

Calls lease.AgentChat.Interrupt(). The lease is retained ΓÇö the AgentChat stays in AgentChatFactory._entries and RunningSessions. The subagent is stopped but still accessible for agent_session_read_events.

agent_session_stop(dispose: true) (interrupt + release):

Calls lease.AgentChat.Interrupt(), then await lease.DisposeAsync(), then removes from _leases. If no other lease holders exist (e.g. no GUI tab has the subagent open), the refcount hits 0 and AgentChatFactory.ReleaseAsync disposes the AgentChat.

AgentSessionToolset.DisposeAsync() (parent session ends):

The toolset disposes all outstanding leases in _leases:

public async ValueTask DisposeAsync()
{
    // Cancel all on_complete registrations first
    _cts.Cancel();
    foreach (var (_, lease) in _leases)
        await lease.DisposeAsync();
    _leases.Clear();
}

This is the primary cleanup path ΓÇö when the parent AgentChat is disposed, it disposes the AgentSessionToolset, which releases all child leases. If a GUI tab independently holds a lease on a subagent (via AgentChatFactory.GetAsync), that tab's lease keeps the AgentChat alive until the tab is closed; the refcount model handles this correctly without any special coordination.

GUI tab lease interaction:

When a user opens a subagent session in the GUI, AgentSessionWorkspaceTabViewModel calls AgentChatFactory.GetAsync(subagentSessionId) to acquire its own lease. The refcount increments. When the tab is closed, AgentSessionWorkspaceTabViewModel.DisposeAsync() releases that lease. The parent toolset's lease and the GUI lease are fully independent ΓÇö either can be released first without affecting the other.

Lazy restore path:

SubAgent stubs restored from persistence (via RestoreSubAgentsAsync) do not hold a lease. They hold only the AgentSessionId. A lease is acquired on-demand via SubAgent.AcquireLeaseAsync() → AgentChatFactory.GetAsync(sessionId) — this loads the session from the persistence store, creates the AgentChat, and returns a lease. The caller is responsible for disposing it.
agent_session_acquire path:
agent_session_acquire calls AgentChatFactory.GetAsync(sessionId) (same as the lazy restore path) and stores the returned lease in _leases. Unlike the lazy restore path (which is triggered by the GUI), this is an explicit tool call — the agent actively opts into managing the session. Disposing the toolset will subsequently release this lease along with all others in _leases.---

4. AgentFactory wiring

In Phantom.Workspaces.Llm.Core/AgentFactory.cs (or the AgentChat.CreateAsync path), always wire AgentSessionToolsetFactory into the toolset chain ΓÇö no conditional guard:

IToolsetFactory toolsetFactory = services.ToolsetFactory ?? ToolsetFactory.CreateEmptyToolsetFactory();
toolsetFactory = new AgentSessionToolsetFactory(
    subAgentTable: agentChat,   // AgentChat implements ISubAgentTable
    parentChat: agentChat,
    currentSessionContext: currentSessionContext,
    underlyingToolsetFactory: toolsetFactory);

The "agent-session" tool kind remains opt-in ΓÇö it must appear in the agent's tools array:

{ "tools": [{ "kind": "agent-session" }] }

This follows the same pattern as "workspace-entity" and "current-session" kinds.

Alternatively, ToolsetFactory gains a static factory method:

public static IToolsetFactory CreateAgentSessionToolsetFactory(
    ISubAgentTable subAgentTable,
    AgentChat parentChat,
    CurrentSessionContext currentSessionContext,
    IToolsetFactory? underlyingToolsetFactory = null)

5. Persistence

No new schema fields are required. The parent→child session relationship is already persisted via AddSubAgentLinkAsync (called automatically by both ISubAgentChatRegistry.GetOrCreateAsync and ISubAgentTable.Add) and restored via ReadSubAgentChildIdsAsync / RestoreSubAgentsAsync on session init.

Restored child sessions appear as SubAgent stubs with AgentChat = null. They are surfaced in the sub-agents browser as "stopped" nodes. Children are not auto-restarted; the user must reopen them via the GUI Restart action.


6. GUI — runtime browser ✅ Already implemented

SubAgentBrowserViewModel, SubAgentsContainerViewModel, and SubAgentSlotViewModel are implemented and live-bound to AgentChat.SubAgents. The "sub-agent" node kind shows status indicators, a "View history" action, and Stop/Restart actions.


7. GUI — subagent panel ✅ Already implemented

When a subagent node is focused, AgentChatOutputControl is bound to the sub-agent's AgentViewModel. The definition-name breadcrumb and status badge are already surfaced via the existing slot/container pattern.


8. GUI — active items cards ✅ Already implemented

RunningSubAgentsHtmlTransformer handles rendering subagent invocation cards in the parent agent's active-items zone. Cards are bound to SubAgentActivityLine and IRunningSubAgent.CompletionState.


9. GUI ΓÇö tool visualization (AgentSessionVisualizerFactory)

New file: Phantom.Workspaces.Agent.Gui/ViewModels/AgentSessionVisualizerFactory.cs

Implements IToolVisualizerFactory. Added to the CompositeToolVisualizerFactory chain alongside WorkspaceVisualizerFactory and CopilotToolVisualizerFactory.

Tool call Visualization
agent_session_create Badge: + <session_id> with link to subagent panel
agent_session_list Compact table of sessions with status badges
agent_session_get Status card: is_busy, running-item previews
agent_session_send → <session_id>: "<text>" inline with queue info
agent_session_stop Γ£ò <session_id> stopped
agent_session_read_events Expandable event list with type-coloured rows and search highlight
agent_session_wait ⏳ waiting for <session_id> → ✔ idle after 4.2 s

Expected tests

AgentSessionToolsetTests (one per tool + edge cases)

  • AgentSessionCreate_ValidDefinition_ReturnsSessionId
  • AgentSessionCreate_WithInitialMessage_EnqueuesMessage
  • AgentSessionCreate_TrustProfileExceedsParent_Errors
  • AgentSessionList_NoFilter_ReturnsAllSessions
  • AgentSessionList_StatusFilter_ReturnsOnlyMatchingSessions
  • AgentSessionGet_DotSessionId_ReturnsSelfSession
  • AgentSessionGet_KnownSession_ReturnsStatusAndRunningItems
  • AgentSessionGet_UnknownSession_ReturnsError
  • AgentSessionSend_ValidSession_EnqueuesMessage
  • AgentSessionStop_RunningSession_CallsInterrupt
  • AgentSessionStop_WithDispose_DisposesSession
  • AgentSessionReadEvents_DotSessionId_ReturnsSelfHistory
  • AgentSessionReadEvents_AfterTimestamp_FiltersCorrectly
  • AgentSessionReadEvents_EventTypeFilter_ReturnsOnlyMatchingTypes
  • AgentSessionWait_SessionBecomesIdle_ReturnsBeforeTimeout
  • AgentSessionWait_Timeout_ReturnsTimeoutStatus
  • AgentSessionWait_WaitForIdle_True_WaitsUntilNotBusy
  • AgentSessionOnComplete_SessionCompletes_EnqueuesParentMessage
  • AgentSessionOnComplete_SessionAlreadyComplete_EnqueuesImmediately
  • AgentSessionOnComplete_CustomMessage_UsesProvidedMessage
  • AgentSessionOnComplete_DefaultMessage_IncludesSessionIdAndStatus
  • AgentSessionAcquire_ExistingSession_ReturnsStatus
  • AgentSessionAcquire_UnknownSession_ReturnsError
  • AgentSessionAcquire_AlreadyInLeases_DoesNotAcquireSecondLease
  • AgentSessionCreate_NoDefinition_UsesParentDefinition

Resolved questions

  1. Concurrency limit ΓÇö Deferred to trust profile design. The trust profile will govern maximum concurrent subagent spawning.

  2. agent_session_create trust gating ΓÇö Deferred to trust profile design.

  3. Cross-session read access ΓÇö Correct as designed. Full-UUID lookups are restricted to sessions the current agent created; a future trust-profile extension can grant cross-session read.

  4. Push notification — Resolved: agent_session_on_complete tool added (see §3 above). When called, it registers a push callback so the parent's ImmediateQueue is automatically triggered when the target session reaches a terminal state, eliminating the need to poll with agent_session_wait.

  5. Subagent-of-subagent ΓÇö Correct as designed. Arbitrary nesting is supported; trust profiles compose correctly through the hierarchy via TrustProfileComposer. No depth limit is imposed by the design.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions