fix(subagent-session-spawning): resolved subagent parent id propagation
- Forwarded `parentAgentId` through task and eval launch paths when spawning subagents. - Mapped `parentAgentId` to `parentId` in `createAgentSession`. - Passed each caller's session `getAgentId` (or `MAIN_AGENT_ID`) as the parent for spawned agents.
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@
|
||||
"enabled": true,
|
||||
"includes": ["**"],
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"a11y": "off",
|
||||
"correctness": {
|
||||
"noUnusedImports": "error",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
- Fixed pressing Esc (or Alt+Up dequeue) while agent-authored messages were queued — advisor concern/blocker notes, hidden goal/plan/budget steers, IRC/extension asides — dumping their text into the user's editor. Editor restoration (`clearQueue()`), pending chips (`getQueuedMessages()`), and `popLastQueuedMessage()` now surface only genuinely user-authored queued messages (plain user turns and `attribution: "user"` custom messages like `/skill`). Plain Alt+Up dequeue leaves all other queued messages in place for the continuing stream; only the Esc interrupt path keeps just advisor cards (so abort's preservation still re-records them as visible advice) and drops other internal steers, so a user interrupt can't be silently undone by an auto-resume on leftover internal context. `queuedMessageCount` still reflects all actual queued work (advisor cards included) so `hasPendingMessages()`/RPC and the empty-submit abort gate stay accurate.
|
||||
- Fixed advisor `concern`/`blocker` advice being withheld from the running agent and then dumped as one burst at the next user prompt after a deliberate interrupt. A user interrupt latches advisor auto-resume suppression, but a non-user resume (synthetic/auto-continue, or a queued steer draining after the abort) leaves the run streaming with that latch still set, so every interrupting note was parked hidden in the next-turn queue instead of steered into the live turn — the agent never heard the advisor mid-run and the backlog flushed all at once on the next prompt. Suppression now only withholds interrupting advice while the agent is idle (or still tearing the interrupted turn down); once a turn is streaming again the note is steered in live, since steering an active run never auto-resumes a stopped one.
|
||||
- Fixed `omp --continue`/`-c` sometimes resuming into a subagent transcript instead of the interactive session. Subagent (and HTML-export) `SessionManager.open()` calls run in the parent's terminal and were clobbering the per-TTY `--continue` breadcrumb with their own artifact-dir session file; these headless opens now suppress the breadcrumb. `continueRecent()` also recovers already-poisoned breadcrumbs by resolving any session file inside a parent's artifacts dir (`<parent>/<agentId>.jsonl`) back up to the top-level session.
|
||||
- Fixed the Agent Hub stacking duplicate `Agent Hub · N running` frames and stranding garbage rows in scrollback while navigating with subagents still streaming. The hub was a non-fullscreen overlay composited over a live transcript, so each time a running subagent's progress grew the frame and scrolled the window the previously-painted hub copy was pushed permanently into the terminal's native scrollback (which the engine can't rewrite). It now renders inline in the editor slot — the same anchored region every other selector and the `ask` tool use — riding the normal append-only commit path, so the transcript commits above it exactly once and the hub repaints in place instead of leaking copies. (Avoids borrowing the alternate screen.)
|
||||
- Fixed every subagent registering itself as its own parent in the agent registry (`parentId === id`), so the Agent Hub rendered each agent as `sub · of <itself>` and the ←← parent-navigation gesture looped on the same agent. The SDK was reusing `parentTaskPrefix` — the agent's own artifact/output-id prefix — as the registry parent link; spawns now pass a separate `parentAgentId` (the spawning agent's id: `Main` for top-level `task` spawns, the parent subagent for nested spawns and eval `agent()`, the focused agent for `/tan`) and the registry records that as the parent.
|
||||
|
||||
## [16.0.2] - 2026-06-16
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveAgentModelPatterns } from "../config/model-resolver";
|
||||
import type { LocalProtocolOptions } from "../internal-urls";
|
||||
import { MCPManager } from "../mcp/manager";
|
||||
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
|
||||
import { MAIN_AGENT_ID } from "../registry/agent-registry";
|
||||
import * as taskDiscovery from "../task/discovery";
|
||||
import * as taskExecutor from "../task/executor";
|
||||
import { AgentOutputManager } from "../task/output-manager";
|
||||
@@ -288,6 +289,7 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
||||
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
|
||||
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
|
||||
parentTelemetry: options.session.getTelemetry?.(),
|
||||
parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
||||
// Deliberately omit parentEvalSessionId: the parent's Python kernel is
|
||||
// blocked on this bridge call, so sharing the eval session would deadlock
|
||||
// (subagent queues behind the parent's in-flight execution, parent waits
|
||||
|
||||
@@ -122,6 +122,7 @@ export class TanCommandController {
|
||||
agentId: cloneId,
|
||||
agentDisplayName: "tan",
|
||||
parentTaskPrefix: cloneId,
|
||||
parentAgentId: ownerId,
|
||||
agentRegistry,
|
||||
disableExtensionDiscovery: true,
|
||||
});
|
||||
|
||||
@@ -507,6 +507,14 @@ export interface CreateAgentSessionOptions {
|
||||
agentRegistry?: AgentRegistry;
|
||||
/** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */
|
||||
parentTaskPrefix?: string;
|
||||
/**
|
||||
* Registry id of the spawning agent, recorded as this subagent's parent in
|
||||
* the agent registry. Distinct from `parentTaskPrefix`, which is this agent's
|
||||
* own artifact/output-id prefix (the executor passes the child's own id
|
||||
* there, so it must never double as the parent link). Undefined for the
|
||||
* top-level "Main" session, which has no parent.
|
||||
*/
|
||||
parentAgentId?: string;
|
||||
/** Inherited eval executor session id for subagents sharing parent eval state. */
|
||||
parentEvalSessionId?: string;
|
||||
|
||||
@@ -2338,7 +2346,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
id: resolvedAgentId,
|
||||
displayName: resolvedAgentDisplayName,
|
||||
kind: agentKind,
|
||||
parentId: options.parentTaskPrefix,
|
||||
parentId: options.parentAgentId,
|
||||
session: null,
|
||||
sessionFile: sessionManager.getSessionFile() ?? null,
|
||||
status: "running",
|
||||
|
||||
@@ -351,6 +351,12 @@ export interface ExecutorOptions {
|
||||
parentTelemetry?: AgentTelemetryConfig;
|
||||
/** Skills to autoload via sendCustomMessage before the first prompt */
|
||||
autoloadSkills?: Skill[];
|
||||
/**
|
||||
* Registry id of the spawning agent, recorded as this subagent's parent.
|
||||
* Forwarded verbatim to the SDK; the executor never derives it (the spawner
|
||||
* passes its own `getAgentId()`).
|
||||
*/
|
||||
parentAgentId?: string;
|
||||
}
|
||||
|
||||
function parseStringifiedJson(value: unknown): unknown {
|
||||
@@ -2021,6 +2027,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
parentHindsightSessionState: options.parentHindsightSessionState,
|
||||
parentMnemopiSessionState: options.parentMnemopiSessionState,
|
||||
parentTaskPrefix: id,
|
||||
parentAgentId: options.parentAgentId,
|
||||
agentId: id,
|
||||
agentDisplayName: subagentDisplayName,
|
||||
enableLsp: lspEnabled,
|
||||
|
||||
@@ -46,7 +46,7 @@ import "../tools/review";
|
||||
import type { AsyncJobManager } from "../async";
|
||||
import type { LocalProtocolOptions } from "../internal-urls";
|
||||
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
|
||||
import { AgentRegistry } from "../registry/agent-registry";
|
||||
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
||||
import { generateCommitMessage } from "../utils/commit-message-generator";
|
||||
import * as git from "../utils/git";
|
||||
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
|
||||
@@ -1300,6 +1300,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
||||
parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
|
||||
parentTelemetry: this.session.getTelemetry?.(),
|
||||
parentEvalSessionId,
|
||||
parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
||||
};
|
||||
|
||||
const runTask = async (): Promise<SingleResult> => {
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("runEvalAgent", () => {
|
||||
getSessionFile: () => null,
|
||||
mcpManager,
|
||||
localProtocolOptions,
|
||||
getAgentId: () => "BridgeParent",
|
||||
} as unknown as ToolSession;
|
||||
|
||||
await runEvalAgent({ prompt: "do work", agentType: "task" }, { session });
|
||||
@@ -60,5 +61,6 @@ describe("runEvalAgent", () => {
|
||||
const options = runSubprocessSpy.mock.calls[0]?.[0];
|
||||
expect(options?.mcpManager).toBe(mcpManager);
|
||||
expect(options?.localProtocolOptions).toBe(localProtocolOptions);
|
||||
expect(options?.parentAgentId).toBe("BridgeParent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,6 +229,34 @@ describe("TanCommandController", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("parents the tan clone to the spawning agent, not to the clone itself", async () => {
|
||||
const harness = createContext({ agentId: "FocusedParent" });
|
||||
vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager);
|
||||
const clone = {
|
||||
prompt: vi.fn(async () => {}),
|
||||
waitForIdle: vi.fn(async () => {}),
|
||||
getLastAssistantMessage: vi.fn(() => assistantText("done")),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
};
|
||||
const createAgentSessionSpy = vi
|
||||
.spyOn(sdkModule, "createAgentSession")
|
||||
.mockResolvedValue({ session: clone } as unknown as CreateAgentSessionResult);
|
||||
const controller = new TanCommandController(harness.ctx);
|
||||
await controller.start("follow the tangent");
|
||||
const capturedRun = harness.capturedRun;
|
||||
if (!capturedRun) throw new Error("run function was not captured");
|
||||
await capturedRun({ jobId: "job-1", signal: new AbortController().signal, reportProgress: async () => {} });
|
||||
|
||||
const opts = createAgentSessionSpy.mock.calls[0]?.[0];
|
||||
// The clone's registry parent is the spawning (focused) agent. Its own
|
||||
// `Tan-<id>` artifact prefix must never double as the parent link, or the
|
||||
// hub would render the tan parented to itself.
|
||||
expect(opts?.parentAgentId).toBe("FocusedParent");
|
||||
expect(opts?.parentTaskPrefix).toMatch(/^Tan-/);
|
||||
expect(opts?.parentTaskPrefix).not.toBe("FocusedParent");
|
||||
});
|
||||
|
||||
it("parks the finished tan in the registry so it stays visible in the Agent Hub", async () => {
|
||||
const harness = createContext();
|
||||
vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager);
|
||||
|
||||
@@ -132,4 +132,24 @@ describe("runSubprocess parent-discovery pass-through (issue #2190)", () => {
|
||||
expect(forwarded?.preloadedExtensionPaths).toBeUndefined();
|
||||
expect(forwarded?.preloadedCustomToolPaths).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records the spawning agent as parentAgentId, distinct from the child's own id and prefix", async () => {
|
||||
const session = yieldEmittingSession();
|
||||
const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
|
||||
|
||||
const result = await runSubprocess({
|
||||
...baseOptions,
|
||||
id: "ChildAgent",
|
||||
parentAgentId: "SpawnerAgent",
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const forwarded = spy.mock.calls[0]?.[0];
|
||||
// The registry parent is the spawning agent — never the child itself (the
|
||||
// self-parent bug). The child's own id still drives both its agent id and
|
||||
// its artifact/output-id prefix; those must not double as the parent link.
|
||||
expect(forwarded?.parentAgentId).toBe("SpawnerAgent");
|
||||
expect(forwarded?.agentId).toBe("ChildAgent");
|
||||
expect(forwarded?.parentTaskPrefix).toBe("ChildAgent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,13 +33,16 @@ const taskAgent: AgentDefinition = {
|
||||
source: "bundled",
|
||||
};
|
||||
|
||||
function createSession(options: { manager?: AsyncJobManager; settings?: Record<string, unknown> } = {}): ToolSession {
|
||||
function createSession(
|
||||
options: { manager?: AsyncJobManager; settings?: Record<string, unknown>; agentId?: string } = {},
|
||||
): ToolSession {
|
||||
return {
|
||||
cwd: "/tmp",
|
||||
hasUI: false,
|
||||
settings: Settings.isolated(options.settings ?? {}),
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
getAgentId: () => options.agentId ?? null,
|
||||
asyncJobManager: options.manager,
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
@@ -243,15 +246,20 @@ describe("task.batch spawning", () => {
|
||||
|
||||
it("spawns one background job per task item and forwards the shared context", async () => {
|
||||
mockDiscovery();
|
||||
const seen: Array<{ id?: string; context?: string; assignment?: string }> = [];
|
||||
const seen: Array<{ id?: string; context?: string; assignment?: string; parentAgentId?: string }> = [];
|
||||
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
|
||||
seen.push({ id: options.id, context: options.context, assignment: options.assignment });
|
||||
seen.push({
|
||||
id: options.id,
|
||||
context: options.context,
|
||||
assignment: options.assignment,
|
||||
parentAgentId: options.parentAgentId,
|
||||
});
|
||||
return makeResult(options.id ?? "?");
|
||||
});
|
||||
|
||||
const manager = createManager();
|
||||
const tool = await TaskTool.create(
|
||||
createSession({ manager, settings: { "async.enabled": true, "task.batch": true } }),
|
||||
createSession({ manager, agentId: "ParentA", settings: { "async.enabled": true, "task.batch": true } }),
|
||||
);
|
||||
|
||||
const result = await tool.execute("tc-batch", {
|
||||
@@ -287,6 +295,9 @@ describe("task.batch spawning", () => {
|
||||
expect(spawn.context).toBe("# Goal\nShared background.");
|
||||
}
|
||||
expect(seen.map(spawn => spawn.assignment).sort()).toEqual(["Do A.", "Do B."]);
|
||||
// Every spawn is parented to the spawning agent (not to itself): the
|
||||
// registry "of <parent>" link must be the caller, never the child's id.
|
||||
for (const spawn of seen) expect(spawn.parentAgentId).toBe("ParentA");
|
||||
});
|
||||
|
||||
it("treats a one-item batch as a single spawn and forwards context", async () => {
|
||||
|
||||
Reference in New Issue
Block a user