mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
* 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
209 lines
7.5 KiB
TypeScript
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]);
|
|
}
|