Files
paseo/packages/app/src/subagents/select.ts
T
Mohamed Boudra a880c9619d feat: background agents for hidden helpers (#8683)
* feat(server): add background agents

An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.

* feat(app): hide background agents from tabs, track and status

* docs(plugins): point hidden helpers at background agents

* fix: keep background agents hidden across cache reload and connected hosts

* fix: keep remote creation working on older hosts and inherit foreign parents' background
2026-11-04 07:19:21 +07:00

209 lines
7.5 KiB
TypeScript

import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { useEffect, useMemo } from "react";
import { usePendingArchiveAgentKeys } from "@/hooks/use-archive-agent";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
export interface PaseoSubagentRow {
kind: "paseo";
address: AgentAddress;
id: Agent["id"];
provider: Agent["provider"];
title: Agent["title"];
/** Managed agents have a real title, so the union's task line is always absent for them. */
description: null;
subtitle: null;
status: Agent["status"];
turn: Agent["turn"];
requiresAttention: Agent["requiresAttention"];
createdAt: Agent["createdAt"];
}
export interface ProviderSubagentRow {
kind: "provider";
id: string;
parentAgentId: string;
provider: ProviderSubagentDescriptorPayload["provider"];
// `title` is the subagent type ("Explore", "general-purpose") and repeats across a fan-out;
// `description` is the task it was given. Both are carried so presentation can choose which
// one names the row — collapsing them here is what makes every row read alike.
title: string | null;
description: string | null;
/** Compact provider-owned context. The app displays it without interpreting its contents. */
subtitle: string | null;
status: ProviderSubagentDescriptorPayload["status"];
requiresAttention: boolean;
createdAt: Date;
}
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
interface SelectSubagentsParams {
serverId: string;
parentAgentId: string;
/** Select children of this provider subagent instead of children of the managed agent. */
providerParentSubagentId?: string;
}
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
function toSubagentRow(agent: Agent): SubagentRow {
return {
kind: "paseo",
address: { serverId: agent.serverId, agentId: agent.id },
id: agent.id,
provider: agent.provider,
title: agent.title,
description: null,
subtitle: null,
status: agent.status,
turn: agent.turn,
requiresAttention: agent.requiresAttention,
createdAt: agent.createdAt,
};
}
export function selectSubagentsForParent(
state: SessionStoreSnapshot,
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): SubagentRow[] {
const rows: SubagentRow[] = [];
for (const [serverId, session] of Object.entries(state.sessions)) {
for (const agent of session.agents.values()) {
const parent = getParentAddressFromLabels(agent.labels, serverId);
if (
agent.archivedAt ||
agent.background ||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
parent?.serverId !== params.serverId ||
parent.agentId !== params.parentAgentId
)
continue;
rows.push(toSubagentRow(agent));
}
}
if (rows.length === 0) {
return EMPTY_SUBAGENT_ROWS;
}
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}
// Session-store selectors run on every store update, including every stream tick, which
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
// them when a recompute yields equal rows so the hook's equality check stays O(1).
export function createSubagentsForParentSelector(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Array<[string, Map<string, Agent>]> = [];
let previousRows: SubagentRow[] | null = null;
return (state) => {
const agents = Object.entries(state.sessions).map(
([serverId, session]) => [serverId, session.agents] as [string, Map<string, Agent>],
);
if (
previousRows &&
agents.length === previousAgents.length &&
agents.every(
([serverId, map], index) =>
previousAgents[index]?.[0] === serverId && previousAgents[index]?.[1] === map,
)
) {
return previousRows;
}
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
previousAgents = agents;
if (!previousRows || !equal(previousRows, rows)) {
previousRows = rows;
}
return previousRows;
};
}
export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
supported: boolean,
nestingSupported = false,
): ProviderSubagentRow[] {
if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
if (params.providerParentSubagentId && !nestingSupported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
const rows: ProviderSubagentRow[] = [];
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
for (const [key, subagent] of state.descriptors) {
if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue;
if (
nestingSupported &&
(subagent.parentSubagentId ?? null) !== (params.providerParentSubagentId ?? null)
) {
continue;
}
rows.push({
kind: "provider",
id: subagent.id,
parentAgentId: subagent.parentAgentId,
provider: subagent.provider,
title: subagent.title,
description: subagent.description,
subtitle: subagent.subtitle ?? null,
status: subagent.status,
requiresAttention: subagent.status === "failed",
createdAt: new Date(subagent.createdAt),
});
}
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const { serverId, parentAgentId } = params;
const pendingArchiveIds = usePendingArchiveAgentKeys();
const selectPaseoRows = useMemo(
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
[serverId, parentAgentId, pendingArchiveIds],
);
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
const nestingSupported = useSessionStore(
(state) =>
state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true,
);
const providerRows = useStoreWithEqualityFn(
useProviderSubagentStore,
(state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported),
equal,
);
const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null);
useEffect(() => {
if (!client || !supported) return;
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
() => undefined,
);
}, [client, params.parentAgentId, params.serverId, supported]);
return useMemo(() => {
if (params.providerParentSubagentId) return providerRows;
if (providerRows.length === 0) return paseoRows;
const rows = [...paseoRows, ...providerRows];
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}, [params.providerParentSubagentId, paseoRows, providerRows]);
}