Skip to content

get_current_session does not report parent chat/session for subagent (child) sessions #1308

Description

@JoshuaRowePhantom

Summary

For a sub-agent (child) chat, get_current_session returns only the child's own agent-session entity plus the host user-computer-profile / user / computer / agent-definition. It does not expose an explicit parent agent-session field and has no explicit indicator that the session is a sub-agent. An agent invoking get_current_session inside a sub-agent cannot tell (a) that it is a sub-agent nor (b) which parent chat(s) dispatched it, except by inspecting a raw untyped array (parent-agent-session-ids) embedded in the whole-blob-serialized agent_session.data.

The tool must present the child agent's information plus a resolved parent-linkage field that:

  1. Handles the fact that parent-agent-session-ids is an array — a session can have more than one parent — so the result field must be plural (parent_agent_sessions), not a singular parent_agent_session.
  2. Handles arbitrary nesting depth — a sub-agent may itself dispatch further sub-agents, forming a multi-parent DAG. Each session reports only its immediate parents; deeper ancestry is walked by the caller re-invoking get_current_session (or reading the parent entity) for each parent. Depth is thereby handled implicitly and correctly for grandchildren-and-beyond.
  3. Exposes an explicit is_subagent boolean (true iff parent-agent-session-ids is non-empty) so a nested session can detect it is a sub-agent at any depth without walking the DAG.

Root Cause

The get_current_session result shape is built in GetCurrentSessionTool.InvokeCoreAsync (Phantom.Workspaces.Llm.Core/CurrentSessionContextProvider.cs lines 104-112) as an anonymous object with fixed members agent_session, user_computer_profile, user, computer, agent_definition — there is no parent-linkage field and no sub-agent indicator.

The upstream CurrentSessionContext record (Phantom.Workspaces.Llm.Core/CurrentSessionContext.cs lines 11-27) carries AgentSessionId, UserComputerProfile, User, Computer, AgentDefinitionReference — no parent reference (singular or plural). Its shared factory CurrentSessionContextFactory.CreateForHostAsync (Phantom.Workspaces.Llm.Core/CurrentSessionContextFactory.cs lines 23-51) also has no parent parameter, so hosts that start a sub-agent session have no way to plumb the parent(s) through.

Meanwhile, parent linkage exists on the agent-session entity's data as an array field:

parent-agent-session-ids: "Entity references to the parent agent sessions of this session. Set on sub-agent sessions to reference the dispatcher's own agent-session entity, so sub-agents are excluded from the top-level sessions view. When absent or empty, the session is a top-level session."Phantom.Workspaces.Data.Core/JsonSchemas/agent-session.json lines 37-46.

The schema declares it as "type": "array" of entity-reference items — the plurality (-ids) is intentional; a session may reference more than one parent.

The sub-agent dispatcher writes it when creating the child session entity:

["parent-agent-session-ids"] = new[] { (dispatcherEntityId ?? new EntityId(Guid.Empty)).ToString() },

(Phantom.Workspaces.Llm.Core/SubAgentDispatcherChatClient.cs line 598).

