Skip to content
Open
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
664 changes: 664 additions & 0 deletions desktop/src-tauri/src/commands/agent_registration.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod agent_model_process;
mod agent_models;
mod agent_models_env;
mod agent_providers;
mod agent_registration;
mod agent_settings;
mod agent_update_rollback;
mod agents;
Expand Down Expand Up @@ -82,6 +83,7 @@ pub use agent_logs::*;
pub use agent_metric_archive::*;
pub use agent_models::*;
pub use agent_providers::*;
pub use agent_registration::*;
pub use agent_settings::*;
pub use agents::*;
pub use canvas::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ pub fn run() {
resolve_oa_owner,
list_relay_agents,
revalidate_relay_agents,
register_existing_agent,
list_managed_agents,
list_managed_agent_runtimes,
start_managed_agent_runtime,
Expand Down
23 changes: 17 additions & 6 deletions desktop/src-tauri/src/managed_agents/agent_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,27 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont
/// Returns an unsigned `EventBuilder` — the caller signs and submits. The
/// `d_tag` is the agent's pubkey.
pub fn build_agent_event(record: &ManagedAgentRecord) -> Result<EventBuilder, String> {
build_agent_event_from_content(&record.pubkey, &agent_event_content(record))
}

/// Build a kind:30177 event from an already-sanitized public projection.
///
/// This is also used when an owner registers an independently operated agent:
/// Desktop knows the public identity and access policy, but intentionally does
/// not possess or create a local [`ManagedAgentRecord`] or the agent's key.
pub fn build_agent_event_from_content(
agent_pubkey: &str,
content: &ManagedAgentEventContent,
) -> Result<EventBuilder, String> {
super::validate_managed_agent_definition_text(
&record.name,
record.persona_id.as_deref(),
record.system_prompt.as_deref(),
&content.name,
content.persona_id.as_deref(),
content.system_prompt.as_deref(),
)
.map_err(|error| format!("Managed agent definition is unsafe to publish: {error}"))?;
let content = serde_json::to_string(&agent_event_content(record))
let content = serde_json::to_string(content)
.map_err(|e| format!("failed to serialize managed-agent content: {e}"))?;
let tags =
vec![Tag::parse(["d", record.pubkey.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
let tags = vec![Tag::parse(["d", agent_pubkey]).map_err(|e| format!("invalid d-tag: {e}"))?];
Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content).tags(tags))
}

Expand Down
15 changes: 14 additions & 1 deletion desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,20 @@ with a TypeScript lookup table or an id comparison in a component.

17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label.

18. **Registering an existing agent never takes custody of its identity.** The
registration path accepts only an existing pubkey whose live kind:0 profile
contains a valid NIP-OA attestation to the current owner. It publishes an
owner-signed kind:30177 directory record with the explicitly selected
`respond_to` policy and allowlist, and does not generate, import, replace,
persist, or request the agent's private key. Allowlist mode does not
implicitly admit the owner: the UI must tell the owner to include their own
pubkey when they want discovery, and the published allowlist must match the
requested normalized values exactly. This directory policy controls Desktop
discovery only; an independently operated agent's runtime gate remains
external and must be configured to admit the same audience. Registration
also must not create a secretless local `ManagedAgentRecord`: independently
operated agents remain relay agents, not startable Desktop runtimes.

## Channel-only runtime controls

Desktop observer controls identify a channel, not a thread session. The harness
Expand All @@ -307,7 +321,6 @@ the CLI with the channel and target thread root:
buzz messages send --channel <channel-id> --reply-to <thread-root-id> \
--mention <agent-pubkey> --content '!cancel'
```

## The tests that enforce this

- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ import {
setPersonaActive,
updatePersona,
} from "@/shared/api/tauriPersonas";
import {
registerExistingAgent,
type RegisterExistingAgentInput,
} from "@/shared/api/tauriAgentRegistration";
import { teamsQueryKey } from "@/features/agents/teamHooks";
import type {
AcpRuntime,
Expand Down Expand Up @@ -515,6 +519,18 @@ export function useCreatePersonaMutation() {
});
}

export function useRegisterExistingAgentMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (input: RegisterExistingAgentInput) =>
registerExistingAgent(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
},
});
}

export function useUpdatePersonaMutation() {
const queryClient = useQueryClient();

Expand Down
11 changes: 10 additions & 1 deletion desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import { useManagedAgentActions } from "./useManagedAgentActions";
import { usePersonaActions } from "./usePersonaActions";
import { useTeamActions } from "./useTeamActions";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { useBakedBuildEnvQuery } from "@/features/agents/hooks";
import {
useBakedBuildEnvQuery,
useRegisterExistingAgentMutation,
} from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { Button } from "@/shared/ui/button";
Expand All @@ -44,6 +47,7 @@ export function AgentsView() {
const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv);
const agents = useManagedAgentActions();
const personas = usePersonaActions();
const registerExistingAgentMutation = useRegisterExistingAgentMutation();
const teamImportInputRef = React.useRef<HTMLInputElement | null>(null);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const fullAiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -94,6 +98,7 @@ export function AgentsView() {
const isActionPending =
agents.isPending ||
personas.isPending ||
registerExistingAgentMutation.isPending ||
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending;
Expand Down Expand Up @@ -510,12 +515,16 @@ export function AgentsView() {
? personas.personaNoticeMessage
: null
}
isRegistrationPending={registerExistingAgentMutation.isPending}
onClearFeedback={() => {
personas.clearFeedback("catalog");
}}
onImportFile={(fileBytes, fileName) => {
void personas.handleImportSnapshotFile(fileBytes, fileName);
}}
onRegisterExistingAgent={(input) =>
registerExistingAgentMutation.mutateAsync(input)
}
onSelectPersona={async (persona, active) => {
const addedPersona = await personas.handleSetActive(
persona,
Expand Down
39 changes: 32 additions & 7 deletions desktop/src/features/agents/ui/CommunityCatalogDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { ChevronDown, Plus, Upload } from "lucide-react";
import { ChevronDown, Link2, Plus, Upload } from "lucide-react";

import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription";
Expand All @@ -8,6 +8,10 @@ import type { CatalogTeam } from "@/features/agents/lib/teamCatalogRelay";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import type {
ExistingAgentRegistrationResult,
RegisterExistingAgentInput,
} from "@/shared/api/tauriAgentRegistration";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { cn } from "@/shared/lib/cn";
import {
Expand All @@ -27,16 +31,17 @@ import { Skeleton } from "@/shared/ui/skeleton";

import agentOutlineUrl from "../assets/agent-outline.svg";
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
import { RegisterExistingAgentPane } from "./ExistingAgentRegistrationPane";
import { PersonaAddedBy } from "./PersonaAddedBy";
import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel";

// ── Type-tagged selection keys ────────────────────────────────────────────────

// The detail pane is a single-select surface across four kinds of content:
// the "create" and "import" panes carried from the unified add-agent dialog
// (#5015), plus a persona or team browsed from the community catalog. Persona
// IDs and team coordinates are prefixed so they cannot collide with each other
// or with the fixed "create"/"import" keys.
// The detail pane is a single-select surface across five kinds of content:
// the "create", "register", and "import" panes carried from the unified
// add-agent dialog (#5015), plus a persona or team browsed from the community
// catalog. Persona IDs and team coordinates are prefixed so they cannot collide
// with each other or with the fixed navigation keys.
type CatalogSelectionKey =
| { kind: "persona"; id: string }
| { kind: "team"; key: string };
Expand Down Expand Up @@ -68,6 +73,10 @@ type CommunityCatalogDialogProps = {
onRequestClose: () => void;
}) => React.ReactNode;
onImportFile: (fileBytes: number[], fileName: string) => void;
isRegistrationPending: boolean;
onRegisterExistingAgent: (
input: RegisterExistingAgentInput,
) => Promise<ExistingAgentRegistrationResult>;

// Persona side
personas: AgentPersona[];
Expand Down Expand Up @@ -101,6 +110,8 @@ type PendingNavigation =
export function CommunityCatalogDialog({
createContent,
onImportFile,
isRegistrationPending,
onRegisterExistingAgent,
personas,
personasError,
personasLoading,
Expand Down Expand Up @@ -278,7 +289,7 @@ export function CommunityCatalogDialog({
<>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && personasPending) return;
if (!nextOpen && (personasPending || isRegistrationPending)) return;
if (!nextOpen) {
requestClose();
return;
Expand Down Expand Up @@ -355,6 +366,13 @@ export function CommunityCatalogDialog({
onClick={() => requestSelection("create")}
testId="agent-catalog-create"
/>
<CatalogNavigationButton
icon={<Link2 className="h-4 w-4" />}
isCurrent={selection === "register"}
label="Register existing"
onClick={() => requestSelection("register")}
testId="agent-catalog-register-existing"
/>
<CatalogNavigationButton
icon={<Upload className="h-4 w-4" />}
isCurrent={isImportSelected}
Expand Down Expand Up @@ -481,6 +499,13 @@ export function CommunityCatalogDialog({
/>
) : null}

{selection === "register" ? (
<RegisterExistingAgentPane
isPending={isRegistrationPending}
onRegister={onRegisterExistingAgent}
/>
) : null}

{selectedPersona || selectedTeam ? (
<>
<div
Expand Down
Loading