fix: prevent agent proliferation when adding bots to channels (#621)

This commit is contained in:
Taylor Ho
2026-05-20 12:08:34 -07:00
committed by GitHub
parent c8f99bbc31
commit af08b0f0e8
9 changed files with 731 additions and 76 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ const overrides = new Map([
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 660], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
+48 -21
View File
@@ -854,6 +854,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1154,6 +1160,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1292,6 +1307,7 @@ dependencies = [
"block-buffer 0.12.0",
"const-oid",
"crypto-common 0.2.1",
"ctutils",
]
[[package]]
@@ -2201,6 +2217,15 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.2",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -3649,7 +3674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac",
"hmac 0.12.1",
]
[[package]]
@@ -5225,25 +5250,7 @@ dependencies = [
]
[[package]]
name = "sprout-core"
version = "0.1.0"
dependencies = [
"chrono",
"hex",
"nostr 0.36.0",
"percent-encoding",
"rand 0.10.1",
"serde",
"serde_json",
"subtle",
"thiserror 2.0.18",
"url",
"uuid",
"zeroize",
]
[[package]]
name = "sprout-desktop"
name = "sprout"
version = "0.1.0"
dependencies = [
"atomic-write-file",
@@ -5298,6 +5305,26 @@ dependencies = [
"zip 2.4.2",
]
[[package]]
name = "sprout-core"
version = "0.1.0"
dependencies = [
"chrono",
"hex",
"hmac 0.13.0",
"nostr 0.36.0",
"percent-encoding",
"rand 0.10.1",
"serde",
"serde_json",
"sha2 0.11.0",
"subtle",
"thiserror 2.0.18",
"url",
"uuid",
"zeroize",
]
[[package]]
name = "sprout-persona"
version = "0.1.0"
@@ -8085,7 +8112,7 @@ dependencies = [
"displaydoc",
"flate2",
"getrandom 0.3.4",
"hmac",
"hmac 0.12.1",
"indexmap 2.14.0",
"lzma-rs",
"memchr",
@@ -0,0 +1,386 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
commandsMatch,
parseTimestamp,
pickPreferredManagedAgent,
findReusablePersonaAgent,
findReusableGenericAgent,
findReusableAgent,
} from "./agentReuse.ts";
const PUB_A = "a".repeat(64);
const PUB_B = "b".repeat(64);
const PUB_C = "c".repeat(64);
function makeAgent(overrides = {}) {
return {
id: "agent-1",
pubkey: PUB_A,
agentCommand: "goose",
status: "running",
personaId: null,
systemPrompt: null,
updatedAt: "2026-01-15T00:00:00Z",
...overrides,
};
}
// --- commandsMatch ---
test("commandsMatch: bare names match", () => {
assert.equal(commandsMatch("goose", "goose"), true);
});
test("commandsMatch: path variants match (unix path vs bare)", () => {
assert.equal(commandsMatch("/usr/bin/goose", "goose"), true);
});
test("commandsMatch: backslash paths match", () => {
assert.equal(commandsMatch("C:\\Users\\bin\\goose", "goose"), true);
});
test("commandsMatch: case insensitive", () => {
assert.equal(commandsMatch("Goose", "GOOSE"), true);
});
test("commandsMatch: claude-code-acp normalizes to claude-acp", () => {
assert.equal(commandsMatch("claude-code-acp", "claude-acp"), true);
});
test("commandsMatch: claude-agent-acp normalizes to claude-acp", () => {
assert.equal(commandsMatch("claude-agent-acp", "claude-acp"), true);
});
test("commandsMatch: claude-code-acp matches claude-agent-acp", () => {
assert.equal(commandsMatch("claude-code-acp", "claude-agent-acp"), true);
});
test("commandsMatch: path + claude normalization combined", () => {
assert.equal(commandsMatch("/opt/bin/claude-code-acp", "claude-acp"), true);
});
test("commandsMatch: different commands do not match", () => {
assert.equal(commandsMatch("goose", "claude-acp"), false);
});
// --- parseTimestamp ---
test("parseTimestamp: valid ISO string", () => {
const result = parseTimestamp("2026-01-15T00:00:00Z");
assert.equal(result, Date.parse("2026-01-15T00:00:00Z"));
});
test("parseTimestamp: null returns 0", () => {
assert.equal(parseTimestamp(null), 0);
});
test("parseTimestamp: undefined returns 0", () => {
assert.equal(parseTimestamp(undefined), 0);
});
test("parseTimestamp: empty string returns 0", () => {
assert.equal(parseTimestamp(""), 0);
});
test("parseTimestamp: invalid string returns 0", () => {
assert.equal(parseTimestamp("not-a-date"), 0);
});
// --- pickPreferredManagedAgent ---
test("pickPreferredManagedAgent: empty array returns undefined", () => {
assert.equal(pickPreferredManagedAgent([]), undefined);
});
test("pickPreferredManagedAgent: prefers running over stopped", () => {
const running = makeAgent({
id: "r",
status: "running",
updatedAt: "2025-01-01T00:00:00Z",
});
const stopped = makeAgent({
id: "s",
status: "stopped",
updatedAt: "2026-06-01T00:00:00Z",
});
const result = pickPreferredManagedAgent([stopped, running]);
assert.equal(result.id, "r");
});
test("pickPreferredManagedAgent: deployed treated same as running", () => {
const deployed = makeAgent({
id: "d",
status: "deployed",
updatedAt: "2025-01-01T00:00:00Z",
});
const stopped = makeAgent({
id: "s",
status: "stopped",
updatedAt: "2026-06-01T00:00:00Z",
});
const result = pickPreferredManagedAgent([stopped, deployed]);
assert.equal(result.id, "d");
});
test("pickPreferredManagedAgent: among same status, picks most recently updated", () => {
const older = makeAgent({
id: "old",
status: "running",
updatedAt: "2025-01-01T00:00:00Z",
});
const newer = makeAgent({
id: "new",
status: "running",
updatedAt: "2026-06-01T00:00:00Z",
});
const result = pickPreferredManagedAgent([older, newer]);
assert.equal(result.id, "new");
});
test("pickPreferredManagedAgent: null updatedAt treated as epoch 0", () => {
const noTimestamp = makeAgent({
id: "no-ts",
status: "stopped",
updatedAt: null,
});
const withTimestamp = makeAgent({
id: "ts",
status: "stopped",
updatedAt: "2026-01-01T00:00:00Z",
});
const result = pickPreferredManagedAgent([noTimestamp, withTimestamp]);
assert.equal(result.id, "ts");
});
test("pickPreferredManagedAgent: undefined updatedAt treated as epoch 0", () => {
const noTimestamp = makeAgent({
id: "no-ts",
status: "stopped",
updatedAt: undefined,
});
const withTimestamp = makeAgent({
id: "ts",
status: "stopped",
updatedAt: "2025-06-01T00:00:00Z",
});
const result = pickPreferredManagedAgent([noTimestamp, withTimestamp]);
assert.equal(result.id, "ts");
});
// --- findReusablePersonaAgent ---
test("findReusablePersonaAgent: finds agent with matching personaId", () => {
const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A });
const channelMembers = new Set([PUB_B]);
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
assert.equal(result, agent);
});
test("findReusablePersonaAgent: excludes agent already in channel", () => {
const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A });
const channelMembers = new Set([PUB_A]);
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
assert.equal(result, undefined);
});
test("findReusablePersonaAgent: excludes agent with different personaId", () => {
const agent = makeAgent({ personaId: "persona-2", pubkey: PUB_A });
const channelMembers = new Set([PUB_B]);
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
assert.equal(result, undefined);
});
test("findReusablePersonaAgent: prefers running agent", () => {
const stopped = makeAgent({
id: "s",
personaId: "p1",
pubkey: PUB_A,
status: "stopped",
updatedAt: "2026-06-01T00:00:00Z",
});
const running = makeAgent({
id: "r",
personaId: "p1",
pubkey: PUB_B,
status: "running",
updatedAt: "2025-01-01T00:00:00Z",
});
const channelMembers = new Set([PUB_C]);
const result = findReusablePersonaAgent(
[stopped, running],
"p1",
channelMembers,
);
assert.equal(result.id, "r");
});
test("findReusablePersonaAgent: pubkey comparison is case-insensitive", () => {
const agent = makeAgent({ personaId: "p1", pubkey: PUB_A.toUpperCase() });
const channelMembers = new Set([PUB_A]);
const result = findReusablePersonaAgent([agent], "p1", channelMembers);
assert.equal(result, undefined);
});
// --- findReusableGenericAgent ---
test("findReusableGenericAgent: finds agent with matching command and no persona/prompt", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, agent);
});
test("findReusableGenericAgent: excludes agent with personaId", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: "some-persona",
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, undefined);
});
test("findReusableGenericAgent: excludes agent with non-empty systemPrompt", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: "Do stuff",
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, undefined);
});
test("findReusableGenericAgent: whitespace-only systemPrompt treated as empty (allowed)", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: " \t\n ",
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, agent);
});
test("findReusableGenericAgent: undefined systemPrompt treated as empty (allowed)", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: undefined,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, agent);
});
test("findReusableGenericAgent: empty string systemPrompt treated as empty (allowed)", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: "",
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, agent);
});
test("findReusableGenericAgent: excludes agent already in channel", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
pubkey: PUB_A,
});
const channelMembers = new Set([PUB_A]);
const result = findReusableGenericAgent([agent], "goose", channelMembers);
assert.equal(result, undefined);
});
test("findReusableGenericAgent: command matching uses normalization", () => {
const agent = makeAgent({
agentCommand: "/usr/local/bin/claude-code-acp",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableGenericAgent(
[agent],
"claude-acp",
channelMembers,
);
assert.equal(result, agent);
});
// --- findReusableAgent (unified entry point) ---
test("findReusableAgent: routes to persona search when personaId provided", () => {
const agent = makeAgent({ personaId: "p1", pubkey: PUB_A });
const channelMembers = new Set([PUB_B]);
const result = findReusableAgent([agent], channelMembers, {
personaId: "p1",
command: "goose",
});
assert.equal(result, agent);
});
test("findReusableAgent: routes to generic search when no personaId and no prompt", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableAgent([agent], channelMembers, {
command: "goose",
});
assert.equal(result, agent);
});
test("findReusableAgent: returns undefined when systemPrompt is non-empty (custom agent)", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableAgent([agent], channelMembers, {
command: "goose",
systemPrompt: "Custom instructions",
});
assert.equal(result, undefined);
});
test("findReusableAgent: whitespace-only systemPrompt in input still routes to generic", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableAgent([agent], channelMembers, {
command: "goose",
systemPrompt: " ",
});
assert.equal(result, agent);
});
test("findReusableAgent: null personaId in input routes to generic", () => {
const agent = makeAgent({
agentCommand: "goose",
personaId: null,
systemPrompt: null,
});
const channelMembers = new Set([PUB_B]);
const result = findReusableAgent([agent], channelMembers, {
personaId: null,
command: "goose",
});
assert.equal(result, agent);
});
+105
View File
@@ -0,0 +1,105 @@
import type { ManagedAgent } from "@/shared/api/types";
/** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */
function normalizePubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
function commandBasename(command: string) {
const normalized = command.trim().replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] ?? normalized;
}
function normalizeCommandIdentity(command: string) {
const lower = commandBasename(command).toLowerCase();
if (lower === "claude-code-acp" || lower === "claude-agent-acp") {
return "claude-acp";
}
return lower;
}
export function commandsMatch(left: string, right: string) {
return normalizeCommandIdentity(left) === normalizeCommandIdentity(right);
}
export function parseTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export function pickPreferredManagedAgent(agents: ManagedAgent[]) {
return [...agents].sort((left, right) => {
const leftRunningScore =
left.status === "running" || left.status === "deployed" ? 1 : 0;
const rightRunningScore =
right.status === "running" || right.status === "deployed" ? 1 : 0;
if (leftRunningScore !== rightRunningScore) {
return rightRunningScore - leftRunningScore;
}
return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
})[0];
}
export function findReusablePersonaAgent(
agents: ManagedAgent[],
personaId: string,
channelMemberPubkeys: ReadonlySet<string>,
): ManagedAgent | undefined {
const candidates = agents.filter(
(agent) =>
agent.personaId === personaId &&
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
return pickPreferredManagedAgent(candidates);
}
export function findReusableGenericAgent(
agents: ManagedAgent[],
command: string,
channelMemberPubkeys: ReadonlySet<string>,
): ManagedAgent | undefined {
const candidates = agents.filter(
(agent) =>
!agent.personaId &&
!agent.systemPrompt?.trim() &&
commandsMatch(agent.agentCommand, command) &&
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
return pickPreferredManagedAgent(candidates);
}
/**
* Check if a reusable agent exists for the given input. Used by the UI to
* surface the "reuse vs create new" guardrail before submission.
*/
export function findReusableAgent(
agents: ManagedAgent[],
channelMemberPubkeys: ReadonlySet<string>,
input: {
personaId?: string | null;
systemPrompt?: string;
command: string;
},
): ManagedAgent | undefined {
if (input.personaId) {
return findReusablePersonaAgent(
agents,
input.personaId,
channelMemberPubkeys,
);
}
if (!input.systemPrompt?.trim()) {
return findReusableGenericAgent(
agents,
input.command,
channelMemberPubkeys,
);
}
return undefined;
}
+53 -54
View File
@@ -1,3 +1,10 @@
import {
commandsMatch,
findReusableGenericAgent,
findReusablePersonaAgent,
pickPreferredManagedAgent,
} from "@/features/agents/agentReuse";
export { findReusableAgent } from "@/features/agents/agentReuse";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
addChannelMembers,
@@ -62,6 +69,8 @@ export type CreateChannelManagedAgentInput = {
respondTo?: RespondToMode;
/** Hex pubkeys for allowlist mode. */
respondToAllowlist?: string[];
/** Skip reuse logic and always create a fresh agent instance. */
forceNewInstance?: boolean;
};
export type CreateChannelManagedAgentResult =
@@ -82,33 +91,6 @@ export type CreateChannelManagedAgentsResult = {
failures: CreateChannelManagedAgentBatchFailure[];
};
function commandBasename(command: string) {
const normalized = command.trim().replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] ?? normalized;
}
function normalizeCommandIdentity(command: string) {
const lower = commandBasename(command).toLowerCase();
if (lower === "claude-code-acp" || lower === "claude-agent-acp") {
return "claude-acp";
}
return lower;
}
function commandsMatch(left: string, right: string) {
return normalizeCommandIdentity(left) === normalizeCommandIdentity(right);
}
function parseTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export async function attachManagedAgentToChannel(
channelId: string,
input: AttachManagedAgentToChannelInput,
@@ -165,33 +147,6 @@ export async function attachManagedAgentToChannel(
} satisfies AttachManagedAgentToChannelResult;
}
function pickPreferredManagedAgent(agents: ManagedAgent[]) {
return [...agents].sort((left, right) => {
const leftRunningScore =
left.status === "running" || left.status === "deployed" ? 1 : 0;
const rightRunningScore =
right.status === "running" || right.status === "deployed" ? 1 : 0;
if (leftRunningScore !== rightRunningScore) {
return rightRunningScore - leftRunningScore;
}
return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
})[0];
}
function findReusablePersonaAgent(
agents: ManagedAgent[],
personaId: string,
channelMemberPubkeys: ReadonlySet<string>,
): ManagedAgent | undefined {
const candidates = agents.filter(
(agent) =>
agent.personaId === personaId &&
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
return pickPreferredManagedAgent(candidates);
}
function buildChannelAgentName(providerId: string, providerLabel: string) {
const normalizedProviderId = providerId.trim().toLowerCase();
if (normalizedProviderId.length > 0) {
@@ -303,6 +258,7 @@ export async function createChannelManagedAgent(
// and is not already in this channel, attach it instead of creating a new one.
if (
input.personaId &&
!input.forceNewInstance &&
context?.managedAgents &&
context.channelMemberPubkeys
) {
@@ -342,6 +298,49 @@ export async function createChannelManagedAgent(
}
}
// Generic agent reuse: if no persona is set and the system prompt is blank,
// look for an existing agent with the same command and no custom prompt.
if (
!input.personaId &&
!input.systemPrompt?.trim() &&
!input.forceNewInstance &&
context?.managedAgents &&
context.channelMemberPubkeys
) {
const reusable = findReusableGenericAgent(
context.managedAgents,
input.provider.command,
context.channelMemberPubkeys,
);
if (reusable) {
const needsRespondToUpdate =
input.respondTo && input.respondTo !== "owner-only";
const updatedAgent = needsRespondToUpdate
? (
await updateManagedAgent({
pubkey: reusable.pubkey,
respondTo: input.respondTo,
respondToAllowlist:
input.respondTo === "allowlist"
? input.respondToAllowlist
: undefined,
})
).agent
: reusable;
const attached = await attachManagedAgentToChannel(channelId, {
agent: updatedAgent,
role,
ensureRunning,
});
return {
...attached,
created: false,
providerId: input.provider.id,
};
}
}
// If the avatar is a data URI (e.g. from a persona PNG card import),
// upload it to get a hosted URL the relay can serve.
let resolvedAvatarUrl = input.avatarUrl?.trim() || undefined;
+1
View File
@@ -55,6 +55,7 @@ import type {
EnsureChannelAgentPresetInput,
EnsureChannelAgentPresetResult,
} from "@/features/agents/channelAgents";
export { findReusableAgent } from "@/features/agents/agentReuse";
export type {
AttachManagedAgentToChannelInput,
AttachManagedAgentToChannelResult,
@@ -10,6 +10,8 @@ import {
import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds";
import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotReuseGuard } from "@/features/channels/ui/AddChannelBotReuseGuard";
import { useReusableAgentDetection } from "@/features/channels/ui/useReusableAgentDetection";
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
import { probeBackendProvider } from "@/shared/api/tauri";
import type {
@@ -135,6 +137,7 @@ export function AddChannelBotDialog({
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],
);
const [forceNewInstance, setForceNewInstance] = React.useState(false);
const resolvedBackendProviders = backendProviders ?? [];
const resolvedBackendProvidersLoading = backendProvidersLoading ?? false;
@@ -160,6 +163,15 @@ export function AddChannelBotDialog({
);
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
const reusableAgent = useReusableAgentDetection(
channelId,
open && channelId !== null,
selectedProvider,
selectedPersonas,
includeGeneric,
customPrompt,
);
// Surface warnings when a persona's preferred provider differs from the
// user-selected provider. In this dialog the user explicitly picks a
// runtime via the dropdown, so the fallback is `selectedProvider` (their
@@ -272,6 +284,7 @@ export function AddChannelBotDialog({
setProbeError(null);
setRespondTo("owner-only");
setRespondToAllowlist([]);
setForceNewInstance(false);
createBotsMutation.reset();
}
@@ -339,6 +352,7 @@ export function AddChannelBotDialog({
systemPrompt: customPrompt,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
},
]
@@ -358,6 +372,7 @@ export function AddChannelBotDialog({
model: persona.model ?? undefined,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
};
}),
@@ -594,6 +609,17 @@ export function AddChannelBotDialog({
/>
) : null}
{reusableAgent ? (
<div className="pt-2">
<AddChannelBotReuseGuard
disabled={createBotsMutation.isPending}
forceNew={forceNewInstance}
onForceNewChange={setForceNewInstance}
reusableAgent={reusableAgent}
/>
</div>
) : null}
{selectedCount > 0 ? (
<CreateAgentRespondToField
allowlist={respondToAllowlist}
@@ -0,0 +1,45 @@
import type { ManagedAgent } from "@/shared/api/types";
type AddChannelBotReuseGuardProps = {
reusableAgent: ManagedAgent;
forceNew: boolean;
onForceNewChange: (forceNew: boolean) => void;
disabled: boolean;
};
export function AddChannelBotReuseGuard({
reusableAgent,
forceNew,
onForceNewChange,
disabled,
}: AddChannelBotReuseGuardProps) {
const statusLabel =
reusableAgent.status === "running" || reusableAgent.status === "deployed"
? "running"
: "stopped";
return (
<div className="space-y-2" data-testid="agent-instance-mode">
<label className="text-sm font-medium" htmlFor="agent-instance-mode">
Agent instance
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={disabled}
id="agent-instance-mode"
onChange={(e) => onForceNewChange(e.target.value === "new")}
value={forceNew ? "new" : "reuse"}
>
<option value="reuse">Reuse existing agent</option>
<option value="new">Create new instance</option>
</select>
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{reusableAgent.name}
</span>{" "}
is already {statusLabel}. Reusing adds it to this channel without
creating a duplicate keypair.
</p>
</div>
);
}
@@ -0,0 +1,66 @@
import * as React from "react";
import {
findReusableAgent,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { AcpProvider, ManagedAgent } from "@/shared/api/types";
type Persona = { id: string };
/**
* Detects whether a reusable managed agent exists for the current dialog
* selection. Returns the reusable agent (if any) so the UI can show the
* "reuse vs create new" guardrail.
*/
export function useReusableAgentDetection(
channelId: string | null,
enabled: boolean,
selectedProvider: AcpProvider | null,
selectedPersonas: readonly Persona[],
includeGeneric: boolean,
customPrompt: string,
): ManagedAgent | undefined {
const managedAgentsQuery = useManagedAgentsQuery();
const channelMembersQuery = useChannelMembersQuery(channelId, enabled);
return React.useMemo(() => {
const agents = managedAgentsQuery.data;
const members = channelMembersQuery.data;
if (!agents || !members || !selectedProvider) return undefined;
const memberPubkeys = new Set(
members.map((m) => normalizePubkey(m.pubkey)),
);
// For persona selection: check the first selected persona
if (selectedPersonas.length === 1 && !includeGeneric) {
return findReusableAgent(agents, memberPubkeys, {
personaId: selectedPersonas[0].id,
command: selectedProvider.command,
});
}
// For generic agent with no custom prompt
if (
includeGeneric &&
selectedPersonas.length === 0 &&
!customPrompt.trim()
) {
return findReusableAgent(agents, memberPubkeys, {
command: selectedProvider.command,
systemPrompt: customPrompt,
});
}
return undefined;
}, [
managedAgentsQuery.data,
channelMembersQuery.data,
selectedProvider,
selectedPersonas,
includeGeneric,
customPrompt,
]);
}