/** * Session dock > Agent: the one place for this chat's agent. Three sections, * in product words: Identity (who the agent is), Capabilities (what it can * do), each with where it comes from (the workspace default, or this chat), * and Agent learning (whether its changes to knowledge, instructions and * skills apply right away or wait for your OK). Identity and capabilities * change with Edit and apply from the next turn (the running turn keeps what * it started with); Agent learning saves at once. A session created before * agent settings (no configuration) says so and converts on its first save, * starting from what it can do today. Tool names only appear under Technical * details. */ import { AGENT_IDENTITY_MAX_CHARACTERS, legacyEffectiveAgentCapabilities, resolveWorkspaceAgentDefaults, resolveWorkspaceDefaultAgentIdentity, } from "@opengeni/contracts"; import { PencilIcon } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; import { toast } from "../../lib/toast"; import { AgentCapabilityPicker, AgentCapabilitySummary } from "../agent/agent-capability-picker"; import { InAppHelpLink } from "../in-app-help-link"; import { learningModesEqual, useLearningSettings, type LearningModes, } from "../agent/capability-learning"; import { Button } from "../../../ui/button"; import { Disclosure } from "../../../ui/disclosure"; import { Field, TextArea } from "../../../ui/field"; import { Notice } from "../../../ui/notice"; import { RelativeTime } from "../../../ui/relative-time"; import { KeepActiveSetting, sessionReadOnlyArchive } from "./session-retention"; import { SessionUsageLimitSection } from "./session-usage-limit-section"; import { useAppContext } from "../../app-context"; import { AGENT_CAPABILITY_GROUPS, AGENT_STARTING_POINTS, agentConfigErrorText, capabilityAvailability, capabilitySummary, draftFromResolved, draftsEqual, requestFromDraft, toolOwnerLabel, workspaceAgentDefaultsDraft, type AgentCapabilityDraft, } from "../../lib/agent-capabilities"; import { chatLearningScope } from "../../lib/chat-learning-scope"; import { isPersonalWorkspace } from "../../lib/managed-self-context"; import { hasWorkspacePermission } from "../../lib/permissions"; import { sessionUsageLimitCapability } from "../../lib/session-usage-limit"; import { cn } from "../../lib/utils"; import type { Session } from "../../types"; import { apiErrorFacts } from "../../../ui/api-error"; /** Settings > General > Agent defaults: what new chats in the workspace start with. */ export function workspaceAgentDefaultsHref(workspaceId: string): string { return `/workspaces/${workspaceId}/settings?section=general&view=agent-defaults`; } /** Settings > Agent learning: the workspace and private-chat defaults. */ export function agentLearningHref(workspaceId: string): string { return `/workspaces/${workspaceId}/settings?section=learning`; } export function AgentConfigurationPanel(props: { session: Session; /** The latest change to these settings, when one is in the loaded events. */ lastChange?: { at: string; pending: boolean } | null | undefined; onReloadSession: () => Promise; /** A new value (composer + > Chat settings) scrolls to Agent learning and focuses it. */ learningFocusRequest?: number | undefined; /** A new value (header menu, usage limit notice) opens the usage limit editor. */ usageLimitFocusRequest?: number | undefined; /** The goal is paused at the usage limit (raising it doesn't resume). */ goalPausedAtUsageLimit?: boolean | undefined; }) { const { session } = props; const context = useAppContext(); const workspace = context.workspaces.find((candidate) => candidate.id === session.workspaceId) ?? null; const canEdit = hasWorkspacePermission( context.accessContext, session.workspaceId, "sessions:control", ); const sectionId = useId(); const learningHeading = useRef(null); const config = session.agent ?? null; const availability = useMemo( () => capabilityAvailability(context.clientConfig.agentConfig, config?.unavailable ?? []), [context.clientConfig.agentConfig, config?.unavailable], ); // A legacy session: what it can do today, as the server will convert it. const legacyValues = useMemo( () => config ? null : legacyEffectiveAgentCapabilities({ firstPartyMcpTools: session.firstPartyMcpTools, tools: session.tools, toolPolicy: session.toolPolicy, humanInputEnabled: workspace?.settings.agentHumanInputEnabled !== false, defaultServerIds: context.workspaceDefaultToolIds, }), [config, session, workspace?.settings, context.workspaceDefaultToolIds], ); const current: AgentCapabilityDraft = config ? draftFromResolved(config) : { from: "all", values: legacyValues! }; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(current); const [identity, setIdentity] = useState(config?.identity ?? ""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const scrollRegion = useRef(null); const learningScope = chatLearningScope( session, isPersonalWorkspace(workspace, context.managedSelfContext), ); // This chat's Agent learning: shown inside Skills and Knowledge, saved with them. const learning = useLearningSettings({ workspaceId: session.workspaceId, scope: learningScope, source: { kind: "chat", id: session.id }, }); const [learningDraft, setLearningDraft] = useState(learning.modes); // Someone else's save (or a reload) while not editing shows the new truth. const configKey = JSON.stringify(config); useEffect(() => { if (editing) return; setDraft(current); setIdentity(config?.identity ?? ""); setLearningDraft(learning.modes); // oxlint-disable-next-line react-hooks/exhaustive-deps -- follow the frozen configuration }, [configKey, editing, learning.modes ? JSON.stringify(learning.modes) : null]); // Settings that arrive after the editor opened fill an empty draft. useEffect(() => { if (learning.modes) setLearningDraft((previous) => previous ?? learning.modes); }, [learning.modes]); const identityChanged = identity.trim() !== (config?.identity ?? "").trim(); const agentChanged = !draftsEqual(draft, current) || identityChanged || !config; const learningChanged = learning.modes !== null && learningDraft !== null && !learningModesEqual(learning.modes, learningDraft); const dirty = agentChanged || learningChanged; const identityTooLong = identity.trim().length > AGENT_IDENTITY_MAX_CHARACTERS; async function save() { if (!canEdit || saving) return; setSaving(true); setError(null); try { if (agentChanged) { await context.client.updateSessionAgent(session.workspaceId, session.id, { agent: { capabilities: requestFromDraft(draft, availability), ...(identityChanged ? { identity: identity.trim() || null } : {}), }, expectedVersion: session.toolPolicyVersion, }); } if (learningChanged && learningDraft) await learning.save(learningDraft); await props.onReloadSession(); setEditing(false); toast.success("Agent settings saved", { description: "They apply from the next turn." }); } catch (failure) { setError( agentConfigErrorText(failure, "Couldn't save the agent settings. Nothing was changed."), ); scrollRegion.current?.scrollTo({ top: 0 }); // A version conflict: someone changed the chat meanwhile; show theirs. if (apiErrorFacts(failure).status === 409) { await props.onReloadSession(); } } finally { setSaving(false); } } const startingPoint = AGENT_STARTING_POINTS.find((option) => option.value === current.from)?.title ?? ""; // Where each part comes from: the workspace's defaults for new chats, or // this chat. A chat that matches the defaults says so. const workspaceDefaults = useMemo( () => workspaceAgentDefaultsDraft({ capabilities: resolveWorkspaceAgentDefaults(workspace?.settings)?.capabilities, legacyHumanInputOff: workspace?.settings.agentHumanInputEnabled === false, }), [workspace?.settings], ); const workspaceIdentity = resolveWorkspaceDefaultAgentIdentity( workspace?.settings, workspace?.agentInstructions, ).identity; const capabilitiesSource = !config ? "Set when this chat started" : draftsEqual(current, workspaceDefaults) ? "Workspace default" : config.source === "workspace_default" || config.source === "deployment_default" ? "Defaults when this chat started" : "Changed for this chat"; const identitySource = config?.identity ? "Set for this chat" : "Workspace default"; const defaultsHref = workspaceAgentDefaultsHref(session.workspaceId); // Composer + > Chat settings: open the editor on what the agent can do. const focusRequest = props.learningFocusRequest ?? 0; useEffect(() => { if (!focusRequest) return; if (canEdit) setEditing(true); const frame = requestAnimationFrame(() => { const heading = learningHeading.current; if (!heading) return; heading.scrollIntoView({ block: "start" }); heading.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [focusRequest, canEdit]); // The usage limit lives in the read view: leave capability editing for it, // unless that would drop unsaved capability changes. const usageLimitFocusRequest = props.usageLimitFocusRequest ?? 0; useEffect(() => { if (usageLimitFocusRequest && !dirty) setEditing(false); // oxlint-disable-next-line react-hooks/exhaustive-deps -- once per request }, [usageLimitFocusRequest]); const usageLimitsOffered = sessionUsageLimitCapability(context.clientConfig) !== "absent"; return (

{editing ? "Edit agent" : "Agent"}

{editing ? `Applies from the next turn · ${capabilitySummary(draft.values, availability)}` : `${config ? startingPoint : "Before agent settings"} · ${capabilitySummary(current.values, availability)}`}

{!editing && canEdit ? ( ) : null}
{editing ? ( <> {error ? ( // First in the form, so it is seen right after pressing Save. {error} ) : null} {!config ? ( Saving converts this session to agent settings, starting from what it can do now. ) : null}