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:
"." or omitted → self-introspection (caller uses CurrentSessionContext)
- Full session ID string (UUID) → direct lookup in
parentChat.SubAgents; ownership enforced
- 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
-
Concurrency limit ΓÇö Deferred to trust profile design. The trust profile will govern maximum concurrent subagent spawning.
-
agent_session_create trust gating ΓÇö Deferred to trust profile design.
-
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.
-
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.
-
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.
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.mdCurrent state
Already in the codebase
ISubAgentChatRegistry/ISubAgentTable/SubAgent.cs— the existing subagent management infrastructure, both implemented byAgentChat. See §2 below for details.AgentChat.SubAgents—ReadOnlyObservableCollection<IRunningSubAgent>already exposed;RestoreSubAgentsAsyncalready called on session initIAgentPersistenceStore.AddSubAgentLinkAsync/ReadSubAgentChildIdsAsync— persistence API for parent→child session links already defined and calledTrustProfileComposer.cs— restrictive intersection of trust profiles already implementedTrust/ITrustProfileProvider.cs— trust profile resolution already availableToolsetFactory.cs—CreateNamedToolsetFactory/Combinepattern established;workspace-entity,current-session,filesystem,web-searchkinds already wiredCurrentSessionContext.cs— session context for self-introspection already existsSubAgentBrowserViewModel,SubAgentSlotViewModel,SubAgentsContainerViewModel,RunningSubAgentDisplay,SubAgentActivityLine,RunningSubAgentsHtmlTransformer— subagent browser, subagent panel, and active-items cards fully implemented;"sub-agent"node kind is already reservedAgentChatSubAgentRegistryTests,SubAgentTests,ISubAgentTableTests,CopilotSubAgentChatClientTests,CopilotSdkChatClientSubAgentRoutingTests,CopilotSdkChatClientSubAgentFactoryTests— Copilot SDK subagent path tests existNot yet implemented
AgentSessionToolsetFactory.csΓÇö does not existAgentSessionToolset.cs(the 7agent_session_*tools) ΓÇö does not existToolsetFactory.CreateAgentSessionToolsetFactoryΓÇö not yet wiredAgentFactory.CreateAgentChatAsyncsubagent wiring ΓÇöAgentSessionToolsetFactorynot yet constructed or registeredAgentSessionVisualizerFactory.csΓÇö does not existImplementation 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 byAgentChat):Used by the Copilot SDK path: when
CopilotSdkChatClientreceives aSubagentStartedEvent, it callsGetOrCreateAsyncwith the Copilot-assignedAgentIdand a synthesisedAgentDefinition.AgentChat.GetOrCreateAsynccreates a new childAgentChatbacked by aSubAgentChatClient(a channel-basedIChatClient). Subsequent events carrying thatAgentIdare routed to theSubAgentChatClient, which forwards them into the childAgentChat's turn. OnSubagentCompletedEvent/SubagentFailedEvent, the registry callsComplete()/Fail()on the child client, ending its turn cleanly.AddSubAgentLinkAsyncis called automatically to persist the parent→child relationship.ISubAgentTable(implemented byAgentChat):Used by
AgentFactoryfor the explicit/tool-driven path: when a newAgentChatis created for a first-class subagent session,ISubAgentTable.Addregisters it with the parent, adds it toAgentChat.SubAgents, and callsAddSubAgentLinkAsync. The returnedSubAgentwrapper exposes the childAgentChatdirectly (eager path) or viaAcquireLeaseAsync(lazy restoration path).SubAgent: wraps a childAgentChat(or a lazy stub restored from persistence). ImplementsIRunningSubAgentso it can appear in the parent'sSubAgentsobservable collection.AgentChat.SubAgents:ReadOnlyObservableCollection<IRunningSubAgent>populated by both registry paths. The GUI sub-agent browser is already bound to this collection.What
AgentSessionToolsetneeds to build on top of this:AgentFactoryto create a new childAgentChatfor eachagent_session_createinvocationISubAgentTable.Add(already available asIServiceProviderservice on the parentAgentChat)SubAgentwrappers inAgentChat.SubAgents3.
AgentSessionToolsetFactory+AgentSessionToolsetNew files:
Phantom.Workspaces.Llm.Core/AgentSessionToolsetFactory.csPhantom.Workspaces.Llm.Core/AgentSessionToolset.csAgentSessionToolsetFactoryimplementsIToolsetFactory. It is activated whentool.Kind == "agent-session".AgentSessionToolsetexposes 7 tools:agent_session_createagent_session_liststatus)agent_session_getagent_session_sendagent_session_stopagent_session_read_eventsagent_session_waitagent_session_on_completesession_idbecomes idle/stopped/error, enqueue a message on the parent'sImmediateQueueagent_session_acquireSession ID resolution order:
"."or omitted ╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£├ª╬ô├╢┬úΓö£├æ self-introspection (caller usesCurrentSessionContext)parentChat.SubAgents; ownership enforcedagent_session_createdefinitionobjectoptionalAgentDefinition(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_messagestringoptionalReturns:
{ "session_id": "string", "status": "running|idle|error", "created_at": "ISO 8601" }Errors if trust profile unresolvable or host incompatible.
When
definitionis omitted:AgentSessionToolsetreads the parent's resolvedAgentDefinitionfromCurrentSessionContext.AgentDefinitionand passes it toAgentChatFactory.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_liststatusstringoptional"running","idle","stopped","error"Returns array:
[{ "session_id", "status", "created_at", "last_activity_at" }]agent_session_getsession_idstringrequiredReturns:
{ "session_id", "status", "is_busy": bool, "running_items": [{ "role", "preview" }], "last_activity_at" }agent_session_sendsession_idstringrequiredtextstringrequiredimmediacystringoptional"immediate"or"queue"(default:"queue")Calls
AgentChat.EnqueueUserMessage. Returns{ "ok": true }or error.agent_session_stopsession_idstringrequireddisposebooloptionaltrue, disposes and removes the session (default:false)Returns
{ "ok": true }or error.agent_session_read_eventssession_idstringrequired"."= self-introspectionafter_timestampstringoptionalevent_typesstring[]optional"user","assistant","tool_call","tool_result","diagnostic"searchstringoptionallimitintoptionalReturns:
{ "events": [{ "timestamp", "event_type", "role", "content_preview", "has_more_content" }], "total_matching": int, "next_cursor": "ISO 8601 | null" }agent_session_waitsession_idstringrequiredtimeout_secondsintoptionalwait_for_idlebooloptionaltrue, returns only whenis_busybecomesfalseReturns:
agent_session_getshape +"status": "idle|running|stopped|error|timeout"+"new_events": [...]agent_session_on_completesession_idstringrequired"."for self)messagestringoptionalImmediateQueuewhen the session reaches a terminal state. Default:"Subagent <session_id> completed with status: <status>"Returns:
{ "ok": true }on successful registration, or an error ifsession_idis unknown.Behaviour:
SubAgent'sCompletionStatetransitions to any terminal state (Completed,Failed,Stopped), the toolset callsparentChat.EnqueueUserMessage(message, ImmediateQueue)so the parent's next turn is triggered immediately.agent_session_on_completeis called, the message is enqueued immediately (no wait).agent_session_on_completeregistrations on the same session are allowed; each fires independently.AgentSessionToolsetis disposed (i.e., when the parent session ends).Implementation notes:
AgentSessionToolsetholds aList<(IRunningSubAgent subagent, string message, CancellationTokenRegistration)>for active registrations.subagent.CompletionState(or poll viaIRunningSubAgentobservable) to detect terminal transition.CancellationTokenfrom the toolset's own lifetime so registrations are cleaned up on disposal.agent_session_acquiresession_idstringrequiredagent_session_list.Returns: same shape as
agent_session_get—{ "session_id", "status", "is_busy", "running_items", "last_activity_at" }.Errors if
session_idis unknown or not owned by the current agent.Behaviour:
AgentChatFactory.GetAsync(sessionId)to load the session from persistence if not already in memory, creating anAgentChatand incrementing the ref-count.RunningAgentChatLeasein_leases[sessionId]so the toolset holds a reference for the session lifetime._leases(acquired or created earlier this session), returns the current status without acquiring a second lease.agent_session_acquire, all otheragent_session_*tools can address the session by its session ID.Typical use — resume after parent restart:
Lease lifetime
AgentChatFactoryis a ref-counted table of runningAgentChatsessions (RunningAgentChatLease). Each call toCreateAsync/GetAsync/GetOrCreateAsyncincrements a ref-count and returns aRunningAgentChatLease. When the last lease for a session is disposed, theAgentChatis removed fromRunningSessionsandDisposeAsync()is called on it.agent_session_create(lease acquisition):The toolset stores one
RunningAgentChatLeaseper created subagent in aDictionary<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 ΓÇö theAgentChatstays inAgentChatFactory._entriesandRunningSessions. The subagent is stopped but still accessible foragent_session_read_events.agent_session_stop(dispose: true)(interrupt + release):Calls
lease.AgentChat.Interrupt(), thenawait 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 andAgentChatFactory.ReleaseAsyncdisposes theAgentChat.AgentSessionToolset.DisposeAsync()(parent session ends):The toolset disposes all outstanding leases in
_leases:This is the primary cleanup path ΓÇö when the parent
AgentChatis disposed, it disposes theAgentSessionToolset, which releases all child leases. If a GUI tab independently holds a lease on a subagent (viaAgentChatFactory.GetAsync), that tab's lease keeps theAgentChatalive 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,
AgentSessionWorkspaceTabViewModelcallsAgentChatFactory.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:
SubAgentstubs restored from persistence (viaRestoreSubAgentsAsync) do not hold a lease. They hold only theAgentSessionId. A lease is acquired on-demand viaSubAgent.AcquireLeaseAsync()ΓåÆAgentChatFactory.GetAsync(sessionId)ΓÇö this loads the session from the persistence store, creates theAgentChat, and returns a lease. The caller is responsible for disposing it.agent_session_acquirepath:agent_session_acquirecallsAgentChatFactory.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.
AgentFactorywiringIn
Phantom.Workspaces.Llm.Core/AgentFactory.cs(or theAgentChat.CreateAsyncpath), always wireAgentSessionToolsetFactoryinto the toolset chain ΓÇö no conditional guard:The
"agent-session"tool kind remains opt-in ΓÇö it must appear in the agent'stoolsarray:{ "tools": [{ "kind": "agent-session" }] }This follows the same pattern as
"workspace-entity"and"current-session"kinds.Alternatively,
ToolsetFactorygains a static factory method:5. Persistence
No new schema fields are required. The parent→child session relationship is already persisted via
AddSubAgentLinkAsync(called automatically by bothISubAgentChatRegistry.GetOrCreateAsyncandISubAgentTable.Add) and restored viaReadSubAgentChildIdsAsync/RestoreSubAgentsAsyncon session init.Restored child sessions appear as
SubAgentstubs withAgentChat = 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, andSubAgentSlotViewModelare implemented and live-bound toAgentChat.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,
AgentChatOutputControlis bound to the sub-agent'sAgentViewModel. The definition-name breadcrumb and status badge are already surfaced via the existing slot/container pattern.8. GUI — active items cards ✅ Already implemented
RunningSubAgentsHtmlTransformerhandles rendering subagent invocation cards in the parent agent's active-items zone. Cards are bound toSubAgentActivityLineandIRunningSubAgent.CompletionState.9. GUI ΓÇö tool visualization (
AgentSessionVisualizerFactory)New file:
Phantom.Workspaces.Agent.Gui/ViewModels/AgentSessionVisualizerFactory.csImplements
IToolVisualizerFactory. Added to theCompositeToolVisualizerFactorychain alongsideWorkspaceVisualizerFactoryandCopilotToolVisualizerFactory.agent_session_create+ <session_id>with link to subagent panelagent_session_listagent_session_getis_busy, running-item previewsagent_session_send→ <session_id>: "<text>"inline with queue infoagent_session_stop✕ <session_id> stoppedagent_session_read_eventsagent_session_wait⏳ waiting for <session_id>→✔ idle after 4.2 sExpected tests
AgentSessionToolsetTests(one per tool + edge cases)AgentSessionCreate_ValidDefinition_ReturnsSessionIdAgentSessionCreate_WithInitialMessage_EnqueuesMessageAgentSessionCreate_TrustProfileExceedsParent_ErrorsAgentSessionList_NoFilter_ReturnsAllSessionsAgentSessionList_StatusFilter_ReturnsOnlyMatchingSessionsAgentSessionGet_DotSessionId_ReturnsSelfSessionAgentSessionGet_KnownSession_ReturnsStatusAndRunningItemsAgentSessionGet_UnknownSession_ReturnsErrorAgentSessionSend_ValidSession_EnqueuesMessageAgentSessionStop_RunningSession_CallsInterruptAgentSessionStop_WithDispose_DisposesSessionAgentSessionReadEvents_DotSessionId_ReturnsSelfHistoryAgentSessionReadEvents_AfterTimestamp_FiltersCorrectlyAgentSessionReadEvents_EventTypeFilter_ReturnsOnlyMatchingTypesAgentSessionWait_SessionBecomesIdle_ReturnsBeforeTimeoutAgentSessionWait_Timeout_ReturnsTimeoutStatusAgentSessionWait_WaitForIdle_True_WaitsUntilNotBusyAgentSessionOnComplete_SessionCompletes_EnqueuesParentMessageAgentSessionOnComplete_SessionAlreadyComplete_EnqueuesImmediatelyAgentSessionOnComplete_CustomMessage_UsesProvidedMessageAgentSessionOnComplete_DefaultMessage_IncludesSessionIdAndStatusAgentSessionAcquire_ExistingSession_ReturnsStatusAgentSessionAcquire_UnknownSession_ReturnsErrorAgentSessionAcquire_AlreadyInLeases_DoesNotAcquireSecondLeaseAgentSessionCreate_NoDefinition_UsesParentDefinitionResolved questions
Concurrency limit ΓÇö Deferred to trust profile design. The trust profile will govern maximum concurrent subagent spawning.
agent_session_createtrust gating ΓÇö Deferred to trust profile design.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.
Push notification ΓÇö Resolved:
agent_session_on_completetool added (see §3 above). When called, it registers a push callback so the parent'sImmediateQueueis automatically triggered when the target session reaches a terminal state, eliminating the need to poll withagent_session_wait.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.