Findings on multi-parent in practice. Today the dispatcher always writes exactly one element (the dispatching session's entity id) at line 598, and there is no code path that adds or merges additional parents into an existing session's parent-agent-session-ids. Multi-parent is therefore schema-supported but not currently produced by production code paths. The tool result must still model plural parents so that (a) it does not need to change shape if/when a multi-parent producer is added, (b) consumers write correct code today, and (c) it faithfully mirrors the schema. sessions-view.json (lines ~98-104) also treats the field as an array, but currently only reads index 0 as an existence check — that is an unrelated simplification and is not evidence that only one parent is ever valid.

Findings on deep nesting. Nothing in the current code special-cases grandchildren. Because the dispatcher writes the parent(s) relative to the current dispatching session — whatever depth that is — a sub-agent that itself dispatches a sub-agent will naturally produce a grandchild whose parent-agent-session-ids references the middle sub-agent's entity. There is no accumulation of transitive ancestors on a single entity; ancestry is stored one level at a time and must be walked one level at a time.

Because GetCurrentSessionTool serializes the entire entity.Data as agent_session.data (CurrentSessionContextProvider.cs lines 244-259), the raw parent ids appear inside the child's own data blob — but they are never resolved to parent entities, never hoisted to a top-level field, and there is no is_subagent flag.

Affected Files

File Lines Role
Phantom.Workspaces.Llm.Core/CurrentSessionContextProvider.cs 104-112, 115-151, 244-259 Builds the tool result; needs a plural parent_agent_sessions array + is_subagent boolean resolved from the agent-session entity.
Phantom.Workspaces.Llm.Core/CurrentSessionContext.cs 11-27 Add an optional collection (IReadOnlyList<...>) of host-supplied parent agent-session ids so hosts can plumb them directly when starting a sub-agent.
Phantom.Workspaces.Llm.Core/CurrentSessionContextFactory.cs 23-51 Accept and forward the parent agent-session id collection when building a context for a sub-agent host.
Phantom.Workspaces.Data.Core/JsonSchemas/agent-session.json 37-46 Existing source-of-truth for parent linkage (parent-agent-session-ids, ARRAY) — read by the tool.
Phantom.Workspaces.Llm.Core/SubAgentDispatcherChatClient.cs 590-599 Existing site that writes parent-agent-session-ids; also the place that should populate the new CurrentSessionContext parents collection when starting the sub-agent's runtime. Nested sub-agents inherit the same code path, so grandchildren-and-beyond work without special casing.

Design / Fix

Result shape

get_current_session returns an object of fixed shape with plural parent-linkage members that are always present:

{
  "agent_session":         {  child agent-session entity  },
  "is_subagent":           true,                       // false iff parent_agent_sessions is empty
  "parent_agent_sessions": [ {  parent agent-session entity  },  ], // empty [] for a root session
  "user_computer_profile": ,
  "user":                  ,
  "computer":              ,
  "agent_definition":      
}
  • parent_agent_sessions is always an array (never null, never omitted). Empty array ([]) means "top-level session". A single-parent sub-agent has a one-element array; a (currently theoretical) multi-parent sub-agent has multiple elements. Each element has the same shape as agent_session, produced by the existing ToSerializableEntity.
  • is_subagent is true iff parent_agent_sessions.length > 0. It is provided as a convenience so agents at arbitrary nesting depth can branch on "am I a sub-agent?" without inspecting the array.
  • Unresolvable parent references (entity id present in parent-agent-session-ids but no matching entity in the store) are skipped from parent_agent_sessions. The raw ids remain visible under agent_session.data.parent-agent-session-ids for debugging, and is_subagent still reflects the raw array's non-emptiness (so a stale reference does not silently make a sub-agent look like a root).

Immediate-parents-only vs full-ancestry

Decision: report only immediate parents.

Justification:

  1. Structure is a DAG, not a tree. Because parent-agent-session-ids is plural, transitive ancestry is a DAG whose in-lined serialization has no obvious canonical order and can (in principle) contain the same ancestor via multiple paths. Inlining it in one call encodes redundant/ambiguous data.
  2. Depth is unbounded. A sub-agent may itself dispatch sub-agents; inlining all ancestors would make the tool result size dependent on dispatch depth, and would require the tool to perform an arbitrary number of IDataAccessLayer reads.
  3. Grandchildren are handled implicitly. Because each entity stores its own immediate parents relative to the dispatching session (see SubAgentDispatcherChatClient.cs:598), a grandchild session naturally reports its own immediate parent (the middle sub-agent). Callers that need the full chain can walk it by calling get_current_session — or reading the parent agent-session entity directly — one level at a time. is_subagent at each level answers "is this level nested?" without a walk.
  4. Simple, stable contract. Consumers get a fixed shape (parent_agent_sessions: EntitySnapshot[]) that does not change with depth and does not require them to interpret a nested tree/DAG structure.

Callers that specifically want the full ancestor chain can walk upward themselves: for each id in agent_session.data.parent-agent-session-ids, look up that entity via the data-access layer and inspect its parent-agent-session-ids, terminating when a session has an empty/absent array.

Two complementary sources for the parents (both plural)

Both feed a single ResolveParentAgentSessionsAsync helper in GetCurrentSessionTool so the returned shape is uniform.

  1. Entity-derived (primary, works for resumed sessions too).
    After ResolveAgentSessionAsync returns the child's EntitySnapshot, read the entire parent-agent-session-ids array from entity.Data (mirroring how TryReadEntityNameReference already reads references at lines 182-203, but iterating the array). For each element, resolve the parent agent-session entity via IDataAccessLayer — either by entity-id (when the stored value is a uuid, as written at SubAgentDispatcherChatClient.cs:598) using a GetRequest, or by agent-session-id == … query the same way ResolveAgentSessionAsync (lines 122-146) already resolves the current session. Serialize each resolved entity with ToSerializableEntity. Preserve the schema order of parent-agent-session-ids.

  2. Host-supplied (secondary, authoritative when non-empty).
    Add an optional ParentAgentSessionEntityIds (or ParentAgentSessionIds) collection property to CurrentSessionContext and a matching parameter on CurrentSessionContextFactory.CreateForHostAsync. Populate it from the sub-agent creation path in SubAgentDispatcherChatClient (around line 598, where dispatcherEntityId is already known, and where — for future multi-parent support — additional parent ids could be threaded in) when the host starts the sub-agent's runtime. When the host-supplied collection is non-empty, GetCurrentSessionTool uses it in place of the entity-derived list.

Sketch (parents resolution):

private async Task<IReadOnlyList<EntitySnapshot>> ResolveParentAgentSessionsAsync(
    EntitySnapshot? childAgentSession,
    CancellationToken cancellationToken)
{
    IReadOnlyList<string> parentIds =
        this.currentSessionContext.ParentAgentSessionEntityIds is { Count: > 0 } hostSupplied
            ? hostSupplied
            : ReadParentAgentSessionIds(childAgentSession); // reads the array from entity.Data

    if (parentIds.Count == 0)
    {
        return Array.Empty<EntitySnapshot>();
    }

    var resolved = new List<EntitySnapshot>(parentIds.Count);
    foreach (var id in parentIds)
    {
        var parent = await ResolveAgentSessionByEntityIdAsync(id, cancellationToken);
        if (parent is not null)
        {
            resolved.Add(parent);
        }
    }
    return resolved;
}

is_subagent is derived from the raw parent-id array (so it stays truthful even when one or more references cannot be resolved):

var rawParentIds = ReadParentAgentSessionIds(agentSession);
var parentAgentSessions = await ResolveParentAgentSessionsAsync(agentSession, cancellationToken);
var isSubagent = rawParentIds.Count > 0
                 || (this.currentSessionContext.ParentAgentSessionEntityIds?.Count ?? 0) > 0;

return JsonSerializer.SerializeToElement(new
{
    agent_session         = ToSerializableEntity(agentSession),
    is_subagent           = isSubagent,
    parent_agent_sessions = parentAgentSessions.Select(ToSerializableEntity).ToArray(),
    user_computer_profile = ToSerializableEntity(userComputerProfile),
    user                  = ToSerializableEntity(user),
    computer              = ToSerializableEntity(computer),
    agent_definition      = ToSerializableEntity(agentDefinition),
});

CurrentSessionContext gains:

/// <summary>
/// Optional host-supplied parent agent-session entity ids. When non-empty, overrides the
/// child entity's <c>parent-agent-session-ids</c> as the source for the tool's
/// <c>parent_agent_sessions</c> array. Plural because the schema permits a session to
/// reference more than one parent, and to keep the shape stable as multi-parent
/// dispatch scenarios are added.
/// </summary>
public IReadOnlyList<string>? ParentAgentSessionEntityIds { get; init; }

Update the tool Description (CurrentSessionContextProvider.cs line 82-83) to document:

  • parent_agent_sessions is an array of the session's immediate parent agent-sessions; empty for a top-level session.
  • is_subagent is true when the session was dispatched by one or more parent sessions.
  • Consumers that need the full ancestor chain walk upward one level at a time.

Considered / Background

  • Singular parent_agent_session field. Rejected because parent-agent-session-ids is schema-declared plural; a singular field would misrepresent the data model and would need a breaking rename the moment multi-parent dispatch is produced. Even though today's dispatcher writes exactly one element, modelling it as an array today is essentially free and future-proof.
  • Inlining the full ancestor chain / DAG. Rejected — see "Immediate-parents-only vs full-ancestry" above. Depth is unbounded and the structure is a DAG; each session reporting its immediate parents keeps the contract stable and delegates traversal to the caller.
  • Emitting parent_agent_sessions as null for root sessions. Rejected — an empty array is a clearer "no parents" signal than null, keeps the JSON type of parent_agent_sessions stable (always an array), and matches the schema where absent/empty means top-level. is_subagent remains the authoritative "am I nested?" flag.

Expected Tests

Add to Phantom.Workspaces.Llm.Core.Tests/CurrentSessionContextProviderTests.cs, matching the existing [Fact] public async Task <Subject>_<Scenario>_<ExpectedOutcome> PascalCase style and the InMemoryDataAccessLayer seeding helpers already in the file (SeedAgentSessionAsync, SeedEntityAsync, InvokeAsync).

Test Scenario Expected
GetCurrentSession_RootSession_ReportsEmptyParentsAndIsSubagentFalse Seed a top-level agent-session with no parent-agent-session-ids. parent_agent_sessions is a JSON array with length 0; is_subagent is JSON false.
GetCurrentSession_SingleParentSubagent_ResolvesParentAgentSession Seed a parent agent-session entity + a child agent-session whose data.parent-agent-session-ids references the parent's entity-id. parent_agent_sessions has length 1; element 0's data.agent-session-id matches the parent; is_subagent is JSON true; agent_session.data.agent-session-id still matches the child.
GetCurrentSession_MultiParentSubagent_ReportsAllParentsInOrder Seed two parent agent-session entities and a child whose data.parent-agent-session-ids array references both, in a specific order. parent_agent_sessions has length 2 and preserves the order of parent-agent-session-ids; is_subagent is true.
GetCurrentSession_ParentAgentSessionShapeMatchesAgentSession Same seed as the single-parent case. Each parent_agent_sessions element exposes the same members as agent_session (entityId, concurrencyTag, modifiedTime, changeId, data), produced by ToSerializableEntity.
GetCurrentSession_GrandchildSession_ReportsImmediateParentAndIsSubagentTrue Seed a root A, a sub-agent B whose parent is A, and a grandchild C whose parent-agent-session-ids references B (not A). Invoke as C. parent_agent_sessions has length 1 and resolves to B (not A); is_subagent is true; the full A→B→C chain is not inlined.
GetCurrentSession_ContextParentIdsOverrideEntityData Seed a child whose parent-agent-session-ids points at parent A, but populate CurrentSessionContext.ParentAgentSessionEntityIds with parent B (both seeded). parent_agent_sessions resolves to B (host-supplied wins over entity-derived); is_subagent is true.
GetCurrentSession_ContextParentIdsMultiple_ResolvedInOrder Populate CurrentSessionContext.ParentAgentSessionEntityIds with [B, A] (both seeded); child entity has no parent-agent-session-ids. parent_agent_sessions has length 2, order [B, A]; is_subagent is true.
GetCurrentSession_ParentReferenceUnresolvable_SkippedFromParentsButIsSubagentTrue Seed a child whose parent-agent-session-ids points at a non-existent entity-id. parent_agent_sessions is an empty array (unresolved refs skipped); is_subagent is JSON true (raw array was non-empty).
GetCurrentSession_UnknownSessionId_ParentsEmptyAndIsSubagentFalse Extension of the existing GetCurrentSession_UnknownSessionId_AgentSessionNull test — the current session id resolves to nothing. agent_session is JSON null; parent_agent_sessions is []; is_subagent is JSON false.
GetCurrentSession_IncludeProfileFalse_StillIncludesParentAgentSessions include_profile=false on a sub-agent session. parent_agent_sessions and is_subagent are still present and populated (parent identity is orthogonal to the profile flag).

If the new host-supplied source is added to CurrentSessionContext, also add a factory-level test in CurrentSessionContextFactoryTests.cs verifying that CreateForHostAsync propagates the ParentAgentSessionEntityIds argument onto the returned context (including that a null-vs-empty distinction is preserved: null means "fall back to the entity", [] means "explicitly no parents").

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identified

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions