Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2808,6 +2808,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
kind: "user_input",
createdAt: now,
blocking,
secret: false,
});
}
}
Expand Down Expand Up @@ -3870,6 +3871,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
]) {
assert.equal(shell?.pendingRuntimeRequest?.id, "secret:b");
assert.isTrue(shell?.pendingRuntimeRequest?.blocking);
assert.isTrue(shell?.pendingRuntimeRequest?.secret);
}

const requestId = RuntimeRequestId.make(`request:${suffix}:async`);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,7 @@ function pendingRuntimeRequestSummary(
kind: request.kind,
createdAt: request.createdAt,
blocking: request.responseCapability.type !== "message",
secret: false,
};
}

Expand All @@ -1500,6 +1501,7 @@ function secretRequestAsPendingInput(
kind: "user_input",
createdAt: item.startedAt ?? item.updatedAt,
blocking: true,
secret: true,
};
}

Expand Down
5 changes: 3 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,9 @@ On web and desktop, use **Agents** to follow work delegated to subagents.
Stop on a thread also stops the subagents it delegated to.

Subagent threads started by the agent can't take messages; message the parent
thread instead. When such a subagent needs an approval or an answer, the parent
thread asks for it.
thread instead. When such a subagent needs an approval or a secret, the parent
thread asks you for it. Its questions go to the parent agent first, and reach you
only if that agent stops without answering.

Expand a tool call in the conversation to see its full command and output.
Summaries shorten shell wrappers and can still describe the latest call after it
Expand Down
82 changes: 81 additions & 1 deletion packages/client-runtime/src/state/threadShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import { PrimaryConnectionTarget } from "../connection/model.ts";
import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
import { applyShellStreamEvent } from "./shellReducer.ts";
import { isThreadWorking } from "./threadInbox.ts";
import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts";
import {
childThreadInputsTitle,
createEnvironmentThreadShellAtoms,
pendingSubagentRequestsByThread,
} from "./threadShell.ts";

const environmentId = EnvironmentId.make("environment-v2");
const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2");
Expand Down Expand Up @@ -215,6 +219,82 @@ describe("v2 thread shell lists", () => {
registry.dispose();
});

it("routes a subagent question to its parent agent until that agent stops", () => {
const { registry, threads, snapshotAtom } = makeHarness();
const root = {
...v2ThreadShell,
latestRunId: RunId.make("root-run"),
status: "running" as const,
activityRunStatus: "running" as const,
};
const child = {
...v2ThreadShell,
id: ThreadId.make("child"),
creationSource: "mcp" as const,
status: "running" as const,
activityRunStatus: "running" as const,
lineage: {
...v2ThreadShell.lineage,
parentThreadId: root.id,
relationshipToParent: "subagent" as const,
},
pendingRuntimeRequest: {
id: RuntimeRequestId.make("question"),
kind: "user_input" as const,
createdAt: v2Now,
secret: false,
},
};
const parent = threads.threadShellAtom({ environmentId, threadId: root.id });
const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id });
const disposeParent = registry.mount(parent);
const disposeInputs = registry.mount(inputs);
const show = (...shells: ReadonlyArray<OrchestrationV2ThreadShell>) => {
registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: shells });
return {
request: registry.get(parent)?.pendingSubagentRequest,
inputs: registry.get(inputs).map((thread) => thread.id),
};
};

expect(show(root, child)).toEqual({ request: null, inputs: [] });
expect(isThreadWorking(registry.get(parent)!)).toBe(true);
const { activityRunStatus: _omitted, ...legacyRoot } = root;
expect(show(legacyRoot, child)).toEqual({ request: null, inputs: [] });
const wakeQueued = { ...root, status: "queued" as const, activityRunStatus: null };
expect(show(wakeQueued, child)).toEqual({ request: null, inputs: [] });
const stopped = { ...root, status: "completed" as const, activityRunStatus: null };
expect(show(stopped, child)).toEqual({ request: "user_input", inputs: [child.id] });
expect(pendingSubagentRequestsByThread([child]).get(root.id)).toBe("user_input");
const secret = { ...child.pendingRuntimeRequest, blocking: true, secret: true };
expect(show(root, { ...child, pendingRuntimeRequest: secret })).toEqual({
request: "user_input",
inputs: [child.id],
});
const { secret: _unmarked, ...olderServerQuestion } = child.pendingRuntimeRequest;
expect(show(root, { ...child, pendingRuntimeRequest: olderServerQuestion })).toEqual({
request: "user_input",
inputs: [child.id],
});

const grandchild = {
...child,
id: ThreadId.make("grandchild"),
lineage: { ...child.lineage, parentThreadId: child.id },
};
const busyChild = { ...child, pendingRuntimeRequest: null };
expect(show(stopped, busyChild, grandchild)).toEqual({ request: null, inputs: [] });

const approval = { ...child.pendingRuntimeRequest, kind: "command" as const };
expect(show(root, { ...child, pendingRuntimeRequest: approval })).toEqual({
request: "approval",
inputs: [child.id],
});
disposeInputs();
disposeParent();
registry.dispose();
});

it.each([
{ reason: "switched-off", state: { enabled: false } },
{
Expand Down
20 changes: 19 additions & 1 deletion packages/client-runtime/src/state/threadShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq
return kind === "user_input" ? "user_input" : "approval";
}

// The server hands a subagent's question to its parent agent, which can answer
// it, so the user sees it only once that parent stops working. Secrets and
// approvals need the user and surface at once, as do questions from older
// servers, which send no secret marker. A queue held for the user reports its
// last finished run rather than "queued", so its question escalates.
function heldForParentAgent(
child: OrchestrationV2ThreadShell,
threadsById: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell>,
): boolean {
const request = child.pendingRuntimeRequest;
if (request?.kind !== "user_input" || request.secret !== false) return false;
const parentId = child.lineage.parentThreadId;
const parent = parentId === null ? undefined : threadsById.get(parentId);
return parent !== undefined && (isChildRunLive(parent) || parent.status === "queued");
}

export function pendingSubagentRequestsByThread(
threads: ReadonlyArray<OrchestrationV2ThreadShell>,
): ReadonlyMap<ThreadId, PendingSubagentRequest> {
Expand All @@ -70,6 +86,7 @@ export function pendingSubagentRequestsByThread(
const request = subagentRequest(child);
if (request === null) continue;
byId ??= new Map(threads.map((thread) => [thread.id, thread] as const));
if (heldForParentAgent(child, byId)) continue;
const seen = new Set<ThreadId>([child.id]);
let current: OrchestrationV2ThreadShell | undefined = child;
while (current?.lineage.relationshipToParent === "subagent") {
Expand Down Expand Up @@ -294,6 +311,7 @@ export function createEnvironmentThreadShellAtoms(input: {
else children.set(parent, [thread]);
}
const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId));
const threadsById = get(environmentThreadIndexAtom(ref.environmentId));
const seen = new Set<ThreadId>([ref.threadId]);
const pending = [ref.threadId];
const next: EnvironmentThreadShell[] = [];
Expand All @@ -302,7 +320,7 @@ export function createEnvironmentThreadShellAtoms(input: {
if (seen.has(child.id)) continue;
seen.add(child.id);
pending.push(child.id);
if (subagentRequest(child) !== null) {
if (subagentRequest(child) !== null && !heldForParentAgent(child, threadsById)) {
next.push(scopedThread(ref.environmentId, child, subagentRequests));
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,11 @@ export const OrchestrationV2PendingRuntimeRequestSummary = Schema.Struct({
createdAt: Schema.DateTimeUtc,
/** False for async questions the run keeps working past. Absent from older servers. */
blocking: Schema.optional(Schema.Boolean),
/**
* True for request_secret prompts, which only the user can answer; false for runtime requests.
* Absent from older servers.
*/
secret: Schema.optional(Schema.Boolean),
});
export type OrchestrationV2PendingRuntimeRequestSummary =
typeof OrchestrationV2PendingRuntimeRequestSummary.Type;
Expand Down
Loading