Preserve opened subagents when parents are archived (#3279)

* fix(subagents): preserve opened children on parent archive

Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.

* fix(subagents): mark generic tab openings

Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.

* fix(subagents): classify cold tab openings

Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.

* fix(app): translate navigation fallback errors

* fix(navigation): allow offline workspace intents

* refactor(app): derive subagent ownership from open tabs

* fix(server): serialize subagent lifecycle ownership
This commit is contained in:
Mohamed Boudra
2026-08-12 21:18:55 +02:00
committed by GitHub
parent fc1cb82e02
commit 4426a6b11f
21 changed files with 696 additions and 70 deletions
+18 -5
View File
@@ -44,9 +44,21 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
Parent archive detaches a subagent instead of archiving it when either condition holds:
- The child belongs to another workspace.
- The child is currently open in an agent tab.
All other children archive with the parent. After the workspace layout hydrates, the client marks
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
generic agent metadata update. This includes background and restored tabs; navigation does not own
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
@@ -74,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
@@ -109,7 +121,7 @@ These are two distinct concepts that used to be conflated:
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
@@ -155,7 +167,7 @@ Archived Paseo subagents disappear from the track, by design. To remove one from
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape
@@ -190,10 +202,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+1 -1
View File
@@ -85,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
+1
View File
@@ -614,6 +614,7 @@ export const ar: TranslationResources = {
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
+1
View File
@@ -613,6 +613,7 @@ export const en = {
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
confirmations: {
close: "Close",
+1
View File
@@ -619,6 +619,7 @@ export const es: TranslationResources = {
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
+1
View File
@@ -619,6 +619,7 @@ export const fr: TranslationResources = {
reloadingAgent: "Agent de rechargement...",
reloadedAgent: "Agent rechargé",
failedToReloadAgent: "Échec du rechargement de l'agent",
failedToCloseAgent: "Échec de la fermeture de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
+1
View File
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
confirmations: {
unsavedTitle: "未保存の変更",
+1
View File
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
confirmations: {
close: "닫기",
+1
View File
@@ -618,6 +618,7 @@ export const ptBR: TranslationResources = {
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
+1
View File
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Перезагруженный агент",
failedToReloadAgent: "Не удалось перезагрузить агент",
failedToCloseAgent: "Не удалось закрыть агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
+1
View File
@@ -614,6 +614,7 @@ export const zhCN: TranslationResources = {
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
@@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => {
]);
expect(groups).toEqual({
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
layoutOnlyAgentTabs: [],
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
otherTabs: [
{
@@ -53,6 +54,21 @@ describe("workspace bulk close helpers", () => {
});
});
it("separates subagent tabs from agents that archive on close", () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
expect(groups).toMatchObject({
archiveAgentTabs: [{ tabId: "agent_root", agentId: "root" }],
layoutOnlyAgentTabs: [{ tabId: "agent_child", agentId: "child" }],
});
expect(buildBulkCloseConfirmationMessage(groups)).toBe(
"This will archive 1 agent(s) and close 1 tab(s).",
);
});
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
const message = buildBulkCloseConfirmationMessage(
classifyBulkClosableTabs([
@@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
logLabel: "all tabs",
});
@@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
warn,
logLabel: "others",
});
@@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => {
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]);
});
it("closes subagent tabs without sending them to closeItems", async () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
const closeItems = vi.fn(async () => ({ agents: [], terminals: [], requestId: "req-1" }));
const preparedSubagents: string[] = [];
const cleanedTabs: string[] = [];
await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (_tabId, action) => action(),
closeLayoutOnlyAgent: async (agentId) => {
preparedSubagents.push(agentId);
},
closeWorkspaceTabWithCleanup: ({ tabId }) => {
cleanedTabs.push(tabId);
},
logLabel: "others",
});
expect(closeItems).toHaveBeenCalledWith({ agentIds: ["root"], terminalIds: [] });
expect(preparedSubagents).toEqual(["child"]);
expect(cleanedTabs).toEqual(["agent_child", "agent_root"]);
});
});
@@ -3,7 +3,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import { i18n } from "@/i18n/i18next";
export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>;
archiveAgentTabs: Array<{ tabId: string; agentId: string }>;
layoutOnlyAgentTabs: Array<{ tabId: string; agentId: string }>;
terminalTabs: Array<{ tabId: string; terminalId: string }>;
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
}
@@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput {
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
closeLayoutOnlyAgent: (agentId: string) => Promise<void>;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
export function classifyBulkClosableTabs(
tabs: WorkspaceTabDescriptor[],
resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive",
): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
archiveAgentTabs: [],
layoutOnlyAgentTabs: [],
terminalTabs: [],
otherTabs: [],
};
for (const tab of tabs) {
if (tab.target.kind === "agent") {
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId };
if (resolveAgentCloseKind(tab.target.agentId) === "layout-only") {
groups.layoutOnlyAgentTabs.push(agentTab);
} else {
groups.archiveAgentTabs.push(agentTab);
}
continue;
}
if (tab.target.kind === "terminal") {
@@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage(
input: BulkClosableTabGroups,
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
): string {
const { agentTabs, terminalTabs, otherTabs } = input;
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
return labels.all({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && terminalTabs.length > 0) {
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) {
return labels.agentsAndTerminals({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
});
}
if (terminalTabs.length > 0 && otherTabs.length > 0) {
if (terminalTabs.length > 0 && tabCount > 0) {
return labels.terminalsAndTabs({
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && otherTabs.length > 0) {
if (archiveAgentTabs.length > 0 && tabCount > 0) {
return labels.agentsAndTabs({
agents: agentTabs.length,
tabs: otherTabs.length,
agents: archiveAgentTabs.length,
tabs: tabCount,
});
}
if (terminalTabs.length > 0) {
return labels.terminals({ terminals: terminalTabs.length });
}
if (otherTabs.length > 0) {
return labels.tabs({ tabs: otherTabs.length });
if (tabCount > 0) {
return labels.tabs({ tabs: tabCount });
}
return labels.agents({ agents: agentTabs.length });
return labels.agents({ agents: archiveAgentTabs.length });
}
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
const {
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup,
closeLayoutOnlyAgent,
logLabel,
warn,
} = input;
const hasDestructiveTabs = groups.archiveAgentTabs.length > 0 || groups.terminalTabs.length > 0;
for (const { tabId, agentId } of groups.layoutOnlyAgentTabs) {
await closeTab(tabId, async () => {
try {
await closeLayoutOnlyAgent(agentId);
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to close subagent tab ${logLabel}`, { error, agentId });
return;
}
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
if (hasDestructiveTabs && client) {
void client
.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
})
.catch((error) => {
@@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
});
}
for (const { tabId, agentId } of groups.agentTabs) {
void closeTab(tabId, async () => {
await Promise.all([
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
void closeTab(tabId, async () => {
}),
),
...groups.terminalTabs.map(({ tabId, terminalId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId, target } of groups.otherTabs) {
void closeTab(tabId, async () => {
}),
),
...groups.otherTabs.map(({ tabId, target }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId, target });
});
}
}),
),
]);
}
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
memo,
useCallback,
@@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
@@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
);
useOpenAgentTabLabels({
client,
serverId: normalizedServerId,
tabs: uiTabs,
enabled: hasHydratedWorkspaceLayoutStore,
});
useSyncWorkspaceActiveBrowser({
workspaceLayout,
isRouteFocused,
@@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const closePolicy = resolveCloseAgentTabPolicy(agent);
let closePolicy = resolveCloseAgentTabPolicy(agent);
const isRunning = agent?.status === "running";
if (isRunning && closePolicy.kind === "archive-on-close") {
@@ -2667,6 +2676,27 @@ function WorkspaceScreenContent({
}
}
if (closePolicy.kind === "layout-only") {
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
if (!sessionClient) {
toast.error(t("common.errors.daemonClientUnavailable"));
return;
}
try {
const clientId = await getOrCreateClientId();
await sessionClient.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
} catch (error) {
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
toast.error(t("workspace.tabs.toasts.failedToCloseAgent"));
return;
}
}
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
[
archiveAgent,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
persistenceKey,
t,
toast,
],
);
const handleClosePassiveTab = useCallback(
@@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({
return;
}
const groups = classifyBulkClosableTabs(tabsToClose);
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
const modifiedCount = tabsToClose.filter(
(tab) =>
getPanelInstanceAttributes({
@@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({
client,
groups,
closeTab,
closeLayoutOnlyAgent: async (agentId) => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const clientId = await getOrCreateClientId();
await client.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
await archiveAgent({ serverId: normalizedServerId, agentId });
}
},
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
@@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
bulkCloseConfirmationLabels,
client,
closeTab,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
function tab(agentId: string, createdAt: number): WorkspaceTab {
return { tabId: `agent_${agentId}`, target: { kind: "agent", agentId }, createdAt };
}
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
return {
id: input.id,
parentAgentId: input.parentAgentId ?? null,
labels: input.open ? { [label]: "true" } : {},
} as Agent;
}
describe("getAgentTabsNeedingOpenLabel", () => {
it("marks every present parented tab, regardless of tab order or focus", () => {
const agents = new Map([
["background-child", agent({ id: "background-child", parentAgentId: "parent" })],
["focused-child", agent({ id: "focused-child", parentAgentId: "parent" })],
["root", agent({ id: "root" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("background-child", 1), tab("root", 2), tab("focused-child", 3)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(),
}),
).toEqual(["background-child", "focused-child"]);
});
it("skips tabs already marked or currently being marked", () => {
const agents = new Map([
["marked", agent({ id: "marked", parentAgentId: "parent", open: true })],
["pending", agent({ id: "pending", parentAgentId: "parent" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("marked", 1), tab("pending", 2)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(["pending"]),
}),
).toEqual([]);
});
});
@@ -0,0 +1,25 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
export function getAgentTabsNeedingOpenLabel(input: {
tabs: WorkspaceTab[];
getAgent: (agentId: string) => Agent | null | undefined;
label: string;
pendingAgentIds: ReadonlySet<string>;
}): string[] {
const agentIds = new Set<string>();
for (const tab of input.tabs) {
if (tab.target.kind !== "agent") {
continue;
}
const agent = input.getAgent(tab.target.agentId);
if (
agent?.parentAgentId &&
agent.labels[input.label] !== "true" &&
!input.pendingAgentIds.has(agent.id)
) {
agentIds.add(agent.id);
}
}
return [...agentIds];
}
@@ -0,0 +1,86 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
}
export function useOpenAgentTabLabels(input: {
client: DaemonClient | null;
serverId: string;
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [retryVersion, setRetryVersion] = useState(0);
useEffect(
() => () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
}
},
[],
);
useEffect(() => {
const client = input.client;
if (!client || !input.enabled) {
return;
}
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
for (const agentId of pendingAgentIdsRef.current) {
if (!openAgentIds.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (openAgentIds.size === 0) {
return;
}
void (async () => {
try {
const clientId = await getOrCreateClientId();
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
for (const agentId of agentIds) {
pendingAgentIdsRef.current.add(agentId);
try {
await client.updateAgent(agentId, { labels: { [label]: "true" } });
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to mark open subagent tab", {
error,
agentId,
});
retryTimerRef.current ??= setTimeout(() => {
retryTimerRef.current = null;
setRetryVersion(increment);
}, 2_000);
} finally {
pendingAgentIdsRef.current.delete(agentId);
}
}
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}
@@ -1,7 +1,10 @@
import { describe, expect, test } from "vitest";
import {
getParentAgentIdFromLabels,
getOpenAgentTabLabel,
hasOpenAgentTab,
isDelegatedAgent,
isOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "./agent-labels.js";
@@ -18,4 +21,19 @@ describe("agent label policy", () => {
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
});
test("treats any true client-scoped open-tab label as open", () => {
const desktopLabel = getOpenAgentTabLabel("desktop-client");
const mobileLabel = getOpenAgentTabLabel("mobile-client");
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "true" })).toBe(true);
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "false" })).toBe(false);
expect(hasOpenAgentTab({})).toBe(false);
});
test("recognizes only client-scoped open-tab labels", () => {
expect(isOpenAgentTabLabel(getOpenAgentTabLabel("client-a"))).toBe(true);
expect(isOpenAgentTabLabel("paseo.open-agent-tab")).toBe(false);
expect(isOpenAgentTabLabel("custom.open-agent-tab.client-a")).toBe(false);
});
});
+15
View File
@@ -1,4 +1,13 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab.";
export function getOpenAgentTabLabel(clientId: string): string {
return `${OPEN_AGENT_TAB_LABEL_PREFIX}${clientId}`;
}
export function isOpenAgentTabLabel(label: string): boolean {
return label.startsWith(OPEN_AGENT_TAB_LABEL_PREFIX);
}
export interface AgentLabelSource {
labels?: Record<string, unknown> | null;
@@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record<string, unknown> | nul
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
return getParentAgentIdFromLabels(agent.labels) !== null;
}
export function hasOpenAgentTab(labels: Record<string, unknown> | null | undefined): boolean {
return Object.entries(labels ?? {}).some(
([label, value]) => isOpenAgentTabLabel(label) && value === "true",
);
}
@@ -15,7 +15,7 @@ import {
} from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { toAgentPayload } from "./agent-projections.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
import type { StoredAgentRecord } from "./agent-storage.js";
@@ -41,6 +41,9 @@ import type {
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
@@ -3590,7 +3593,7 @@ test("setLabels merges and persists labels", async () => {
});
});
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -3621,6 +3624,8 @@ test("detachAgent removes only the parent label from a live agent and emits stat
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
team: "infra",
},
workspaceId: undefined,
@@ -3648,7 +3653,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat
expect(emittedLabels).toContainEqual({ team: "infra" });
});
test("detachAgent removes the parent label from a stored-only agent", async () => {
test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -3679,6 +3684,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () =
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
role: "reviewer",
},
workspaceId: undefined,
@@ -6724,6 +6731,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con
expect(storedUnrelated?.archivedAt).toBeUndefined();
});
test("archiveAgent detaches an open same-workspace child instead of cascading", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Open child" },
undefined,
{
workspaceId: "workspace-a",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "true",
},
},
);
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
expect(manager.getAgent(child.id)?.id).toBe(child.id);
});
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Cross-workspace child" },
undefined,
{
workspaceId: "workspace-b",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
},
},
);
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.workspaceId).toBe("workspace-b");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
class ChildOpensAfterCascadeListStorage extends AgentStorage {
childId: string | null = null;
override async list(): Promise<StoredAgentRecord[]> {
const records = await super.list();
const parentIsArchived = records.some(
(record) => record.id !== this.childId && Boolean(record.archivedAt),
);
const staleChild = records.find((record) => record.id === this.childId);
if (parentIsArchived && staleChild) {
await super.upsert({
...staleChild,
labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
});
}
return records;
}
}
const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Late-open child" },
undefined,
{
workspaceId: "workspace-a",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
},
},
);
storage.childId = child.id;
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test("archiveAgent cannot overtake a received child open-tab update", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-"));
const markerWriteStarted = deferred<void>();
const releaseMarkerWrite = deferred<void>();
class BlockingOpenMarkerStorage extends AgentStorage {
childId: string | null = null;
override async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") {
markerWriteStarted.resolve();
await releaseMarkerWrite.promise;
}
await super.applySnapshot(agent, options);
}
}
const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Opening child" },
undefined,
{
workspaceId: "workspace-a",
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
},
);
storage.childId = child.id;
const markOpen = manager.updateAgentMetadata(child.id, {
labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
});
await markerWriteStarted.promise;
const archiveParent = manager.archiveAgent(parent.id);
releaseMarkerWrite.resolve();
await Promise.all([markOpen, archiveParent]);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test("archiveAgent cascade closes a running child runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
const storagePath = join(workdir, "agents");
@@ -7,7 +7,9 @@ import {
} from "@getpaseo/protocol/agent-lifecycle";
import {
getParentAgentIdFromLabels,
hasOpenAgentTab,
isDelegatedAgent,
isOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino";
@@ -613,6 +615,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
return null;
}
function shouldDetachFromArchivedParent(
parent: StoredAgentRecord,
child: StoredAgentRecord,
): boolean {
const isCrossWorkspace =
parent.workspaceId !== undefined &&
child.workspaceId !== undefined &&
parent.workspaceId !== child.workspaceId;
return isCrossWorkspace || hasOpenAgentTab(child.labels);
}
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
for (const label of Object.keys(labels)) {
if (isOpenAgentTabLabel(label)) {
patch[label] = null;
}
}
return patch;
}
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
@@ -631,6 +654,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
@@ -1494,6 +1518,10 @@ export class AgentManager {
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
throw new Error("Agent storage is not configured");
@@ -1527,6 +1555,10 @@ export class AgentManager {
return;
}
const records = await registry.list();
const parent = records.find((record) => record.id === parentAgentId);
if (!parent) {
throw new Error(`Archived parent ${parentAgentId} not found in storage`);
}
for (const record of records) {
if (record.archivedAt) {
continue;
@@ -1534,11 +1566,27 @@ export class AgentManager {
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
if (this.agents.has(record.id)) {
await this.archiveAgent(record.id);
} else {
await this.archiveSnapshot(record.id, new Date().toISOString());
const child = await registry.get(record.id);
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
await this.runLifecycleMutation(child.id, async () => {
const currentChild = await registry.get(child.id);
if (
!currentChild ||
currentChild.archivedAt ||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
) {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
}
});
}
}
@@ -1711,8 +1759,10 @@ export class AgentManager {
}
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
await this.runLifecycleMutation(agentId, async () => {
const agent = this.requireAgent(agentId);
await this.writeLabels(agent.id, labels);
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
@@ -1754,6 +1804,14 @@ export class AgentManager {
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
}
private async detachAgentUnlocked(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
const registry = this.requireRegistry();
const liveAgent = this.agents.get(agentId);
@@ -1768,7 +1826,7 @@ export class AgentManager {
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -1784,7 +1842,7 @@ export class AgentManager {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -1890,6 +1948,18 @@ export class AgentManager {
title?: string;
labels?: Record<string, string>;
},
): Promise<void> {
await this.runLifecycleMutation(agentId, () =>
this.updateAgentMetadataUnlocked(agentId, updates),
);
}
private async updateAgentMetadataUnlocked(
agentId: string,
updates: {
title?: string;
labels?: Record<string, string>;
},
): Promise<void> {
const liveAgent = this.getAgent(agentId);
if (liveAgent) {
@@ -1905,6 +1975,24 @@ export class AgentManager {
await this.writeStoredMetadata(agentId, updates);
}
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
// Parent cascade classifies a child inside the same lane used by open-tab
// label writes, so a received ownership update cannot be overtaken.
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(mutation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.lifecycleMutationTails.set(agentId, tail);
void tail.finally(() => {
if (this.lifecycleMutationTails.get(agentId) === tail) {
this.lifecycleMutationTails.delete(agentId);
}
});
return result;
}
async runAgent(
agentId: string,
prompt: AgentPromptInput,