From 03678e3b6dc5b917b7a81cd4ba8d8cd132cf3555 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:02:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(profiles):=20top-level=20agent=20profiles?= =?UTF-8?q?=20=E2=80=94=20soul,=20memory,=20skills,=20MCP,=20connectors=20?= =?UTF-8?q?(#3632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/components/settings/SettingsHome.tsx | 7 + .../panels/ProfileEditorPage.test.tsx | 138 +++++ .../settings/panels/ProfileEditorPage.tsx | 513 +++++++++++++++++ .../settings/panels/ProfilesPanel.test.tsx | 141 +++++ .../settings/panels/ProfilesPanel.tsx | 176 ++++++ app/src/lib/i18n/ar.ts | 46 ++ app/src/lib/i18n/bn.ts | 47 ++ app/src/lib/i18n/de.ts | 51 ++ app/src/lib/i18n/en.ts | 48 ++ app/src/lib/i18n/es.ts | 51 ++ app/src/lib/i18n/fr.ts | 49 ++ app/src/lib/i18n/hi.ts | 48 ++ app/src/lib/i18n/id.ts | 48 ++ app/src/lib/i18n/it.ts | 49 ++ app/src/lib/i18n/ko.ts | 47 ++ app/src/lib/i18n/pl.ts | 49 ++ app/src/lib/i18n/pt.ts | 49 ++ app/src/lib/i18n/ru.ts | 52 ++ app/src/lib/i18n/zh-CN.ts | 43 ++ app/src/pages/Settings.tsx | 5 + app/src/services/api/agentProfilesApi.test.ts | 8 +- app/src/services/api/agentProfilesApi.ts | 8 +- app/src/types/agentProfile.ts | 9 + src/core/all.rs | 3 + .../agent/harness/session/builder/factory.rs | 66 ++- src/openhuman/agent/mod.rs | 2 - src/openhuman/agent/schemas.rs | 390 ------------- .../agent/task_dispatcher/executor.rs | 23 +- src/openhuman/agent/task_dispatcher/types.rs | 9 +- .../agent/tools/delegate_to_personality.rs | 4 +- src/openhuman/agent/tools/run_workflow.rs | 27 +- src/openhuman/agent_memory/memory_loader.rs | 416 ++++++++------ .../channels/providers/web/run_task.rs | 17 +- .../channels/providers/web/session.rs | 7 +- src/openhuman/channels/providers/web/types.rs | 7 + src/openhuman/channels/providers/web_tests.rs | 16 + src/openhuman/channels/runtime/startup.rs | 2 + src/openhuman/mcp_client/registry.rs | 66 +++ src/openhuman/meet_agent/brain/llm.rs | 1 + src/openhuman/memory/mod.rs | 1 + src/openhuman/memory/source_scope.rs | 212 +++++++ .../memory_search/tools/chunk_context.rs | 17 +- .../memory_search/tools/vector_search.rs | 5 +- src/openhuman/memory_store/chunks/store.rs | 38 +- .../memory_store/chunks/store_tests.rs | 40 ++ src/openhuman/memory_store/retrieval/mod.rs | 1 + .../memory_store/tools/raw_chunks.rs | 3 + src/openhuman/memory_tree/retrieval/source.rs | 93 +++- src/openhuman/memory_tree/tree/rpc.rs | 1 + src/openhuman/mod.rs | 1 + src/openhuman/profiles/mod.rs | 31 ++ src/openhuman/profiles/ops.rs | 232 ++++++++ .../paths.rs} | 43 +- src/openhuman/profiles/prompt_section.rs | 69 +++ src/openhuman/profiles/schemas.rs | 295 ++++++++++ .../{agent/profiles.rs => profiles/store.rs} | 515 ++++++------------ src/openhuman/profiles/types.rs | 148 +++++ src/openhuman/runtime_node/ops.rs | 2 + src/openhuman/tools/ops.rs | 47 +- src/openhuman/workflows/tools.rs | 96 +++- tests/domain_modules_e2e.rs | 16 +- tests/inference_agent_raw_coverage_e2e.rs | 59 +- tests/personality_e2e.rs | 20 +- tests/worker_b_domain_e2e.rs | 16 +- tests/worker_b_raw_coverage_e2e.rs | 14 +- 65 files changed, 3755 insertions(+), 998 deletions(-) create mode 100644 app/src/components/settings/panels/ProfileEditorPage.test.tsx create mode 100644 app/src/components/settings/panels/ProfileEditorPage.tsx create mode 100644 app/src/components/settings/panels/ProfilesPanel.test.tsx create mode 100644 app/src/components/settings/panels/ProfilesPanel.tsx create mode 100644 src/openhuman/memory/source_scope.rs create mode 100644 src/openhuman/profiles/mod.rs create mode 100644 src/openhuman/profiles/ops.rs rename src/openhuman/{agent/personality_paths.rs => profiles/paths.rs} (89%) create mode 100644 src/openhuman/profiles/prompt_section.rs create mode 100644 src/openhuman/profiles/schemas.rs rename src/openhuman/{agent/profiles.rs => profiles/store.rs} (62%) create mode 100644 src/openhuman/profiles/types.rs diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 516c3615d..1408136bc 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -291,6 +291,13 @@ const SettingsHome = () => { icon: AgentsIcon, onClick: () => navigateToSettings('agents-settings'), }, + { + id: 'profiles', + title: t('settings.profiles.title'), + description: t('settings.profiles.menuDesc'), + icon: PersonalityIcon, + onClick: () => navigateToSettings('profiles'), + }, { id: 'persona', title: t('settings.assistant.personality'), diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx new file mode 100644 index 000000000..bb81766e1 --- /dev/null +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -0,0 +1,138 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { agentProfilesApi } from '../../../services/api/agentProfilesApi'; +import agentProfilesReducer from '../../../store/agentProfileSlice'; +import type { AgentProfile } from '../../../types/agentProfile'; +import ProfileEditorPage from './ProfileEditorPage'; + +vi.mock('../../../services/api/agentProfilesApi', () => ({ + agentProfilesApi: { list: vi.fn(), select: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../components/SettingsHeader', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})); + +const mockUpsert = vi.mocked(agentProfilesApi.upsert); + +function profile(overrides: Partial = {}): AgentProfile { + return { + id: 'writer', + name: 'Writer', + description: 'Drafts copy.', + agentId: 'orchestrator', + builtIn: false, + ...overrides, + }; +} + +function renderAt(path: string, profiles: AgentProfile[] = []) { + const store = configureStore({ + reducer: { agentProfiles: agentProfilesReducer }, + preloadedState: { + agentProfiles: { profiles, activeProfileId: 'default', status: 'idle' as const, error: null }, + }, + }); + return { + store, + ...render( + + + + } /> + } /> + + + + ), + }; +} + +describe('ProfileEditorPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpsert.mockResolvedValue({ profiles: [], activeProfileId: 'default' }); + }); + + it('create mode: name drives the slug and Create dispatches an upsert', async () => { + renderAt('/settings/profiles/new'); + expect(screen.getByText('New profile')).toBeInTheDocument(); + + const name = screen.getByLabelText('Name'); + fireEvent.change(name, { target: { value: 'My Research' } }); + const id = screen.getByLabelText('ID') as HTMLInputElement; + expect(id.value).toBe('my-research'); // auto-slugged + + fireEvent.click(screen.getByText('Create')); + await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); + const sent = mockUpsert.mock.calls[0][0]; + expect(sent.id).toBe('my-research'); + expect(sent.name).toBe('My Research'); + expect(sent.includeAgentConversations).toBe(true); + expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles'); + }); + + it('disables Create until a non-empty resolved id exists', () => { + renderAt('/settings/profiles/new'); + const create = screen.getByText('Create').closest('button')!; + expect(create).toBeDisabled(); + // A punctuation-only name still slugs to '' → stays disabled. + fireEvent.change(screen.getByLabelText('Name'), { target: { value: '!!!' } }); + expect(create).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ok' } }); + expect(create).not.toBeDisabled(); + }); + + it('edit mode hydrates fields from the existing profile', () => { + renderAt('/settings/profiles/edit/writer', [ + profile({ + id: 'writer', + name: 'Writer', + description: 'Drafts copy.', + soulMd: 'I am Writer.', + }), + ]); + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Writer'); + expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe( + 'Drafts copy.' + ); + expect((screen.getByLabelText('Soul (SOUL.md)') as HTMLTextAreaElement).value).toBe( + 'I am Writer.' + ); + }); + + it('shows not-found when editing an id absent from a loaded list', () => { + renderAt('/settings/profiles/edit/ghost', [profile({ id: 'writer' })]); + expect(screen.getByText('Profile not found')).toBeInTheDocument(); + }); + + it('an All/Selected allowlist accepts a typed chip', () => { + renderAt('/settings/profiles/new'); + // Switch the Skills allowlist from All to Selected. + const selectedButtons = screen.getAllByText('Selected'); + fireEvent.click(selectedButtons[0]); + const chipInput = screen.getByPlaceholderText('Type an id, press Enter'); + fireEvent.change(chipInput, { target: { value: 'deep-research' } }); + fireEvent.keyDown(chipInput, { key: 'Enter' }); + expect(screen.getByText('deep-research')).toBeInTheDocument(); + }); + + it('toggles the recall-agent-conversations switch into the saved payload', async () => { + renderAt('/settings/profiles/new'); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Scoped' } }); + fireEvent.click(screen.getByLabelText('Recall agent conversations')); // true -> false + fireEvent.click(screen.getByText('Create')); + await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); + expect(mockUpsert.mock.calls[0][0].includeAgentConversations).toBe(false); + }); +}); diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx new file mode 100644 index 000000000..5e4c98489 --- /dev/null +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -0,0 +1,513 @@ +/** + * ProfileEditorPage — Settings > Agent Profiles > (New | Edit). + * + * Full-page editor for an agent *profile* (a "flavour": custom name + SOUL.md, + * runtime defaults, and per-profile allowlists for memory sources, connectors, + * skills, and MCP servers). Distinct from the agent *registry* editor + * (`AgentEditorPage`), which edits sub-agent definitions. + * + * Routes: `/settings/profiles/new` and `/settings/profiles/edit/:id`. + * + * Each allowlist follows the "All / Selected" contract: `null`/`undefined` = + * all (unrestricted); an array = restrict to those ids (empty = none). + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LuX } from 'react-icons/lu'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { selectAgentProfiles, upsertAgentProfile } from '../../../store/agentProfileSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import type { AgentProfile } from '../../../types/agentProfile'; +import Button from '../../ui/Button'; +import SettingsHeader from '../components/SettingsHeader'; +import { + SettingsRow, + SettingsSection, + SettingsSwitch, + SettingsTextArea, + SettingsTextField, +} from '../controls'; + +const MODEL_HINTS = ['hint:reasoning', 'hint:chat', 'hint:agentic', 'hint:coding']; + +function slugify(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** Normalize an allowlist for save: empty array stays `[]` (= none selected); + * `null` means "all". The chip editor only produces arrays, so callers pass + * `null` explicitly via the All/Selected toggle. */ +type Allowlist = string[] | null; + +const ProfileEditorPage = () => { + const { t } = useT(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { id: routeId } = useParams<{ id: string }>(); + const profiles = useAppSelector(selectAgentProfiles); + const backToList = useCallback(() => navigate('/settings/profiles'), [navigate]); + const isCreate = !routeId; + + const existing = useMemo( + () => (routeId ? profiles.find(p => p.id === routeId) : undefined), + [profiles, routeId] + ); + + const [name, setName] = useState(''); + const [agentId, setAgentId] = useState('orchestrator'); + const [idTouched, setIdTouched] = useState(!isCreate); + const [profileId, setProfileId] = useState(''); + const [description, setDescription] = useState(''); + const [model, setModel] = useState(''); + const [temperature, setTemperature] = useState(''); + const [systemPromptSuffix, setSystemPromptSuffix] = useState(''); + const [soulMd, setSoulMd] = useState(''); + const [includeAgentConversations, setIncludeAgentConversations] = useState(true); + const [memorySources, setMemorySources] = useState(null); + const [composioIntegrations, setComposioIntegrations] = useState(null); + const [allowedSkills, setAllowedSkills] = useState(null); + const [allowedMcpServers, setAllowedMcpServers] = useState(null); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Hydrate form state from the loaded profile (edit mode). This intentionally + // fans out into multiple setters in an effect: the source is async Redux + // state that may arrive after mount, so a keyed remount / lazy initial state + // can't capture it. Mirrors the suppression used by other settings panels. + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { + if (isCreate) return; + if (!existing) { + // Profiles may still be loading; only flag not-found once a list exists. + if (profiles.length > 0) setNotFound(true); + return; + } + setNotFound(false); + setName(existing.name); + setAgentId(existing.agentId || 'orchestrator'); + setProfileId(existing.id); + setDescription(existing.description ?? ''); + setModel(existing.modelOverride ?? ''); + setTemperature( + existing.temperature === null || existing.temperature === undefined + ? '' + : String(existing.temperature) + ); + setSystemPromptSuffix(existing.systemPromptSuffix ?? ''); + setSoulMd(existing.soulMd ?? ''); + setIncludeAgentConversations(existing.includeAgentConversations ?? true); + setMemorySources(existing.memorySources ?? null); + setComposioIntegrations(existing.composioIntegrations ?? null); + setAllowedSkills(existing.allowedSkills ?? null); + setAllowedMcpServers(existing.allowedMcpServers ?? null); + }, [existing, isCreate, profiles.length]); + + const handleName = (value: string) => { + setName(value); + if (isCreate && !idTouched) setProfileId(slugify(value)); + }; + + // Resolved profile id: explicit id on create (falling back to a slug of the + // name), or the existing id on edit. Must be non-empty to submit — a + // punctuation-only name slugs to '' and must not reach the RPC layer. + const resolvedId = (isCreate ? profileId.trim() || slugify(name) : profileId).trim(); + + const canSubmit = !submitting && (isCreate ? resolvedId.length > 0 : true); + + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const id = resolvedId; + if (!id) { + setError(t('settings.profiles.editor.idRequired')); + setSubmitting(false); + return; + } + const tempNum = temperature.trim() === '' ? null : Number(temperature); + const profile: AgentProfile = { + id, + name: name.trim() || id, + description: description.trim(), + agentId: agentId.trim() || 'orchestrator', + modelOverride: model.trim() || null, + temperature: tempNum !== null && Number.isFinite(tempNum) ? tempNum : null, + systemPromptSuffix: systemPromptSuffix.trim() || null, + soulMd: soulMd.trim() || null, + includeAgentConversations, + memorySources, + composioIntegrations, + allowedSkills, + allowedMcpServers, + builtIn: existing?.builtIn ?? false, + }; + try { + await dispatch(upsertAgentProfile(profile)).unwrap(); + if (mountedRef.current) backToList(); + } catch (err) { + if (mountedRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setSubmitting(false); + } + }; + + const title = isCreate + ? t('settings.profiles.editor.createTitle') + : name || t('settings.profiles.editor.editTitle'); + + const breadcrumbs = [ + { label: t('nav.settings'), onClick: () => navigate('/settings') }, + { label: t('settings.profiles.title'), onClick: backToList }, + ]; + + return ( +
+ + +
+ {notFound ? ( +
+
+ {t('settings.profiles.editor.notFound')} +
+ +
+ ) : ( +
+ {/* Identity */} + + handleName(e.target.value)} + aria-label={t('settings.profiles.editor.name')} + /> + } + /> + {isCreate ? ( + { + setIdTouched(true); + setProfileId(e.target.value); + }} + aria-label={t('settings.profiles.editor.id')} + /> + } + /> + ) : ( + + {profileId} + + } + /> + )} + setDescription(e.target.value)} + rows={2} + aria-label={t('settings.profiles.editor.description')} + /> + } + /> + + + {/* Soul */} + + setSoulMd(e.target.value)} + rows={6} + aria-label={t('settings.profiles.editor.soul')} + /> + } + /> + + + {/* Runtime defaults */} + + setAgentId(e.target.value)} + aria-label={t('settings.profiles.editor.baseAgent')} + /> + } + /> + setModel(e.target.value)} + placeholder={MODEL_HINTS.join(', ')} + aria-label={t('settings.profiles.editor.model')} + /> + } + /> + setTemperature(e.target.value)} + placeholder="0.0 – 1.0" + aria-label={t('settings.profiles.editor.temperature')} + /> + } + /> + setSystemPromptSuffix(e.target.value)} + rows={2} + aria-label={t('settings.profiles.editor.systemPromptSuffix')} + /> + } + /> + + + {/* Memory */} + + + } + /> + + + + {/* Capabilities */} + + + + + + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ )} +
+
+ ); +}; + +/** + * "All / Selected" allowlist editor. `null` value = all (unrestricted); an + * array switches to restricted mode with a chip editor. Toggling back to All + * emits `null`; toggling to Selected emits the current array (or `[]`). + */ +function AllowlistField({ + label, + hint, + value, + onChange, +}: { + label: string; + hint: string; + value: Allowlist; + onChange: (next: Allowlist) => void; +}) { + const { t } = useT(); + const [draft, setDraft] = useState(''); + const restricted = value !== null; + const items = value ?? []; + + const commitDraft = () => { + const next = draft + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (next.length === 0) return; + const merged = Array.from(new Set([...(value ?? []), ...next])); + onChange(merged); + setDraft(''); + }; + + return ( + +
+ + +
+ + {restricted && ( +
+
+ {items.map(item => ( + + {item} + + + ))} +
+ setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + commitDraft(); + } + }} + onBlur={commitDraft} + placeholder={t('settings.profiles.editor.addPlaceholder')} + aria-label={label} + /> +
+ )} + + } + /> + ); +} + +export default ProfileEditorPage; diff --git a/app/src/components/settings/panels/ProfilesPanel.test.tsx b/app/src/components/settings/panels/ProfilesPanel.test.tsx new file mode 100644 index 000000000..00b7b4802 --- /dev/null +++ b/app/src/components/settings/panels/ProfilesPanel.test.tsx @@ -0,0 +1,141 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { agentProfilesApi } from '../../../services/api/agentProfilesApi'; +import agentProfilesReducer from '../../../store/agentProfileSlice'; +import type { AgentProfile, AgentProfilesResponse } from '../../../types/agentProfile'; +import ProfilesPanel from './ProfilesPanel'; + +vi.mock('../../../services/api/agentProfilesApi', () => ({ + agentProfilesApi: { list: vi.fn(), select: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../components/SettingsHeader', () => ({ + default: ({ title, action }: { title: string; action?: React.ReactNode }) => ( +
+

{title}

+ {action} +
+ ), +})); + +const mockList = vi.mocked(agentProfilesApi.list); +const mockSelect = vi.mocked(agentProfilesApi.select); +const mockDelete = vi.mocked(agentProfilesApi.delete); + +function profile(overrides: Partial = {}): AgentProfile { + return { + id: 'research', + name: 'Research', + description: 'Source-grounded research.', + agentId: 'researcher', + builtIn: true, + ...overrides, + }; +} + +function response(profiles: AgentProfile[], activeProfileId: string): AgentProfilesResponse { + return { profiles, activeProfileId }; +} + +const PROFILES = [ + profile({ id: 'default', name: 'Default', builtIn: true }), + profile({ id: 'research', name: 'Research', builtIn: true }), + profile({ id: 'writer', name: 'Writer', description: 'Drafts copy.', builtIn: false }), +]; + +function renderPanel() { + const store = configureStore({ reducer: { agentProfiles: agentProfilesReducer } }); + return { + store, + ...render( + + + + + + ), + }; +} + +describe('ProfilesPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockList.mockResolvedValue(response(PROFILES, 'default')); + }); + + it('loads and lists profiles with active + source badges', async () => { + renderPanel(); + expect(await screen.findByText('Writer')).toBeInTheDocument(); + expect(screen.getByText('Research')).toBeInTheDocument(); + // Active badge on the default profile, source badges on all. + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0); + expect(screen.getByText('Custom')).toBeInTheDocument(); + expect(mockList).toHaveBeenCalled(); + }); + + it('navigates to the create page from the New profile action', async () => { + renderPanel(); + await screen.findByText('Writer'); + fireEvent.click(screen.getByText('New profile')); + expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles/new'); + }); + + it('sets a non-active profile as active', async () => { + mockSelect.mockResolvedValue(response(PROFILES, 'writer')); + renderPanel(); + await screen.findByText('Writer'); + // "Set as active" is shown for non-active profiles. + fireEvent.click(screen.getAllByText('Set as active')[0]); + await waitFor(() => expect(mockSelect).toHaveBeenCalled()); + }); + + it('navigates to the edit page for a profile', async () => { + renderPanel(); + await screen.findByText('Writer'); + fireEvent.click(screen.getAllByText('Edit')[0]); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/settings/profiles/edit/')); + }); + + it('deletes a custom profile after confirmation', async () => { + mockDelete.mockResolvedValue(response(PROFILES.slice(0, 2), 'default')); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPanel(); + await screen.findByText('Writer'); + // Only the custom profile (Writer) has a Delete button. + fireEvent.click(screen.getByText('Delete')); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('writer')); + confirmSpy.mockRestore(); + }); + + it('does not delete when confirmation is cancelled', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + renderPanel(); + await screen.findByText('Writer'); + fireEvent.click(screen.getByText('Delete')); + expect(mockDelete).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); + + it('renders the empty state when there are no profiles', async () => { + mockList.mockResolvedValue(response([], 'default')); + renderPanel(); + expect(await screen.findByText('No agent profiles yet')).toBeInTheDocument(); + }); + + it('surfaces a load error', async () => { + mockList.mockRejectedValue(new Error('boom')); + renderPanel(); + await waitFor(() => expect(screen.getByText(/boom/)).toBeInTheDocument()); + }); +}); diff --git a/app/src/components/settings/panels/ProfilesPanel.tsx b/app/src/components/settings/panels/ProfilesPanel.tsx new file mode 100644 index 000000000..222baed6e --- /dev/null +++ b/app/src/components/settings/panels/ProfilesPanel.tsx @@ -0,0 +1,176 @@ +/** + * ProfilesPanel — Settings > Agent Profiles. + * + * Lists agent *profiles* ("flavours"): the active one, built-in defaults, and + * user-created profiles. Lets the user set the active profile, create a new + * one, edit, or delete a custom profile. The editor lives at + * `/settings/profiles/(new|edit/:id)` (`ProfileEditorPage`). + */ +import { useCallback, useEffect, useState } from 'react'; +import { LuPlus } from 'react-icons/lu'; +import { useNavigate } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + deleteAgentProfile, + loadAgentProfiles, + selectActiveAgentProfileId, + selectAgentProfile, + selectAgentProfiles, +} from '../../../store/agentProfileSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import Button from '../../ui/Button'; +import SettingsHeader from '../components/SettingsHeader'; +import { SettingsEmptyState, SettingsSection } from '../controls'; + +const ProfilesPanel = () => { + const { t } = useT(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const profiles = useAppSelector(selectAgentProfiles); + const activeId = useAppSelector(selectActiveAgentProfileId); + const status = useAppSelector(state => state.agentProfiles.status); + const error = useAppSelector(state => state.agentProfiles.error); + const [actionError, setActionError] = useState(null); + + useEffect(() => { + void dispatch(loadAgentProfiles()); + }, [dispatch]); + + const setActive = useCallback( + async (id: string) => { + setActionError(null); + try { + await dispatch(selectAgentProfile(id)).unwrap(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }, + [dispatch] + ); + + const remove = useCallback( + async (id: string) => { + // eslint-disable-next-line no-alert + if (!window.confirm(t('settings.profiles.deleteConfirm'))) return; + setActionError(null); + try { + await dispatch(deleteAgentProfile(id)).unwrap(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }, + [dispatch, t] + ); + + const breadcrumbs = [{ label: t('nav.settings'), onClick: () => navigate('/settings') }]; + + return ( +
+ navigate('/settings')} + breadcrumbs={breadcrumbs} + action={ + + } + /> + +
+

+ {t('settings.profiles.subtitle')} +

+ + {(actionError || error) && ( +

+ {actionError || `${t('settings.profiles.loadError')}: ${error}`} +

+ )} + + {profiles.length === 0 ? ( + status === 'loading' ? ( +
+
+ {t('common.loading')} +
+ ) : ( + + ) + ) : ( + +
    + {profiles.map(profile => { + const isActive = profile.id === activeId; + return ( +
  • +
    +
    + + {profile.name} + + {isActive && ( + + {t('settings.profiles.active')} + + )} + + {profile.builtIn + ? t('settings.profiles.sourceBuiltIn') + : t('settings.profiles.sourceCustom')} + +
    + {profile.description && ( +

    + {profile.description} +

    + )} +
    +
    + {!isActive && ( + + )} + + {!profile.builtIn && ( + + )} +
    +
  • + ); + })} +
+
+ )} +
+
+ ); +}; + +export default ProfilesPanel; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0a65afa74..c5dc106b0 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5247,6 +5247,52 @@ const messages: TranslationMap = { 'notch.speaking': 'أتحدث…', 'notch.transcribing': 'أفسّر…', 'notch.executing': 'أنفّذ…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'ملفات الوكيل', + 'settings.profiles.subtitle': 'وكلاء بطابع خاص — لكل منهم روحه وذاكرته وموصّلاته ومهاراته.', + 'settings.profiles.menuDesc': 'أنشئ ملفات الوكيل وأدِرها', + 'settings.profiles.new': 'ملف جديد', + 'settings.profiles.empty': 'لا توجد ملفات وكيل بعد', + 'settings.profiles.loadError': 'تعذّر تحميل الملفات', + 'settings.profiles.active': 'نشط', + 'settings.profiles.setActive': 'تعيين كنشط', + 'settings.profiles.sourceBuiltIn': 'مدمج', + 'settings.profiles.sourceCustom': 'مخصّص', + 'settings.profiles.deleteConfirm': 'حذف هذا الملف؟ لا يمكن التراجع عن ذلك.', + 'settings.profiles.editor.createTitle': 'ملف جديد', + 'settings.profiles.editor.editTitle': 'تحرير الملف', + 'settings.profiles.editor.name': 'الاسم', + 'settings.profiles.editor.id': 'المعرّف', + 'settings.profiles.editor.idHint': 'أحرف صغيرة وأرقام وشرطات فقط.', + 'settings.profiles.editor.description': 'الوصف', + 'settings.profiles.editor.soul': 'الروح (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'هوية مخصّصة لهذا الملف. عند تركه فارغًا يُستخدم ملف SOUL.md الخاص بمساحة العمل.', + 'settings.profiles.editor.baseAgent': 'الوكيل الأساسي', + 'settings.profiles.editor.baseAgentHint': + 'تعريف الوكيل الذي يعمل به هذا الملف (مثل orchestrator).', + 'settings.profiles.editor.model': 'النموذج', + 'settings.profiles.editor.modelHint': 'تجاوز اختياري للنموذج. عند تركه فارغًا يَرِث الافتراضي.', + 'settings.profiles.editor.temperature': 'درجة الحرارة', + 'settings.profiles.editor.systemPromptSuffix': 'لاحقة موجّه النظام', + 'settings.profiles.editor.agentConversations': 'استدعاء محادثات الوكيل', + 'settings.profiles.editor.agentConversationsHint': + 'تضمين ذاكرة المحادثات السابقة والمتقاطعة في سياق هذا الملف.', + 'settings.profiles.editor.memorySources': 'مصادر الذاكرة', + 'settings.profiles.editor.memorySourcesHint': 'مصادر الذاكرة التي يستدعي منها هذا الملف.', + 'settings.profiles.editor.connectors': 'الموصّلات', + 'settings.profiles.editor.connectorsHint': 'حِزم أدوات Composio التي يمكن لهذا الملف استخدامها.', + 'settings.profiles.editor.skills': 'المهارات', + 'settings.profiles.editor.skillsHint': 'سير العمل الذي يمكن لهذا الملف سرده وتشغيله.', + 'settings.profiles.editor.mcpServers': 'خوادم MCP', + 'settings.profiles.editor.mcpServersHint': 'خوادم MCP التي يمكن لهذا الملف الوصول إليها.', + 'settings.profiles.editor.all': 'الكل', + 'settings.profiles.editor.selected': 'محدّد', + 'settings.profiles.editor.addPlaceholder': 'اكتب معرّفًا ثم اضغط Enter', + 'settings.profiles.editor.removeAria': 'إزالة {item}', + 'settings.profiles.editor.notFound': 'الملف غير موجود', + 'settings.profiles.editor.saving': 'جارٍ الحفظ…', + 'settings.profiles.editor.idRequired': 'لا يمكن أن يكون معرّف الملف فارغًا', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index f197e5b38..ef4a84955 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5351,6 +5351,53 @@ const messages: TranslationMap = { 'notch.speaking': 'বলছি…', 'notch.transcribing': 'ট্রান্সক্রাইব করছি…', 'notch.executing': 'চালাচ্ছি…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'এজেন্ট প্রোফাইল', + 'settings.profiles.subtitle': + 'স্বতন্ত্র এজেন্ট — প্রতিটির নিজস্ব আত্মা, স্মৃতি, কানেক্টর ও দক্ষতা।', + 'settings.profiles.menuDesc': 'এজেন্ট প্রোফাইল তৈরি ও পরিচালনা করুন', + 'settings.profiles.new': 'নতুন প্রোফাইল', + 'settings.profiles.empty': 'এখনও কোনো এজেন্ট প্রোফাইল নেই', + 'settings.profiles.loadError': 'প্রোফাইল লোড করা যায়নি', + 'settings.profiles.active': 'সক্রিয়', + 'settings.profiles.setActive': 'সক্রিয় হিসেবে সেট করুন', + 'settings.profiles.sourceBuiltIn': 'অন্তর্নির্মিত', + 'settings.profiles.sourceCustom': 'কাস্টম', + 'settings.profiles.deleteConfirm': 'এই প্রোফাইল মুছবেন? এটি ফেরানো যাবে না।', + 'settings.profiles.editor.createTitle': 'নতুন প্রোফাইল', + 'settings.profiles.editor.editTitle': 'প্রোফাইল সম্পাদনা', + 'settings.profiles.editor.name': 'নাম', + 'settings.profiles.editor.id': 'আইডি', + 'settings.profiles.editor.idHint': 'শুধু ছোট হাতের অক্ষর, সংখ্যা ও ড্যাশ।', + 'settings.profiles.editor.description': 'বিবরণ', + 'settings.profiles.editor.soul': 'আত্মা (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'এই প্রোফাইলের কাস্টম পরিচয়। খালি থাকলে ওয়ার্কস্পেসের SOUL.md ব্যবহার হয়।', + 'settings.profiles.editor.baseAgent': 'বেস এজেন্ট', + 'settings.profiles.editor.baseAgentHint': + 'এই প্রোফাইল যে এজেন্ট সংজ্ঞা হিসেবে চলে (যেমন orchestrator)।', + 'settings.profiles.editor.model': 'মডেল', + 'settings.profiles.editor.modelHint': 'ঐচ্ছিক মডেল ওভাররাইড। খালি থাকলে ডিফল্ট উত্তরাধিকার নেয়।', + 'settings.profiles.editor.temperature': 'তাপমাত্রা', + 'settings.profiles.editor.systemPromptSuffix': 'সিস্টেম প্রম্পট প্রত্যয়', + 'settings.profiles.editor.agentConversations': 'এজেন্ট কথোপকথন স্মরণ', + 'settings.profiles.editor.agentConversationsHint': + 'এই প্রোফাইলের প্রসঙ্গে পূর্ববর্তী ও ক্রস-চ্যাট স্মৃতি অন্তর্ভুক্ত করুন।', + 'settings.profiles.editor.memorySources': 'স্মৃতির উৎস', + 'settings.profiles.editor.memorySourcesHint': 'এই প্রোফাইল যেসব উৎস থেকে স্মরণ করে।', + 'settings.profiles.editor.connectors': 'কানেক্টর', + 'settings.profiles.editor.connectorsHint': 'এই প্রোফাইল যেসব Composio টুলকিট ব্যবহার করতে পারে।', + 'settings.profiles.editor.skills': 'দক্ষতা', + 'settings.profiles.editor.skillsHint': 'এই প্রোফাইল যেসব ওয়ার্কফ্লো তালিকাভুক্ত ও চালাতে পারে।', + 'settings.profiles.editor.mcpServers': 'MCP সার্ভার', + 'settings.profiles.editor.mcpServersHint': 'এই প্রোফাইল যেসব MCP সার্ভারে পৌঁছাতে পারে।', + 'settings.profiles.editor.all': 'সব', + 'settings.profiles.editor.selected': 'নির্বাচিত', + 'settings.profiles.editor.addPlaceholder': 'একটি আইডি লিখে এন্টার চাপুন', + 'settings.profiles.editor.removeAria': '{item} সরান', + 'settings.profiles.editor.notFound': 'প্রোফাইল পাওয়া যায়নি', + 'settings.profiles.editor.saving': 'সংরক্ষণ হচ্ছে…', + 'settings.profiles.editor.idRequired': 'প্রোফাইল আইডি খালি রাখা যাবে না', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index cf64d6499..07b870a65 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5499,6 +5499,57 @@ const messages: TranslationMap = { 'notch.speaking': 'Spreche…', 'notch.transcribing': 'Transkribiere…', 'notch.executing': 'Führe aus…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Agentenprofile', + 'settings.profiles.subtitle': + 'Agenten mit Charakter – jeder mit eigener Seele, Erinnerung, Konnektoren und Fähigkeiten.', + 'settings.profiles.menuDesc': 'Agentenprofile erstellen und verwalten', + 'settings.profiles.new': 'Neues Profil', + 'settings.profiles.empty': 'Noch keine Agentenprofile', + 'settings.profiles.loadError': 'Profile konnten nicht geladen werden', + 'settings.profiles.active': 'Aktiv', + 'settings.profiles.setActive': 'Als aktiv festlegen', + 'settings.profiles.sourceBuiltIn': 'Integriert', + 'settings.profiles.sourceCustom': 'Benutzerdefiniert', + 'settings.profiles.deleteConfirm': + 'Dieses Profil löschen? Das kann nicht rückgängig gemacht werden.', + 'settings.profiles.editor.createTitle': 'Neues Profil', + 'settings.profiles.editor.editTitle': 'Profil bearbeiten', + 'settings.profiles.editor.name': 'Name', + 'settings.profiles.editor.id': 'Kennung', + 'settings.profiles.editor.idHint': 'Nur Kleinbuchstaben, Ziffern und Bindestriche.', + 'settings.profiles.editor.description': 'Beschreibung', + 'settings.profiles.editor.soul': 'Seele (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Benutzerdefinierte Identität dieses Profils. Leer greift auf die SOUL.md des Arbeitsbereichs zurück.', + 'settings.profiles.editor.baseAgent': 'Basis-Agent', + 'settings.profiles.editor.baseAgentHint': + 'Mit welcher Agentendefinition dieses Profil läuft (z. B. orchestrator).', + 'settings.profiles.editor.model': 'Modell', + 'settings.profiles.editor.modelHint': + 'Optionale Modellüberschreibung. Leer erbt die Standardeinstellung.', + 'settings.profiles.editor.temperature': 'Temperatur', + 'settings.profiles.editor.systemPromptSuffix': 'Suffix der Systemaufforderung', + 'settings.profiles.editor.agentConversations': 'Agentengespräche abrufen', + 'settings.profiles.editor.agentConversationsHint': + 'Erinnerungen aus früheren und chatübergreifenden Gesprächen in den Kontext dieses Profils einbeziehen.', + 'settings.profiles.editor.memorySources': 'Gedächtnisquellen', + 'settings.profiles.editor.memorySourcesHint': + 'Gedächtnisquellen, aus denen dieses Profil abruft.', + 'settings.profiles.editor.connectors': 'Konnektoren', + 'settings.profiles.editor.connectorsHint': 'Composio-Toolkits, die dieses Profil verwenden kann.', + 'settings.profiles.editor.skills': 'Fähigkeiten', + 'settings.profiles.editor.skillsHint': + 'Workflows, die dieses Profil auflisten und ausführen kann.', + 'settings.profiles.editor.mcpServers': 'MCP-Server', + 'settings.profiles.editor.mcpServersHint': 'MCP-Server, die dieses Profil erreichen kann.', + 'settings.profiles.editor.all': 'Alle', + 'settings.profiles.editor.selected': 'Ausgewählt', + 'settings.profiles.editor.addPlaceholder': 'Kennung eingeben und Enter drücken', + 'settings.profiles.editor.removeAria': '{item} entfernen', + 'settings.profiles.editor.notFound': 'Profil nicht gefunden', + 'settings.profiles.editor.saving': 'Wird gespeichert…', + 'settings.profiles.editor.idRequired': 'Die Profil-Kennung darf nicht leer sein', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 46cd4c523..e742dc288 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5403,6 +5403,54 @@ const en: TranslationMap = { 'settings.agents.editor.create': 'Create agent', 'settings.agents.editor.saving': 'Saving…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Agent Profiles', + 'settings.profiles.subtitle': + 'Flavoured agents — each with its own soul, memory, connectors, and skills.', + 'settings.profiles.menuDesc': 'Create and manage agent profiles', + 'settings.profiles.new': 'New profile', + 'settings.profiles.empty': 'No agent profiles yet', + 'settings.profiles.loadError': "Couldn't load profiles", + 'settings.profiles.active': 'Active', + 'settings.profiles.setActive': 'Set as active', + 'settings.profiles.sourceBuiltIn': 'Built-in', + 'settings.profiles.sourceCustom': 'Custom', + 'settings.profiles.deleteConfirm': 'Delete this profile? This cannot be undone.', + 'settings.profiles.editor.createTitle': 'New profile', + 'settings.profiles.editor.editTitle': 'Edit profile', + 'settings.profiles.editor.name': 'Name', + 'settings.profiles.editor.id': 'ID', + 'settings.profiles.editor.idHint': 'Lowercase letters, numbers, and dashes.', + 'settings.profiles.editor.description': 'Description', + 'settings.profiles.editor.soul': 'Soul (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Custom identity for this profile. Empty falls back to the workspace SOUL.md.', + 'settings.profiles.editor.baseAgent': 'Base agent', + 'settings.profiles.editor.baseAgentHint': + 'Which agent definition this profile runs as (e.g. orchestrator).', + 'settings.profiles.editor.model': 'Model', + 'settings.profiles.editor.modelHint': 'Optional model override. Empty inherits the default.', + 'settings.profiles.editor.temperature': 'Temperature', + 'settings.profiles.editor.systemPromptSuffix': 'System prompt suffix', + 'settings.profiles.editor.agentConversations': 'Recall agent conversations', + 'settings.profiles.editor.agentConversationsHint': + 'Include prior-chat and cross-chat memory in this profile’s context.', + 'settings.profiles.editor.memorySources': 'Memory sources', + 'settings.profiles.editor.memorySourcesHint': 'Memory sources this profile recalls from.', + 'settings.profiles.editor.connectors': 'Connectors', + 'settings.profiles.editor.connectorsHint': 'Composio toolkits this profile can use.', + 'settings.profiles.editor.skills': 'Skills', + 'settings.profiles.editor.skillsHint': 'Workflows this profile can list and run.', + 'settings.profiles.editor.mcpServers': 'MCP servers', + 'settings.profiles.editor.mcpServersHint': 'MCP servers this profile can reach.', + 'settings.profiles.editor.all': 'All', + 'settings.profiles.editor.selected': 'Selected', + 'settings.profiles.editor.addPlaceholder': 'Type an id, press Enter', + 'settings.profiles.editor.removeAria': 'Remove {item}', + 'settings.profiles.editor.notFound': 'Profile not found', + 'settings.profiles.editor.idRequired': 'Profile id cannot be empty', + 'settings.profiles.editor.saving': 'Saving…', + // ── Agent Workflows ────────────────────────────────────────────────────── 'nav.workflows': 'Workflows', 'workflows.title': 'Workflows', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 95b1b5222..0ca155c52 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5466,6 +5466,57 @@ const messages: TranslationMap = { 'notch.speaking': 'Hablando…', 'notch.transcribing': 'Transcribiendo…', 'notch.executing': 'Ejecutando…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Perfiles de agente', + 'settings.profiles.subtitle': + 'Agentes con personalidad: cada uno con su propia alma, memoria, conectores y habilidades.', + 'settings.profiles.menuDesc': 'Crea y gestiona perfiles de agente', + 'settings.profiles.new': 'Nuevo perfil', + 'settings.profiles.empty': 'Aún no hay perfiles de agente', + 'settings.profiles.loadError': 'No se pudieron cargar los perfiles', + 'settings.profiles.active': 'Activo', + 'settings.profiles.setActive': 'Establecer como activo', + 'settings.profiles.sourceBuiltIn': 'Integrado', + 'settings.profiles.sourceCustom': 'Personalizado', + 'settings.profiles.deleteConfirm': '¿Eliminar este perfil? Esta acción no se puede deshacer.', + 'settings.profiles.editor.createTitle': 'Nuevo perfil', + 'settings.profiles.editor.editTitle': 'Editar perfil', + 'settings.profiles.editor.name': 'Nombre', + 'settings.profiles.editor.id': 'Identificador', + 'settings.profiles.editor.idHint': 'Solo letras minúsculas, números y guiones.', + 'settings.profiles.editor.description': 'Descripción', + 'settings.profiles.editor.soul': 'Alma (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Identidad personalizada de este perfil. Si se deja vacío, se usa el SOUL.md del espacio de trabajo.', + 'settings.profiles.editor.baseAgent': 'Agente base', + 'settings.profiles.editor.baseAgentHint': + 'Con qué definición de agente se ejecuta este perfil (por ejemplo, orchestrator).', + 'settings.profiles.editor.model': 'Modelo', + 'settings.profiles.editor.modelHint': + 'Anulación de modelo opcional. Si se deja vacío, hereda el predeterminado.', + 'settings.profiles.editor.temperature': 'Temperatura', + 'settings.profiles.editor.systemPromptSuffix': 'Sufijo del mensaje del sistema', + 'settings.profiles.editor.agentConversations': 'Recordar conversaciones del agente', + 'settings.profiles.editor.agentConversationsHint': + 'Incluir memoria de chats anteriores y entre chats en el contexto de este perfil.', + 'settings.profiles.editor.memorySources': 'Fuentes de memoria', + 'settings.profiles.editor.memorySourcesHint': + 'Fuentes de memoria de las que recuerda este perfil.', + 'settings.profiles.editor.connectors': 'Conectores', + 'settings.profiles.editor.connectorsHint': + 'Kits de herramientas de Composio que puede usar este perfil.', + 'settings.profiles.editor.skills': 'Habilidades', + 'settings.profiles.editor.skillsHint': + 'Flujos de trabajo que este perfil puede listar y ejecutar.', + 'settings.profiles.editor.mcpServers': 'Servidores MCP', + 'settings.profiles.editor.mcpServersHint': 'Servidores MCP a los que puede acceder este perfil.', + 'settings.profiles.editor.all': 'Todos', + 'settings.profiles.editor.selected': 'Seleccionados', + 'settings.profiles.editor.addPlaceholder': 'Escribe un identificador y pulsa Intro', + 'settings.profiles.editor.removeAria': 'Quitar {item}', + 'settings.profiles.editor.notFound': 'Perfil no encontrado', + 'settings.profiles.editor.saving': 'Guardando…', + 'settings.profiles.editor.idRequired': 'El identificador del perfil no puede estar vacío', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e1fb283f1..aa29d7d76 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5484,6 +5484,55 @@ const messages: TranslationMap = { 'notch.speaking': 'Je parle…', 'notch.transcribing': 'Transcription…', 'notch.executing': "J'exécute…", + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': "Profils d'agent", + 'settings.profiles.subtitle': + 'Des agents avec du caractère — chacun avec sa propre âme, mémoire, connecteurs et compétences.', + 'settings.profiles.menuDesc': "Créez et gérez les profils d'agent", + 'settings.profiles.new': 'Nouveau profil', + 'settings.profiles.empty': "Aucun profil d'agent pour le moment", + 'settings.profiles.loadError': 'Impossible de charger les profils', + 'settings.profiles.active': 'Actif', + 'settings.profiles.setActive': 'Définir comme actif', + 'settings.profiles.sourceBuiltIn': 'Intégré', + 'settings.profiles.sourceCustom': 'Personnalisé', + 'settings.profiles.deleteConfirm': 'Supprimer ce profil ? Cette action est irréversible.', + 'settings.profiles.editor.createTitle': 'Nouveau profil', + 'settings.profiles.editor.editTitle': 'Modifier le profil', + 'settings.profiles.editor.name': 'Nom', + 'settings.profiles.editor.id': 'Identifiant', + 'settings.profiles.editor.idHint': 'Lettres minuscules, chiffres et tirets uniquement.', + 'settings.profiles.editor.description': 'Description', + 'settings.profiles.editor.soul': 'Âme (SOUL.md)', + 'settings.profiles.editor.soulHint': + "Identité personnalisée de ce profil. Vide, on revient au SOUL.md de l'espace de travail.", + 'settings.profiles.editor.baseAgent': 'Agent de base', + 'settings.profiles.editor.baseAgentHint': + "Définition d'agent sous laquelle ce profil s'exécute (par ex. orchestrator).", + 'settings.profiles.editor.model': 'Modèle', + 'settings.profiles.editor.modelHint': + 'Remplacement de modèle facultatif. Vide, hérite de la valeur par défaut.', + 'settings.profiles.editor.temperature': 'Température', + 'settings.profiles.editor.systemPromptSuffix': 'Suffixe du message système', + 'settings.profiles.editor.agentConversations': "Rappeler les conversations de l'agent", + 'settings.profiles.editor.agentConversationsHint': + 'Inclure la mémoire des discussions précédentes et croisées dans le contexte de ce profil.', + 'settings.profiles.editor.memorySources': 'Sources de mémoire', + 'settings.profiles.editor.memorySourcesHint': 'Sources de mémoire que ce profil rappelle.', + 'settings.profiles.editor.connectors': 'Connecteurs', + 'settings.profiles.editor.connectorsHint': + 'Boîtes à outils Composio que ce profil peut utiliser.', + 'settings.profiles.editor.skills': 'Compétences', + 'settings.profiles.editor.skillsHint': 'Flux de travail que ce profil peut lister et exécuter.', + 'settings.profiles.editor.mcpServers': 'Serveurs MCP', + 'settings.profiles.editor.mcpServersHint': 'Serveurs MCP que ce profil peut atteindre.', + 'settings.profiles.editor.all': 'Tous', + 'settings.profiles.editor.selected': 'Sélectionnés', + 'settings.profiles.editor.addPlaceholder': 'Saisissez un identifiant, puis Entrée', + 'settings.profiles.editor.removeAria': 'Retirer {item}', + 'settings.profiles.editor.notFound': 'Profil introuvable', + 'settings.profiles.editor.saving': 'Enregistrement…', + 'settings.profiles.editor.idRequired': "L'identifiant du profil ne peut pas être vide", }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 86f318732..54df975cd 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5354,6 +5354,54 @@ const messages: TranslationMap = { 'notch.speaking': 'बोल रहा हूं…', 'notch.transcribing': 'ट्रांसक्राइब कर रहा हूं…', 'notch.executing': 'चला रहा हूं…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'एजेंट प्रोफ़ाइल', + 'settings.profiles.subtitle': 'विशिष्ट एजेंट — हर एक की अपनी आत्मा, स्मृति, कनेक्टर और कौशल।', + 'settings.profiles.menuDesc': 'एजेंट प्रोफ़ाइल बनाएँ और प्रबंधित करें', + 'settings.profiles.new': 'नई प्रोफ़ाइल', + 'settings.profiles.empty': 'अभी कोई एजेंट प्रोफ़ाइल नहीं', + 'settings.profiles.loadError': 'प्रोफ़ाइल लोड नहीं हो सकीं', + 'settings.profiles.active': 'सक्रिय', + 'settings.profiles.setActive': 'सक्रिय बनाएँ', + 'settings.profiles.sourceBuiltIn': 'अंतर्निहित', + 'settings.profiles.sourceCustom': 'कस्टम', + 'settings.profiles.deleteConfirm': 'यह प्रोफ़ाइल हटाएँ? इसे पूर्ववत नहीं किया जा सकता।', + 'settings.profiles.editor.createTitle': 'नई प्रोफ़ाइल', + 'settings.profiles.editor.editTitle': 'प्रोफ़ाइल संपादित करें', + 'settings.profiles.editor.name': 'नाम', + 'settings.profiles.editor.id': 'आईडी', + 'settings.profiles.editor.idHint': 'केवल छोटे अक्षर, अंक और डैश।', + 'settings.profiles.editor.description': 'विवरण', + 'settings.profiles.editor.soul': 'आत्मा (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'इस प्रोफ़ाइल की कस्टम पहचान। खाली होने पर वर्कस्पेस की SOUL.md पर लौटता है।', + 'settings.profiles.editor.baseAgent': 'आधार एजेंट', + 'settings.profiles.editor.baseAgentHint': + 'यह प्रोफ़ाइल किस एजेंट परिभाषा के रूप में चलती है (जैसे orchestrator)।', + 'settings.profiles.editor.model': 'मॉडल', + 'settings.profiles.editor.modelHint': + 'वैकल्पिक मॉडल ओवरराइड। खाली होने पर डिफ़ॉल्ट विरासत में लेता है।', + 'settings.profiles.editor.temperature': 'तापमान', + 'settings.profiles.editor.systemPromptSuffix': 'सिस्टम प्रॉम्प्ट प्रत्यय', + 'settings.profiles.editor.agentConversations': 'एजेंट वार्तालाप स्मरण करें', + 'settings.profiles.editor.agentConversationsHint': + 'इस प्रोफ़ाइल के संदर्भ में पिछली और क्रॉस-चैट स्मृति शामिल करें।', + 'settings.profiles.editor.memorySources': 'स्मृति स्रोत', + 'settings.profiles.editor.memorySourcesHint': 'इस प्रोफ़ाइल के स्मरण के स्रोत।', + 'settings.profiles.editor.connectors': 'कनेक्टर', + 'settings.profiles.editor.connectorsHint': 'इस प्रोफ़ाइल द्वारा उपयोग योग्य Composio टूलकिट।', + 'settings.profiles.editor.skills': 'कौशल', + 'settings.profiles.editor.skillsHint': + 'इस प्रोफ़ाइल द्वारा सूचीबद्ध और चलाए जा सकने वाले वर्कफ़्लो।', + 'settings.profiles.editor.mcpServers': 'MCP सर्वर', + 'settings.profiles.editor.mcpServersHint': 'इस प्रोफ़ाइल द्वारा पहुँचने योग्य MCP सर्वर।', + 'settings.profiles.editor.all': 'सभी', + 'settings.profiles.editor.selected': 'चयनित', + 'settings.profiles.editor.addPlaceholder': 'आईडी टाइप करें, एंटर दबाएँ', + 'settings.profiles.editor.removeAria': '{item} हटाएँ', + 'settings.profiles.editor.notFound': 'प्रोफ़ाइल नहीं मिली', + 'settings.profiles.editor.saving': 'सहेजा जा रहा है…', + 'settings.profiles.editor.idRequired': 'प्रोफ़ाइल आईडी खाली नहीं हो सकती', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a5560ff23..0d83fcc2a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5372,6 +5372,54 @@ const messages: TranslationMap = { 'notch.speaking': 'Berbicara…', 'notch.transcribing': 'Mentranskrip…', 'notch.executing': 'Mengeksekusi…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Profil Agen', + 'settings.profiles.subtitle': + 'Agen dengan kepribadian — masing-masing dengan jiwa, memori, konektor, dan keterampilannya sendiri.', + 'settings.profiles.menuDesc': 'Buat dan kelola profil agen', + 'settings.profiles.new': 'Profil baru', + 'settings.profiles.empty': 'Belum ada profil agen', + 'settings.profiles.loadError': 'Tidak dapat memuat profil', + 'settings.profiles.active': 'Aktif', + 'settings.profiles.setActive': 'Jadikan aktif', + 'settings.profiles.sourceBuiltIn': 'Bawaan', + 'settings.profiles.sourceCustom': 'Khusus', + 'settings.profiles.deleteConfirm': 'Hapus profil ini? Tindakan ini tidak dapat dibatalkan.', + 'settings.profiles.editor.createTitle': 'Profil baru', + 'settings.profiles.editor.editTitle': 'Edit profil', + 'settings.profiles.editor.name': 'Nama', + 'settings.profiles.editor.id': 'ID', + 'settings.profiles.editor.idHint': 'Hanya huruf kecil, angka, dan tanda hubung.', + 'settings.profiles.editor.description': 'Deskripsi', + 'settings.profiles.editor.soul': 'Jiwa (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Identitas khusus untuk profil ini. Kosong akan kembali ke SOUL.md ruang kerja.', + 'settings.profiles.editor.baseAgent': 'Agen dasar', + 'settings.profiles.editor.baseAgentHint': + 'Definisi agen yang dijalankan profil ini (mis. orchestrator).', + 'settings.profiles.editor.model': 'Model', + 'settings.profiles.editor.modelHint': 'Penggantian model opsional. Kosong akan mewarisi default.', + 'settings.profiles.editor.temperature': 'Temperatur', + 'settings.profiles.editor.systemPromptSuffix': 'Sufiks prompt sistem', + 'settings.profiles.editor.agentConversations': 'Ingat percakapan agen', + 'settings.profiles.editor.agentConversationsHint': + 'Sertakan memori obrolan sebelumnya dan antar-obrolan dalam konteks profil ini.', + 'settings.profiles.editor.memorySources': 'Sumber memori', + 'settings.profiles.editor.memorySourcesHint': 'Sumber memori yang diingat profil ini.', + 'settings.profiles.editor.connectors': 'Konektor', + 'settings.profiles.editor.connectorsHint': 'Toolkit Composio yang dapat digunakan profil ini.', + 'settings.profiles.editor.skills': 'Keterampilan', + 'settings.profiles.editor.skillsHint': + 'Alur kerja yang dapat didaftar dan dijalankan profil ini.', + 'settings.profiles.editor.mcpServers': 'Server MCP', + 'settings.profiles.editor.mcpServersHint': 'Server MCP yang dapat dijangkau profil ini.', + 'settings.profiles.editor.all': 'Semua', + 'settings.profiles.editor.selected': 'Terpilih', + 'settings.profiles.editor.addPlaceholder': 'Ketik id, tekan Enter', + 'settings.profiles.editor.removeAria': 'Hapus {item}', + 'settings.profiles.editor.notFound': 'Profil tidak ditemukan', + 'settings.profiles.editor.saving': 'Menyimpan…', + 'settings.profiles.editor.idRequired': 'ID profil tidak boleh kosong', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 6a83e706c..279ee2d99 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5458,6 +5458,55 @@ const messages: TranslationMap = { 'notch.speaking': 'Parlo…', 'notch.transcribing': 'Trascrizione…', 'notch.executing': 'Eseguendo…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Profili agente', + 'settings.profiles.subtitle': + 'Agenti con carattere: ognuno con la propria anima, memoria, connettori e competenze.', + 'settings.profiles.menuDesc': 'Crea e gestisci i profili agente', + 'settings.profiles.new': 'Nuovo profilo', + 'settings.profiles.empty': 'Ancora nessun profilo agente', + 'settings.profiles.loadError': 'Impossibile caricare i profili', + 'settings.profiles.active': 'Attivo', + 'settings.profiles.setActive': 'Imposta come attivo', + 'settings.profiles.sourceBuiltIn': 'Integrato', + 'settings.profiles.sourceCustom': 'Personalizzato', + 'settings.profiles.deleteConfirm': 'Eliminare questo profilo? Operazione irreversibile.', + 'settings.profiles.editor.createTitle': 'Nuovo profilo', + 'settings.profiles.editor.editTitle': 'Modifica profilo', + 'settings.profiles.editor.name': 'Nome', + 'settings.profiles.editor.id': 'Identificativo', + 'settings.profiles.editor.idHint': 'Solo lettere minuscole, numeri e trattini.', + 'settings.profiles.editor.description': 'Descrizione', + 'settings.profiles.editor.soul': 'Anima (SOUL.md)', + 'settings.profiles.editor.soulHint': + "Identità personalizzata di questo profilo. Vuoto ricorre al SOUL.md dell'area di lavoro.", + 'settings.profiles.editor.baseAgent': 'Agente di base', + 'settings.profiles.editor.baseAgentHint': + 'Con quale definizione di agente viene eseguito questo profilo (ad es. orchestrator).', + 'settings.profiles.editor.model': 'Modello', + 'settings.profiles.editor.modelHint': + 'Override del modello opzionale. Vuoto eredita il valore predefinito.', + 'settings.profiles.editor.temperature': 'Temperatura', + 'settings.profiles.editor.systemPromptSuffix': 'Suffisso del prompt di sistema', + 'settings.profiles.editor.agentConversations': "Richiamare le conversazioni dell'agente", + 'settings.profiles.editor.agentConversationsHint': + 'Includere la memoria delle chat precedenti e incrociate nel contesto di questo profilo.', + 'settings.profiles.editor.memorySources': 'Fonti di memoria', + 'settings.profiles.editor.memorySourcesHint': 'Fonti di memoria da cui questo profilo richiama.', + 'settings.profiles.editor.connectors': 'Connettori', + 'settings.profiles.editor.connectorsHint': 'Toolkit Composio utilizzabili da questo profilo.', + 'settings.profiles.editor.skills': 'Competenze', + 'settings.profiles.editor.skillsHint': + 'Flussi di lavoro che questo profilo può elencare ed eseguire.', + 'settings.profiles.editor.mcpServers': 'Server MCP', + 'settings.profiles.editor.mcpServersHint': 'Server MCP raggiungibili da questo profilo.', + 'settings.profiles.editor.all': 'Tutti', + 'settings.profiles.editor.selected': 'Selezionati', + 'settings.profiles.editor.addPlaceholder': 'Digita un identificativo e premi Invio', + 'settings.profiles.editor.removeAria': 'Rimuovi {item}', + 'settings.profiles.editor.notFound': 'Profilo non trovato', + 'settings.profiles.editor.saving': 'Salvataggio…', + 'settings.profiles.editor.idRequired': "L'identificativo del profilo non può essere vuoto", }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 14e112bbc..6d40152be 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5299,6 +5299,53 @@ const messages: TranslationMap = { 'notch.speaking': '말하는 중…', 'notch.transcribing': '변환 중…', 'notch.executing': '실행 중…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': '에이전트 프로필', + 'settings.profiles.subtitle': + '개성 있는 에이전트 — 각자 고유한 영혼, 기억, 커넥터, 기술을 갖습니다.', + 'settings.profiles.menuDesc': '에이전트 프로필 만들기 및 관리', + 'settings.profiles.new': '새 프로필', + 'settings.profiles.empty': '아직 에이전트 프로필이 없습니다', + 'settings.profiles.loadError': '프로필을 불러오지 못했습니다', + 'settings.profiles.active': '활성', + 'settings.profiles.setActive': '활성으로 설정', + 'settings.profiles.sourceBuiltIn': '기본 제공', + 'settings.profiles.sourceCustom': '사용자 지정', + 'settings.profiles.deleteConfirm': '이 프로필을 삭제할까요? 되돌릴 수 없습니다.', + 'settings.profiles.editor.createTitle': '새 프로필', + 'settings.profiles.editor.editTitle': '프로필 편집', + 'settings.profiles.editor.name': '이름', + 'settings.profiles.editor.id': '식별자', + 'settings.profiles.editor.idHint': '소문자, 숫자, 하이픈만 사용할 수 있습니다.', + 'settings.profiles.editor.description': '설명', + 'settings.profiles.editor.soul': '영혼 (SOUL.md)', + 'settings.profiles.editor.soulHint': + '이 프로필의 사용자 지정 정체성. 비워 두면 작업 공간의 SOUL.md로 대체됩니다.', + 'settings.profiles.editor.baseAgent': '기본 에이전트', + 'settings.profiles.editor.baseAgentHint': + '이 프로필이 어떤 에이전트 정의로 실행되는지 (예: orchestrator).', + 'settings.profiles.editor.model': '모델', + 'settings.profiles.editor.modelHint': '선택적 모델 재정의. 비워 두면 기본값을 상속합니다.', + 'settings.profiles.editor.temperature': '온도', + 'settings.profiles.editor.systemPromptSuffix': '시스템 프롬프트 접미사', + 'settings.profiles.editor.agentConversations': '에이전트 대화 회상', + 'settings.profiles.editor.agentConversationsHint': + '이 프로필의 컨텍스트에 이전 및 교차 채팅 기억을 포함합니다.', + 'settings.profiles.editor.memorySources': '기억 소스', + 'settings.profiles.editor.memorySourcesHint': '이 프로필이 회상하는 기억 소스.', + 'settings.profiles.editor.connectors': '커넥터', + 'settings.profiles.editor.connectorsHint': '이 프로필이 사용할 수 있는 Composio 도구 모음.', + 'settings.profiles.editor.skills': '기술', + 'settings.profiles.editor.skillsHint': '이 프로필이 나열하고 실행할 수 있는 워크플로.', + 'settings.profiles.editor.mcpServers': 'MCP 서버', + 'settings.profiles.editor.mcpServersHint': '이 프로필이 접근할 수 있는 MCP 서버.', + 'settings.profiles.editor.all': '전체', + 'settings.profiles.editor.selected': '선택됨', + 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', + 'settings.profiles.editor.removeAria': '{item} 제거', + 'settings.profiles.editor.notFound': '프로필을 찾을 수 없습니다', + 'settings.profiles.editor.saving': '저장 중…', + 'settings.profiles.editor.idRequired': '프로필 식별자는 비워 둘 수 없습니다', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 0c1f0c57f..564054654 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5440,6 +5440,55 @@ const messages: TranslationMap = { 'notch.speaking': 'Mówię…', 'notch.transcribing': 'Transkrybuję…', 'notch.executing': 'Wykonuję…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Profile agenta', + 'settings.profiles.subtitle': + 'Agenci z charakterem — każdy z własną duszą, pamięcią, łącznikami i umiejętnościami.', + 'settings.profiles.menuDesc': 'Twórz profile agenta i zarządzaj nimi', + 'settings.profiles.new': 'Nowy profil', + 'settings.profiles.empty': 'Brak profili agenta', + 'settings.profiles.loadError': 'Nie udało się wczytać profili', + 'settings.profiles.active': 'Aktywny', + 'settings.profiles.setActive': 'Ustaw jako aktywny', + 'settings.profiles.sourceBuiltIn': 'Wbudowany', + 'settings.profiles.sourceCustom': 'Niestandardowy', + 'settings.profiles.deleteConfirm': 'Usunąć ten profil? Tej operacji nie można cofnąć.', + 'settings.profiles.editor.createTitle': 'Nowy profil', + 'settings.profiles.editor.editTitle': 'Edytuj profil', + 'settings.profiles.editor.name': 'Nazwa', + 'settings.profiles.editor.id': 'Identyfikator', + 'settings.profiles.editor.idHint': 'Tylko małe litery, cyfry i myślniki.', + 'settings.profiles.editor.description': 'Opis', + 'settings.profiles.editor.soul': 'Dusza (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Niestandardowa tożsamość tego profilu. Puste pole powoduje powrót do SOUL.md obszaru roboczego.', + 'settings.profiles.editor.baseAgent': 'Agent bazowy', + 'settings.profiles.editor.baseAgentHint': + 'Z jaką definicją agenta działa ten profil (np. orchestrator).', + 'settings.profiles.editor.model': 'Model', + 'settings.profiles.editor.modelHint': 'Opcjonalne zastąpienie modelu. Puste dziedziczy domyślny.', + 'settings.profiles.editor.temperature': 'Temperatura', + 'settings.profiles.editor.systemPromptSuffix': 'Sufiks monitu systemowego', + 'settings.profiles.editor.agentConversations': 'Przywoływanie rozmów agenta', + 'settings.profiles.editor.agentConversationsHint': + 'Uwzględnij pamięć wcześniejszych i międzyczatowych rozmów w kontekście tego profilu.', + 'settings.profiles.editor.memorySources': 'Źródła pamięci', + 'settings.profiles.editor.memorySourcesHint': 'Źródła pamięci, z których przywołuje ten profil.', + 'settings.profiles.editor.connectors': 'Łączniki', + 'settings.profiles.editor.connectorsHint': + 'Zestawy narzędzi Composio, których może używać ten profil.', + 'settings.profiles.editor.skills': 'Umiejętności', + 'settings.profiles.editor.skillsHint': + 'Przepływy pracy, które ten profil może wyświetlać i uruchamiać.', + 'settings.profiles.editor.mcpServers': 'Serwery MCP', + 'settings.profiles.editor.mcpServersHint': 'Serwery MCP, do których ma dostęp ten profil.', + 'settings.profiles.editor.all': 'Wszystkie', + 'settings.profiles.editor.selected': 'Wybrane', + 'settings.profiles.editor.addPlaceholder': 'Wpisz identyfikator i naciśnij Enter', + 'settings.profiles.editor.removeAria': 'Usuń {item}', + 'settings.profiles.editor.notFound': 'Nie znaleziono profilu', + 'settings.profiles.editor.saving': 'Zapisywanie…', + 'settings.profiles.editor.idRequired': 'Identyfikator profilu nie może być pusty', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 2946414ec..3e0f76aae 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5449,6 +5449,55 @@ const messages: TranslationMap = { 'notch.speaking': 'Falando…', 'notch.transcribing': 'Transcrevendo…', 'notch.executing': 'Executando…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Perfis de agente', + 'settings.profiles.subtitle': + 'Agentes com personalidade — cada um com sua própria alma, memória, conectores e habilidades.', + 'settings.profiles.menuDesc': 'Crie e gerencie perfis de agente', + 'settings.profiles.new': 'Novo perfil', + 'settings.profiles.empty': 'Ainda não há perfis de agente', + 'settings.profiles.loadError': 'Não foi possível carregar os perfis', + 'settings.profiles.active': 'Ativo', + 'settings.profiles.setActive': 'Definir como ativo', + 'settings.profiles.sourceBuiltIn': 'Integrado', + 'settings.profiles.sourceCustom': 'Personalizado', + 'settings.profiles.deleteConfirm': 'Excluir este perfil? Isso não pode ser desfeito.', + 'settings.profiles.editor.createTitle': 'Novo perfil', + 'settings.profiles.editor.editTitle': 'Editar perfil', + 'settings.profiles.editor.name': 'Nome', + 'settings.profiles.editor.id': 'Identificador', + 'settings.profiles.editor.idHint': 'Apenas letras minúsculas, números e hífens.', + 'settings.profiles.editor.description': 'Descrição', + 'settings.profiles.editor.soul': 'Alma (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Identidade personalizada deste perfil. Vazio recorre ao SOUL.md do espaço de trabalho.', + 'settings.profiles.editor.baseAgent': 'Agente base', + 'settings.profiles.editor.baseAgentHint': + 'Com qual definição de agente este perfil é executado (por exemplo, orchestrator).', + 'settings.profiles.editor.model': 'Modelo', + 'settings.profiles.editor.modelHint': 'Substituição de modelo opcional. Vazio herda o padrão.', + 'settings.profiles.editor.temperature': 'Temperatura', + 'settings.profiles.editor.systemPromptSuffix': 'Sufixo do prompt do sistema', + 'settings.profiles.editor.agentConversations': 'Lembrar conversas do agente', + 'settings.profiles.editor.agentConversationsHint': + 'Incluir memória de conversas anteriores e entre conversas no contexto deste perfil.', + 'settings.profiles.editor.memorySources': 'Fontes de memória', + 'settings.profiles.editor.memorySourcesHint': 'Fontes de memória das quais este perfil recorda.', + 'settings.profiles.editor.connectors': 'Conectores', + 'settings.profiles.editor.connectorsHint': + 'Kits de ferramentas do Composio que este perfil pode usar.', + 'settings.profiles.editor.skills': 'Habilidades', + 'settings.profiles.editor.skillsHint': + 'Fluxos de trabalho que este perfil pode listar e executar.', + 'settings.profiles.editor.mcpServers': 'Servidores MCP', + 'settings.profiles.editor.mcpServersHint': 'Servidores MCP que este perfil pode acessar.', + 'settings.profiles.editor.all': 'Todos', + 'settings.profiles.editor.selected': 'Selecionados', + 'settings.profiles.editor.addPlaceholder': 'Digite um identificador e pressione Enter', + 'settings.profiles.editor.removeAria': 'Remover {item}', + 'settings.profiles.editor.notFound': 'Perfil não encontrado', + 'settings.profiles.editor.saving': 'Salvando…', + 'settings.profiles.editor.idRequired': 'O identificador do perfil não pode estar vazio', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8ded184ac..a8d4ed7e5 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5408,6 +5408,58 @@ const messages: TranslationMap = { 'notch.speaking': 'Говорю…', 'notch.transcribing': 'Транскрибирую…', 'notch.executing': 'Выполняю…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': 'Профили агента', + 'settings.profiles.subtitle': + 'Агенты с характером — у каждого своя душа, память, коннекторы и навыки.', + 'settings.profiles.menuDesc': 'Создавайте профили агента и управляйте ими', + 'settings.profiles.new': 'Новый профиль', + 'settings.profiles.empty': 'Профилей агента пока нет', + 'settings.profiles.loadError': 'Не удалось загрузить профили', + 'settings.profiles.active': 'Активный', + 'settings.profiles.setActive': 'Сделать активным', + 'settings.profiles.sourceBuiltIn': 'Встроенный', + 'settings.profiles.sourceCustom': 'Пользовательский', + 'settings.profiles.deleteConfirm': 'Удалить этот профиль? Это действие нельзя отменить.', + 'settings.profiles.editor.createTitle': 'Новый профиль', + 'settings.profiles.editor.editTitle': 'Изменить профиль', + 'settings.profiles.editor.name': 'Имя', + 'settings.profiles.editor.id': 'Идентификатор', + 'settings.profiles.editor.idHint': 'Только строчные буквы, цифры и дефисы.', + 'settings.profiles.editor.description': 'Описание', + 'settings.profiles.editor.soul': 'Душа (SOUL.md)', + 'settings.profiles.editor.soulHint': + 'Пользовательская идентичность этого профиля. Пусто — используется SOUL.md рабочей области.', + 'settings.profiles.editor.baseAgent': 'Базовый агент', + 'settings.profiles.editor.baseAgentHint': + 'Под каким определением агента работает этот профиль (например, orchestrator).', + 'settings.profiles.editor.model': 'Модель', + 'settings.profiles.editor.modelHint': + 'Необязательная замена модели. Пусто — наследует значение по умолчанию.', + 'settings.profiles.editor.temperature': 'Температура', + 'settings.profiles.editor.systemPromptSuffix': 'Суффикс системного запроса', + 'settings.profiles.editor.agentConversations': 'Вспоминать разговоры агента', + 'settings.profiles.editor.agentConversationsHint': + 'Включать память прошлых и межчатовых разговоров в контекст этого профиля.', + 'settings.profiles.editor.memorySources': 'Источники памяти', + 'settings.profiles.editor.memorySourcesHint': + 'Источники памяти, из которых вспоминает этот профиль.', + 'settings.profiles.editor.connectors': 'Коннекторы', + 'settings.profiles.editor.connectorsHint': + 'Наборы инструментов Composio, которые может использовать этот профиль.', + 'settings.profiles.editor.skills': 'Навыки', + 'settings.profiles.editor.skillsHint': + 'Рабочие процессы, которые этот профиль может перечислять и запускать.', + 'settings.profiles.editor.mcpServers': 'Серверы MCP', + 'settings.profiles.editor.mcpServersHint': + 'Серверы MCP, к которым может обращаться этот профиль.', + 'settings.profiles.editor.all': 'Все', + 'settings.profiles.editor.selected': 'Выбранные', + 'settings.profiles.editor.addPlaceholder': 'Введите идентификатор и нажмите Enter', + 'settings.profiles.editor.removeAria': 'Удалить {item}', + 'settings.profiles.editor.notFound': 'Профиль не найден', + 'settings.profiles.editor.saving': 'Сохранение…', + 'settings.profiles.editor.idRequired': 'Идентификатор профиля не может быть пустым', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d4b2d6f55..c48f8a4a5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5086,6 +5086,49 @@ const messages: TranslationMap = { 'notch.speaking': '说话中…', 'notch.transcribing': '转录中…', 'notch.executing': '执行中…', + // ── Agent Profiles ─────────────────────────────────────────────────────── + 'settings.profiles.title': '智能体配置', + 'settings.profiles.subtitle': '风格化智能体——每个都有自己的灵魂、记忆、连接器和技能。', + 'settings.profiles.menuDesc': '创建并管理智能体配置', + 'settings.profiles.new': '新建配置', + 'settings.profiles.empty': '暂无智能体配置', + 'settings.profiles.loadError': '无法加载配置', + 'settings.profiles.active': '当前使用', + 'settings.profiles.setActive': '设为当前', + 'settings.profiles.sourceBuiltIn': '内置', + 'settings.profiles.sourceCustom': '自定义', + 'settings.profiles.deleteConfirm': '删除此配置?此操作无法撤销。', + 'settings.profiles.editor.createTitle': '新建配置', + 'settings.profiles.editor.editTitle': '编辑配置', + 'settings.profiles.editor.name': '名称', + 'settings.profiles.editor.id': '标识', + 'settings.profiles.editor.idHint': '仅限小写字母、数字和短横线。', + 'settings.profiles.editor.description': '描述', + 'settings.profiles.editor.soul': '灵魂(SOUL.md)', + 'settings.profiles.editor.soulHint': '此配置的自定义身份。留空则回退到工作区的 SOUL.md。', + 'settings.profiles.editor.baseAgent': '基础智能体', + 'settings.profiles.editor.baseAgentHint': '此配置运行所基于的智能体定义(例如 orchestrator)。', + 'settings.profiles.editor.model': '模型', + 'settings.profiles.editor.modelHint': '可选的模型覆盖。留空则继承默认值。', + 'settings.profiles.editor.temperature': '温度', + 'settings.profiles.editor.systemPromptSuffix': '系统提示词后缀', + 'settings.profiles.editor.agentConversations': '召回智能体对话', + 'settings.profiles.editor.agentConversationsHint': '在此配置的上下文中纳入此前对话和跨会话记忆。', + 'settings.profiles.editor.memorySources': '记忆来源', + 'settings.profiles.editor.memorySourcesHint': '此配置召回所使用的记忆来源。', + 'settings.profiles.editor.connectors': '连接器', + 'settings.profiles.editor.connectorsHint': '此配置可使用的 Composio 工具包。', + 'settings.profiles.editor.skills': '技能', + 'settings.profiles.editor.skillsHint': '此配置可列出并运行的工作流。', + 'settings.profiles.editor.mcpServers': 'MCP 服务器', + 'settings.profiles.editor.mcpServersHint': '此配置可访问的 MCP 服务器。', + 'settings.profiles.editor.all': '全部', + 'settings.profiles.editor.selected': '指定', + 'settings.profiles.editor.addPlaceholder': '输入标识后按回车', + 'settings.profiles.editor.removeAria': '移除 {item}', + 'settings.profiles.editor.notFound': '未找到配置', + 'settings.profiles.editor.saving': '正在保存…', + 'settings.profiles.editor.idRequired': '配置标识不能为空', }; export default messages; diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 89cb97ff1..0c59075ca 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -41,6 +41,8 @@ import NotificationsTabbedPanel from '../components/settings/panels/Notification import PermissionsPanel from '../components/settings/panels/PermissionsPanel'; import PersonaPanel from '../components/settings/panels/PersonaPanel'; import PrivacyPanel from '../components/settings/panels/PrivacyPanel'; +import ProfileEditorPage from '../components/settings/panels/ProfileEditorPage'; +import ProfilesPanel from '../components/settings/panels/ProfilesPanel'; import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel'; import SandboxSettingsPanel from '../components/settings/panels/SandboxSettingsPanel'; import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel'; @@ -569,6 +571,9 @@ const Settings = () => { )} /> )} /> )} /> + )} /> + )} /> + )} /> )} /> )} /> {/* Developer Options */} diff --git a/app/src/services/api/agentProfilesApi.test.ts b/app/src/services/api/agentProfilesApi.test.ts index 188358cfa..ffaa6ab35 100644 --- a/app/src/services/api/agentProfilesApi.test.ts +++ b/app/src/services/api/agentProfilesApi.test.ts @@ -28,14 +28,14 @@ describe('agentProfilesApi', () => { const { agentProfilesApi } = await import('./agentProfilesApi'); await expect(agentProfilesApi.list()).resolves.toEqual(response); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.agent_profiles_list' }); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.profiles_list' }); mockCallCoreRpc.mockResolvedValueOnce({ data: { ...response, activeProfileId: 'research' } }); await expect(agentProfilesApi.select('research')).resolves.toMatchObject({ activeProfileId: 'research', }); expect(mockCallCoreRpc).toHaveBeenLastCalledWith({ - method: 'openhuman.agent_profile_select', + method: 'openhuman.profiles_select', params: { profile_id: 'research' }, }); }); @@ -55,7 +55,7 @@ describe('agentProfilesApi', () => { const { agentProfilesApi } = await import('./agentProfilesApi'); await expect(agentProfilesApi.upsert(profile)).resolves.toEqual(response); expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.agent_profile_upsert', + method: 'openhuman.profiles_upsert', params: { profile }, }); @@ -64,7 +64,7 @@ describe('agentProfilesApi', () => { activeProfileId: 'default', }); expect(mockCallCoreRpc).toHaveBeenLastCalledWith({ - method: 'openhuman.agent_profile_delete', + method: 'openhuman.profiles_delete', params: { profile_id: 'custom' }, }); }); diff --git a/app/src/services/api/agentProfilesApi.ts b/app/src/services/api/agentProfilesApi.ts index 9787db97e..63f57920e 100644 --- a/app/src/services/api/agentProfilesApi.ts +++ b/app/src/services/api/agentProfilesApi.ts @@ -19,14 +19,14 @@ function unwrapEnvelope(response: Envelope | T): T { export const agentProfilesApi = { list: async (): Promise => { const response = await callCoreRpc>({ - method: 'openhuman.agent_profiles_list', + method: 'openhuman.profiles_list', }); return unwrapEnvelope(response); }, select: async (profileId: string): Promise => { const response = await callCoreRpc>({ - method: 'openhuman.agent_profile_select', + method: 'openhuman.profiles_select', params: { profile_id: profileId }, }); return unwrapEnvelope(response); @@ -34,7 +34,7 @@ export const agentProfilesApi = { upsert: async (profile: AgentProfile): Promise => { const response = await callCoreRpc>({ - method: 'openhuman.agent_profile_upsert', + method: 'openhuman.profiles_upsert', params: { profile }, }); return unwrapEnvelope(response); @@ -42,7 +42,7 @@ export const agentProfilesApi = { delete: async (profileId: string): Promise => { const response = await callCoreRpc>({ - method: 'openhuman.agent_profile_delete', + method: 'openhuman.profiles_delete', params: { profile_id: profileId }, }); return unwrapEnvelope(response); diff --git a/app/src/types/agentProfile.ts b/app/src/types/agentProfile.ts index 4b48f9c37..1ced4110c 100644 --- a/app/src/types/agentProfile.ts +++ b/app/src/types/agentProfile.ts @@ -12,7 +12,16 @@ export interface AgentProfile { voiceId?: string | null; soulMd?: string | null; soulMdPath?: string | null; + /** Composio toolkit slugs this profile can use. null/undefined = all. */ composioIntegrations?: string[] | null; + /** Memory-source entry ids this profile recalls from. null/undefined = all. */ + memorySources?: string[] | null; + /** Whether this profile recalls prior agent conversations. Default true. */ + includeAgentConversations?: boolean; + /** Skill/workflow ids this profile can list and run. null/undefined = all. */ + allowedSkills?: string[] | null; + /** MCP server names this profile can reach. null/undefined = all. */ + allowedMcpServers?: string[] | null; memoryDirSuffix?: string | null; isMaster?: boolean | null; sortOrder?: number | null; diff --git a/src/core/all.rs b/src/core/all.rs index 5b785b5eb..d4d2b5e0a 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -124,6 +124,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers()); // Agent definition and prompt inspection controllers.extend(crate::openhuman::agent::all_agent_registered_controllers()); + // Persistent agent profiles (flavours): name, soul, memory sources, skills, MCP, connectors. + controllers.extend(crate::openhuman::profiles::all_profiles_registered_controllers()); // User-facing agent registry: defaults, enablement, custom agents, tool policy. controllers .extend(crate::openhuman::agent_registry::all_agent_registry_registered_controllers()); @@ -336,6 +338,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas()); schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas()); schemas.extend(crate::openhuman::agent::all_agent_controller_schemas()); + schemas.extend(crate::openhuman::profiles::all_profiles_controller_schemas()); schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas()); schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas()); schemas.extend(crate::openhuman::health::all_health_controller_schemas()); diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 622f5f144..97ddc169f 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -142,7 +142,15 @@ impl Agent { .unwrap_or(config.default_temperature) ); - Self::build_session_agent_inner(config, agent_id, target_def.as_ref(), None, None, false) + Self::build_session_agent_inner( + config, + agent_id, + target_def.as_ref(), + None, + None, + false, + None, + ) } /// Same as [`Self::from_config_for_agent`] but also appends a @@ -175,6 +183,7 @@ impl Agent { Some(reflection_chunks), None, false, + None, ) } @@ -186,6 +195,7 @@ impl Agent { agent_id: &str, reflection_chunks: Option>, profile_prompt_suffix: Option, + profile: Option<&crate::openhuman::profiles::AgentProfile>, ) -> Result { let target_def: Option = match AgentDefinitionRegistry::global() { @@ -218,6 +228,7 @@ impl Agent { reflection_chunks, profile_prompt_suffix, false, + profile, ) } @@ -242,6 +253,7 @@ impl Agent { None, Some(prompt_suffix), true, + None, )?; let safe_name: String = juror_name .chars() @@ -282,6 +294,7 @@ impl Agent { /// the subconscious LLM cited when it produced the spawning /// reflection (#623). Empty / `None` is the default for normal chat /// threads — the section is omitted entirely. + #[allow(clippy::too_many_arguments)] fn build_session_agent_inner( config: &Config, agent_id: &str, @@ -289,7 +302,19 @@ impl Agent { reflection_chunks: Option>, profile_prompt_suffix: Option, read_only_tools_only: bool, + profile: Option<&crate::openhuman::profiles::AgentProfile>, ) -> Result { + if let Some(p) = profile { + tracing::debug!( + profile_id = %p.id, + include_agent_conversations = p.include_agent_conversations, + allowed_tools = p.allowed_tools.as_ref().map_or(0, |t| t.len()), + allowed_skills = p.allowed_skills.as_ref().map_or(0, |s| s.len()), + allowed_mcp_servers = p.allowed_mcp_servers.as_ref().map_or(0, |m| m.len()), + memory_sources = p.memory_sources.as_ref().map_or(0, |s| s.len()), + "[profiles] applying per-profile session gate" + ); + } let runtime: Arc = Arc::from(host_runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -317,6 +342,13 @@ impl Agent { &config.workspace_dir, )?); + // Per-profile skill (workflow) + MCP-server allowlists. `None` = all. + let profile_skill_allowlist: Option> = profile + .and_then(|p| p.allowed_skills.clone()) + .map(|v| v.into_iter().collect()); + let profile_mcp_allowlist: Option> = + profile.and_then(|p| p.allowed_mcp_servers.clone()); + let mut tools = tools::all_tools_with_runtime( Arc::new(config.clone()), &security, @@ -328,6 +360,8 @@ impl Agent { &config.action_dir, &config.agents, config, + profile_skill_allowlist.as_ref(), + profile_mcp_allowlist.as_deref(), ); // Filter tools by user preference stored in app state. @@ -595,7 +629,7 @@ impl Agent { suffix.chars().count() ); prompt_builder = prompt_builder.add_section(Box::new( - crate::openhuman::agent::profiles::AgentProfilePromptSection::new(suffix), + crate::openhuman::profiles::AgentProfilePromptSection::new(suffix), )); } @@ -716,6 +750,26 @@ impl Agent { // and then paying a repair pass on turn 1 just to recover the real // delegation surface. let prewarmed_integrations = crate::openhuman::composio::cached_active_integrations(config); + // Per-profile connector gate: scope the connected-integration view to the + // active profile's `composio_integrations` allowlist (None = all). This + // governs both the system-prompt "connected integrations" surface and the + // agent's `connected_integrations` field below, so a profile only ever + // sees the toolkits it was granted. + let prewarmed_integrations = match ( + prewarmed_integrations, + profile.and_then(|p| p.composio_integrations.as_deref()), + ) { + (Some(list), Some(allow)) => { + let filtered = crate::openhuman::profiles::filter_integrations(&list, Some(allow)); + tracing::debug!( + before = list.len(), + after = filtered.len(), + "[profiles] composio connectors scoped to profile allowlist" + ); + Some(filtered) + } + (other, _) => other, + }; let prewarmed_integrations_slice = prewarmed_integrations.as_deref().unwrap_or(&[]); // Resolve the per-agent delegation tool set and visible-tool @@ -1050,7 +1104,13 @@ impl Agent { .resolved_memory_limits() .max_memory_context_chars, ) - .with_workspace_dir(config.workspace_dir.clone()), + .with_workspace_dir(config.workspace_dir.clone()) + // Per-profile memory gate: when the active profile opts out + // of agent-conversation recall, suppress the prior-chat and + // cross-chat blocks. Defaults to on for None / unset. + .with_agent_conversations( + profile.map_or(true, |p| p.include_agent_conversations), + ), )) .prompt_builder(prompt_builder) .config(config.agent.clone()) diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index 38f5f79d3..462febcae 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -29,9 +29,7 @@ pub mod host_runtime; pub mod library; pub mod memory_loader; pub mod multimodal; -pub mod personality_paths; pub mod pformat; -pub mod profiles; pub mod progress; /// Prompt plumbing — types, section builders, and /// [`SystemPromptBuilder`](prompts::SystemPromptBuilder). Moved from diff --git a/src/openhuman/agent/schemas.rs b/src/openhuman/agent/schemas.rs index 854f4bdd3..d1b91bee1 100644 --- a/src/openhuman/agent/schemas.rs +++ b/src/openhuman/agent/schemas.rs @@ -4,7 +4,6 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent::profiles::{AgentProfile, AgentProfileStore}; use crate::openhuman::config::rpc as config_rpc; use crate::rpc::RpcOutcome; @@ -24,10 +23,6 @@ pub fn all_controller_schemas() -> Vec { schemas("get_definition"), schemas("reload_definitions"), schemas("triage_evaluate"), - schemas("profiles_list"), - schemas("profile_select"), - schemas("profile_upsert"), - schemas("profile_delete"), ] } @@ -61,22 +56,6 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("triage_evaluate"), handler: handle_triage_evaluate, }, - RegisteredController { - schema: schemas("profiles_list"), - handler: handle_profiles_list, - }, - RegisteredController { - schema: schemas("profile_select"), - handler: handle_profile_select, - }, - RegisteredController { - schema: schemas("profile_upsert"), - handler: handle_profile_upsert, - }, - RegisteredController { - schema: schemas("profile_delete"), - handler: handle_profile_delete, - }, ] } @@ -166,48 +145,6 @@ pub fn schemas(function: &str) -> ControllerSchema { ], outputs: vec![json_output("result", "Triage evaluation result.")], }, - "profiles_list" => ControllerSchema { - namespace: "agent", - function: "profiles_list", - description: "List persistent agent profiles and the active profile id.", - inputs: vec![], - outputs: vec![json_output("profiles", "Agent profile state payload.")], - }, - "profile_select" => ControllerSchema { - namespace: "agent", - function: "profile_select", - description: "Select the active persistent agent profile.", - inputs: vec![required_string("profile_id", "Agent profile id.")], - outputs: vec![json_output( - "profiles", - "Updated agent profile state payload.", - )], - }, - "profile_upsert" => ControllerSchema { - namespace: "agent", - function: "profile_upsert", - description: "Create or update an agent profile.", - inputs: vec![FieldSchema { - name: "profile", - ty: TypeSchema::Json, - comment: "Agent profile payload.", - required: true, - }], - outputs: vec![json_output( - "profiles", - "Updated agent profile state payload.", - )], - }, - "profile_delete" => ControllerSchema { - namespace: "agent", - function: "profile_delete", - description: "Delete a custom agent profile.", - inputs: vec![required_string("profile_id", "Agent profile id.")], - outputs: vec![json_output( - "profiles", - "Updated agent profile state payload.", - )], - }, _ => ControllerSchema { namespace: "agent", function: "unknown", @@ -223,21 +160,6 @@ pub fn schemas(function: &str) -> ControllerSchema { } } -#[derive(Debug, Deserialize)] -struct ProfileSelectParams { - profile_id: String, -} - -#[derive(Debug, Deserialize)] -struct ProfileUpsertParams { - profile: AgentProfile, -} - -#[derive(Debug, Deserialize)] -struct ProfileDeleteParams { - profile_id: String, -} - fn handle_chat(params: Map) -> ControllerFuture { Box::pin(async move { let p = deserialize_params::(params)?; @@ -439,206 +361,6 @@ fn handle_triage_evaluate(params: Map) -> ControllerFuture { }) } -fn handle_profiles_list(_params: Map) -> ControllerFuture { - Box::pin(async move { - let request_id = format!("profiles-list-{}", uuid::Uuid::new_v4()); - tracing::debug!( - request_id = %request_id, - "[rpc][agent][entry] profiles_list" - ); - let config = config_rpc::load_config_with_timeout().await.map_err(|e| { - tracing::debug!( - request_id = %request_id, - error = %e, - "[rpc][agent][error] profiles_list load_config" - ); - e - })?; - let state = AgentProfileStore::new(config.workspace_dir) - .load() - .map_err(|e| { - tracing::debug!( - request_id = %request_id, - error = %e, - "[rpc][agent][error] profiles_list load_store" - ); - e - })?; - tracing::debug!( - request_id = %request_id, - active_profile_id = %state.active_profile_id, - profile_count = state.profiles.len(), - "[rpc][agent][exit] profiles_list" - ); - Ok(serde_json::json!({ - "profiles": state.profiles, - "activeProfileId": state.active_profile_id, - })) - }) -} - -fn handle_profile_select(params: Map) -> ControllerFuture { - Box::pin(async move { - let request_id = format!("profile-select-{}", uuid::Uuid::new_v4()); - tracing::debug!( - request_id = %request_id, - "[rpc][agent][entry] profile_select" - ); - let p = deserialize_params::(params)?; - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - "[rpc][agent] profile_select params" - ); - let config = config_rpc::load_config_with_timeout().await.map_err(|e| { - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - error = %e, - "[rpc][agent][error] profile_select load_config" - ); - e - })?; - let state = AgentProfileStore::new(config.workspace_dir) - .select(&p.profile_id) - .map_err(|e| { - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - error = %e, - "[rpc][agent][error] profile_select store" - ); - e - })?; - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - active_profile_id = %state.active_profile_id, - "[rpc][agent][exit] profile_select" - ); - Ok(serde_json::json!({ - "profiles": state.profiles, - "activeProfileId": state.active_profile_id, - })) - }) -} - -fn handle_profile_upsert(params: Map) -> ControllerFuture { - Box::pin(async move { - let request_id = format!("profile-upsert-{}", uuid::Uuid::new_v4()); - tracing::debug!( - request_id = %request_id, - "[rpc][agent][entry] profile_upsert" - ); - let p = deserialize_params::(params)?; - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile.id, - agent_id = %p.profile.agent_id, - "[rpc][agent] profile_upsert params" - ); - if let Some(registry) = crate::openhuman::agent::harness::AgentDefinitionRegistry::global() - { - let agent_id = p.profile.agent_id.trim(); - if !agent_id.is_empty() && registry.get(agent_id).is_none() { - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile.id, - agent_id, - "[rpc][agent][error] profile_upsert unknown_agent" - ); - return Err(format!("agent definition '{agent_id}' not found")); - } - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile.id, - agent_id, - "[rpc][agent] profile_upsert registry_ok" - ); - } else { - tracing::debug!( - request_id = %request_id, - "[rpc][agent] profile_upsert registry_unavailable" - ); - } - let config = config_rpc::load_config_with_timeout().await.map_err(|e| { - tracing::debug!( - request_id = %request_id, - error = %e, - "[rpc][agent][error] profile_upsert load_config" - ); - e - })?; - let state = AgentProfileStore::new(config.workspace_dir) - .upsert(p.profile) - .map_err(|e| { - tracing::debug!( - request_id = %request_id, - error = %e, - "[rpc][agent][error] profile_upsert store" - ); - e - })?; - tracing::debug!( - request_id = %request_id, - active_profile_id = %state.active_profile_id, - profile_count = state.profiles.len(), - "[rpc][agent][exit] profile_upsert" - ); - Ok(serde_json::json!({ - "profiles": state.profiles, - "activeProfileId": state.active_profile_id, - })) - }) -} - -fn handle_profile_delete(params: Map) -> ControllerFuture { - Box::pin(async move { - let request_id = format!("profile-delete-{}", uuid::Uuid::new_v4()); - tracing::debug!( - request_id = %request_id, - "[rpc][agent][entry] profile_delete" - ); - let p = deserialize_params::(params)?; - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - "[rpc][agent] profile_delete params" - ); - let config = config_rpc::load_config_with_timeout().await.map_err(|e| { - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - error = %e, - "[rpc][agent][error] profile_delete load_config" - ); - e - })?; - let state = AgentProfileStore::new(config.workspace_dir) - .delete(&p.profile_id) - .map_err(|e| { - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - error = %e, - "[rpc][agent][error] profile_delete store" - ); - e - })?; - tracing::debug!( - request_id = %request_id, - profile_id = %p.profile_id, - active_profile_id = %state.active_profile_id, - profile_count = state.profiles.len(), - "[rpc][agent][exit] profile_delete" - ); - Ok(serde_json::json!({ - "profiles": state.profiles, - "activeProfileId": state.active_profile_id, - })) - }) -} - fn deserialize_params(params: Map) -> Result { serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) } @@ -687,8 +409,6 @@ fn to_json(outcome: RpcOutcome) -> Result mod tests { use super::*; use crate::core::TypeSchema; - use crate::openhuman::agent::profiles::DEFAULT_PROFILE_ID; - use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK; use serde_json::json; #[test] @@ -705,10 +425,6 @@ mod tests { "get_definition", "reload_definitions", "triage_evaluate", - "profiles_list", - "profile_select", - "profile_upsert", - "profile_delete", ] ); assert_eq!(schemas.len(), all_registered_controllers().len()); @@ -732,14 +448,6 @@ mod tests { .iter() .any(|input| input.name == "dry_run" && !input.required)); - let profiles = schemas("profiles_list"); - assert_eq!(profiles.inputs.len(), 0); - let profile_select = schemas("profile_select"); - assert!(profile_select - .inputs - .iter() - .any(|input| input.name == "profile_id" && input.required)); - let unknown = schemas("nope"); assert_eq!(unknown.function, "unknown"); assert_eq!(unknown.outputs[0].name, "error"); @@ -817,102 +525,4 @@ mod tests { to_json(RpcOutcome::new(json!({ "ok": true }), Vec::new())).expect("json outcome"); assert_eq!(value["ok"], json!(true)); } - - struct WorkspaceEnvGuard { - previous: Option, - } - - impl WorkspaceEnvGuard { - fn set(path: &std::path::Path) -> Self { - let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", path); - } - Self { previous } - } - } - - impl Drop for WorkspaceEnvGuard { - fn drop(&mut self) { - match self.previous.take() { - Some(value) => unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", value); - }, - None => unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - }, - } - } - } - - #[tokio::test] - async fn profile_handlers_persist_and_return_profile_state() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let temp = tempfile::tempdir().expect("tempdir"); - let _env = WorkspaceEnvGuard::set(temp.path()); - - let upserted = handle_profile_upsert(Map::from_iter([( - "profile".into(), - json!({ - "id": "writer", - "name": "Writer", - "description": "Draft concise copy", - "agentId": "orchestrator", - "modelOverride": "agentic-v1", - "temperature": 0.2, - "systemPromptSuffix": "Use a crisp tone.", - "allowedTools": ["todo"], - "builtIn": false, - }), - )])) - .await - .expect("profile upsert"); - assert_eq!(upserted["activeProfileId"], DEFAULT_PROFILE_ID); - assert!(upserted["profiles"] - .as_array() - .expect("profiles array") - .iter() - .any(|profile| profile["id"] == "writer")); - - let selected = handle_profile_select(Map::from_iter([( - "profile_id".into(), - Value::String("writer".into()), - )])) - .await - .expect("profile select"); - assert_eq!(selected["activeProfileId"], "writer"); - - let listed = handle_profiles_list(Map::new()) - .await - .expect("profiles list"); - assert_eq!(listed["activeProfileId"], "writer"); - - let deleted = handle_profile_delete(Map::from_iter([( - "profile_id".into(), - Value::String("writer".into()), - )])) - .await - .expect("profile delete"); - assert_eq!(deleted["activeProfileId"], DEFAULT_PROFILE_ID); - } - - #[tokio::test] - async fn profile_upsert_rejects_unknown_registered_agent_id() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let _ = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global_builtins(); - - let err = handle_profile_upsert(Map::from_iter([( - "profile".into(), - json!({ - "id": "bad", - "name": "Bad", - "description": "", - "agentId": "__missing_agent__", - "builtIn": false, - }), - )])) - .await - .expect_err("unknown agent should fail before store write"); - assert!(err.contains("agent definition"), "err: {err}"); - } } diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index 16caf1392..46c28816e 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -9,10 +9,10 @@ use std::path::Path; use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, PromptSource}; use crate::openhuman::agent::harness::session::Agent; use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap; -use crate::openhuman::agent::personality_paths::PersonalityContext; use crate::openhuman::agent::task_board::TaskCardStatus; use crate::openhuman::agent::task_session; use crate::openhuman::config::Config; +use crate::openhuman::profiles::PersonalityContext; use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch}; use crate::openhuman::todos::runs::{self, RunOutcome}; @@ -44,7 +44,7 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) -> } // 1) Personality (#2895): a user-defined profile with scoped identity. - if let Ok(state) = crate::openhuman::agent::profiles::load_profiles(workspace_dir) { + if let Ok(state) = crate::openhuman::profiles::load_profiles(workspace_dir) { if let Some(profile) = state.profiles.iter().find(|p| p.id == handle) { let ctx = PersonalityContext::from_profile(workspace_dir, profile.clone()); let mut preamble = format!( @@ -62,6 +62,7 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) -> return ResolvedExecutor { agent_id: profile.agent_id.clone(), prompt_suffix: Some(preamble), + profile: Some(profile.clone()), label: format!("personality:{handle}"), }; } @@ -81,6 +82,7 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) -> return ResolvedExecutor { agent_id: "orchestrator".to_string(), prompt_suffix: Some(suffix), + profile: None, label: format!("skill:{handle}"), }; } @@ -93,6 +95,7 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) -> return ResolvedExecutor { agent_id: handle.to_string(), prompt_suffix: None, + profile: None, label: format!("agent:{handle}"), }; } @@ -146,6 +149,7 @@ pub(super) async fn run_autonomous( &executor.agent_id, None, executor.prompt_suffix.clone(), + executor.profile.as_ref(), ) .map_err(|e| format!("build agent: {e:#}"))?; agent.set_event_context(run_id.to_string(), "task"); @@ -182,9 +186,18 @@ pub(super) async fn run_autonomous( // already authorized the parent turn that dispatched this task. Label // as CLI so the approval gate doesn't fail closed on internal // sub-agent invocations. - let run = crate::openhuman::agent::turn_origin::with_origin( - crate::openhuman::agent::turn_origin::AgentTurnOrigin::Cli, - with_autonomous_iter_cap(TASK_RUN_MAX_ITERATIONS, agent.run_single(prompt)), + // Gate memory-source recall for this background run to the profile's + // allowlist (None = unrestricted), mirroring the web chat turn. + let memory_scope = executor + .profile + .as_ref() + .and_then(|p| p.memory_sources.clone()); + let run = crate::openhuman::memory::source_scope::with_source_scope( + memory_scope, + crate::openhuman::agent::turn_origin::with_origin( + crate::openhuman::agent::turn_origin::AgentTurnOrigin::Cli, + with_autonomous_iter_cap(TASK_RUN_MAX_ITERATIONS, agent.run_single(prompt)), + ), ); let result = match session_thread_id.as_deref() { Some(thread_id) => { diff --git a/src/openhuman/agent/task_dispatcher/types.rs b/src/openhuman/agent/task_dispatcher/types.rs index 7bd50e187..d59c72b8e 100644 --- a/src/openhuman/agent/task_dispatcher/types.rs +++ b/src/openhuman/agent/task_dispatcher/types.rs @@ -19,11 +19,17 @@ pub(super) struct ActiveRun { /// A resolved executor: which built-in agent definition to build, an optional /// system-prompt suffix carrying a personality identity or skill guidelines, -/// and a label for logs/telemetry. +/// the resolved agent profile (when the handle is a personality) so its +/// per-profile allowlists are enforced, and a label for logs/telemetry. #[derive(Debug, Clone, PartialEq)] pub(super) struct ResolvedExecutor { pub(super) agent_id: String, pub(super) prompt_suffix: Option, + /// The resolved profile for personality executors. `None` for skill / + /// built-in / default executors → unrestricted (legacy behaviour). When + /// present, the autonomous run applies the profile's tool/skill/MCP/ + /// connector and memory-source gates, not just its SOUL/MEMORY text. + pub(super) profile: Option, pub(super) label: String, } @@ -32,6 +38,7 @@ impl ResolvedExecutor { Self { agent_id: "orchestrator".to_string(), prompt_suffix: None, + profile: None, label: "default".to_string(), } } diff --git a/src/openhuman/agent/tools/delegate_to_personality.rs b/src/openhuman/agent/tools/delegate_to_personality.rs index 3627efa5c..d50736c8d 100644 --- a/src/openhuman/agent/tools/delegate_to_personality.rs +++ b/src/openhuman/agent/tools/delegate_to_personality.rs @@ -3,8 +3,8 @@ use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::current_parent; use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; -use crate::openhuman::agent::personality_paths::PersonalityContext; -use crate::openhuman::agent::profiles::AgentProfileStore; +use crate::openhuman::profiles::AgentProfileStore; +use crate::openhuman::profiles::PersonalityContext; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index 1f96ed9a9..f571c608c 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -207,7 +207,11 @@ fn outcome_to_result( /// `run_workflow` — orchestrator-callable spawn + inline await of another /// workflow. -pub struct RunWorkflowTool; +pub struct RunWorkflowTool { + /// Per-profile allowlist of runnable workflow `dir_name` slugs. `None` + /// (the default) means every installed workflow may be run. + skill_allowlist: Option>, +} impl Default for RunWorkflowTool { fn default() -> Self { @@ -217,7 +221,18 @@ impl Default for RunWorkflowTool { impl RunWorkflowTool { pub fn new() -> Self { - Self + Self { + skill_allowlist: None, + } + } + + /// Restrict which workflows this tool may run to a per-profile allowlist. + pub fn with_skill_allowlist( + mut self, + allowlist: Option>, + ) -> Self { + self.skill_allowlist = allowlist; + self } } @@ -292,6 +307,14 @@ impl Tool for RunWorkflowTool { )); } }; + if let Some(allow) = &self.skill_allowlist { + if !allow.contains(&workflow_id) { + log::debug!("[profiles] run_workflow blocked by profile allowlist: {workflow_id}"); + return Ok(ToolResult::error(format!( + "run_workflow: workflow `{workflow_id}` is not available to the active agent profile" + ))); + } + } let inputs = args.get("inputs").cloned(); let wait_seconds = parse_wait_seconds(&args); diff --git a/src/openhuman/agent_memory/memory_loader.rs b/src/openhuman/agent_memory/memory_loader.rs index 88564b94d..c4df61e62 100644 --- a/src/openhuman/agent_memory/memory_loader.rs +++ b/src/openhuman/agent_memory/memory_loader.rs @@ -75,6 +75,11 @@ pub struct DefaultMemoryLoader { /// Workspace dir for direct cross-thread JSONL search (issue #1505). /// `None` falls back to the Memory-trait recall path. workspace_dir: Option, + /// When `false`, the agent profile opted out of recalling prior agent + /// conversations — both the `[Prior conversations]` and `[Cross-chat + /// context]` blocks are suppressed. Defaults to `true` (legacy behaviour). + /// Set per-profile via `AgentProfile::include_agent_conversations`. + include_agent_conversations: bool, } /// Lightweight citation object derived from recalled memory entries. @@ -100,6 +105,7 @@ impl Default for DefaultMemoryLoader { min_relevance_score: 0.4, max_context_chars: 2000, workspace_dir: None, + include_agent_conversations: true, } } } @@ -111,6 +117,7 @@ impl DefaultMemoryLoader { min_relevance_score, max_context_chars: 2000, workspace_dir: None, + include_agent_conversations: true, } } @@ -119,6 +126,14 @@ impl DefaultMemoryLoader { self } + /// Toggle recall of prior agent conversations (the `[Prior conversations]` + /// and `[Cross-chat context]` prompt blocks). Wired from the active + /// profile's `include_agent_conversations` flag. + pub fn with_agent_conversations(mut self, include: bool) -> Self { + self.include_agent_conversations = include; + self + } + /// Wire the workspace dir so the `[Cross-chat context]` block can do /// direct JSONL scans across threads (issue #1505). Without this the /// loader still falls back to the Memory-trait recall path, which only @@ -249,73 +264,78 @@ impl MemoryLoader for DefaultMemoryLoader { // `high.*` are eligible, and only the first short snippet of // each is included so the block never crowds out the user's // actual message. - let prior_query = format!("{} {}", CONVERSATION_MEMORY_NAMESPACE, user_message); - let prior_entries = memory - .recall( - &prior_query, - PRIOR_CONVERSATION_LIMIT * 4, - crate::openhuman::memory::RecallOpts { - namespace: Some(CONVERSATION_MEMORY_NAMESPACE), - ..Default::default() - }, - ) - .await - .unwrap_or_default(); + // + // Skipped entirely when the active profile opts out of recalling + // prior agent conversations (`include_agent_conversations = false`). + if self.include_agent_conversations { + let prior_query = format!("{} {}", CONVERSATION_MEMORY_NAMESPACE, user_message); + let prior_entries = memory + .recall( + &prior_query, + PRIOR_CONVERSATION_LIMIT * 4, + crate::openhuman::memory::RecallOpts { + namespace: Some(CONVERSATION_MEMORY_NAMESPACE), + ..Default::default() + }, + ) + .await + .unwrap_or_default(); - let mut appended_prior_header = false; - let mut prior_added = 0usize; - for entry in prior_entries - .into_iter() - .filter(|e| e.key.starts_with(PRIOR_CONVERSATION_KEY_PREFIX)) - .filter(|e| match e.score { - Some(score) => score >= self.min_relevance_score, - None => true, - }) - { - if prior_added >= PRIOR_CONVERSATION_LIMIT { - break; - } - // The stored content is two lines: - // [high preference] I prefer Postgres ... - // [provenance] {"thread_id":"thr_…", ...} - // For the prompt we keep only the first line so the block - // stays compact. Provenance survives in the underlying - // memory entry and is queryable through the memory tool. - let primary = entry - .content - .lines() - .find(|l| !l.trim_start().starts_with("[provenance]")) - .unwrap_or(&entry.content) - .trim(); - if primary.is_empty() { - continue; - } - if !appended_prior_header { - let section = "[Prior conversations]\n"; - if context.len() + section.len() > budget { + let mut appended_prior_header = false; + let mut prior_added = 0usize; + for entry in prior_entries + .into_iter() + .filter(|e| e.key.starts_with(PRIOR_CONVERSATION_KEY_PREFIX)) + .filter(|e| match e.score { + Some(score) => score >= self.min_relevance_score, + None => true, + }) + { + if prior_added >= PRIOR_CONVERSATION_LIMIT { break; } - context.push_str(section); - appended_prior_header = true; - } - // Date-stamp the fact so a months-old "high importance" - // statement isn't read as a present-tense commitment (#2944). - let line = match memory_entry_date_label(&entry.timestamp) { - Some(date) => format!("- (noted {date}) {primary}\n"), - None => format!("- {primary}\n"), - }; - if context.len() + line.len() > budget { - tracing::debug!( + // The stored content is two lines: + // [high preference] I prefer Postgres ... + // [provenance] {"thread_id":"thr_…", ...} + // For the prompt we keep only the first line so the block + // stays compact. Provenance survives in the underlying + // memory entry and is queryable through the memory tool. + let primary = entry + .content + .lines() + .find(|l| !l.trim_start().starts_with("[provenance]")) + .unwrap_or(&entry.content) + .trim(); + if primary.is_empty() { + continue; + } + if !appended_prior_header { + let section = "[Prior conversations]\n"; + if context.len() + section.len() > budget { + break; + } + context.push_str(section); + appended_prior_header = true; + } + // Date-stamp the fact so a months-old "high importance" + // statement isn't read as a present-tense commitment (#2944). + let line = match memory_entry_date_label(&entry.timestamp) { + Some(date) => format!("- (noted {date}) {primary}\n"), + None => format!("- {primary}\n"), + }; + if context.len() + line.len() > budget { + tracing::debug!( budget, current_len = context.len(), skipped_line_len = line.len(), "[memory_loader] context budget reached while appending prior conversations" ); - break; + break; + } + context.push_str(&line); + prior_added += 1; } - context.push_str(&line); - prior_added += 1; - } + } // end: include_agent_conversations (prior conversations) // ── Cross-chat context (#1505) ─────────────────────────────────── // @@ -336,114 +356,124 @@ impl MemoryLoader for DefaultMemoryLoader { // through `memory.recall` with `cross_session=true` instead. // That path reads `episodic_log` (populated only by the // archivist tool) so it's a best-effort secondary signal. - let current_thread_id = - crate::openhuman::inference::provider::thread_context::current_thread_id(); - let cross_hits: Vec<(String, String)> = if let Some(workspace_dir) = &self.workspace_dir { - let store = ConversationStore::new(workspace_dir.clone()); - match store.search_cross_thread_messages( - user_message, - CROSS_CHAT_LIMIT * 4, - current_thread_id.as_deref(), - ) { - Ok(hits) => { - tracing::debug!( - "[memory_loader] cross-chat JSONL scan returned {} hits (exclude={:?})", - hits.len(), - current_thread_id - ); - hits.into_iter() - .filter(|h| h.score >= self.min_relevance_score) - .take(CROSS_CHAT_LIMIT) - .map(|h| (h.thread_id, h.content)) - .collect() + // + // Suppressed when the profile opts out of agent-conversation recall. + if self.include_agent_conversations { + let current_thread_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + let cross_hits: Vec<(String, String)> = if let Some(workspace_dir) = &self.workspace_dir + { + let store = ConversationStore::new(workspace_dir.clone()); + match store.search_cross_thread_messages( + user_message, + CROSS_CHAT_LIMIT * 4, + current_thread_id.as_deref(), + ) { + Ok(hits) => { + tracing::debug!( + "[memory_loader] cross-chat JSONL scan returned {} hits (exclude={:?})", + hits.len(), + current_thread_id + ); + hits.into_iter() + .filter(|h| h.score >= self.min_relevance_score) + .take(CROSS_CHAT_LIMIT) + .map(|h| (h.thread_id, h.content)) + .collect() + } + Err(e) => { + tracing::warn!( + "[memory_loader] cross-chat JSONL scan failed (non-fatal): {e}" + ); + Vec::new() + } } - Err(e) => { - tracing::warn!("[memory_loader] cross-chat JSONL scan failed (non-fatal): {e}"); - Vec::new() - } - } - } else { - // Fallback path (no workspace_dir wired) - let cross_session_opts = crate::openhuman::memory::RecallOpts { - session_id: current_thread_id.as_deref(), - cross_session: true, - min_score: Some(self.min_relevance_score), - ..Default::default() - }; - let entries = memory - .recall(user_message, CROSS_CHAT_LIMIT * 3, cross_session_opts) - .await - .unwrap_or_default(); - entries - .into_iter() - .filter(|e| e.id.starts_with("episodic-cross:")) - .filter(|e| { - // Fallback entries may carry a JSON session blob - // (`{"thread_id": "...", "client_id": "..."}`) rather - // than a bare thread_id, so the SQL-side exclusion - // can miss. Re-check on this side using the same - // normalization shape. - let Some(current_tid) = current_thread_id.as_deref() else { - return true; - }; - let Some(raw_sid) = e.session_id.as_deref() else { - return true; - }; - let sid_thread = serde_json::from_str::(raw_sid) - .ok() - .and_then(|v| { - v.get("thread_id") - .and_then(|t| t.as_str().map(|s| s.to_string())) - }) - .unwrap_or_else(|| raw_sid.to_string()); - sid_thread != current_tid - }) - .filter(|e| match e.score { - Some(score) => score >= self.min_relevance_score, - None => true, - }) - .take(CROSS_CHAT_LIMIT) - .map(|e| { - let sid = e - .session_id - .clone() - .unwrap_or_else(|| "unknown".to_string()); - (sid, e.content) - }) - .collect() - }; - - let mut appended_cross_header = false; - for (sid, content) in cross_hits { - let snippet = if content.chars().count() > CROSS_CHAT_SNIPPET_CHARS { - crate::openhuman::util::truncate_with_ellipsis(&content, CROSS_CHAT_SNIPPET_CHARS) } else { - content + // Fallback path (no workspace_dir wired) + let cross_session_opts = crate::openhuman::memory::RecallOpts { + session_id: current_thread_id.as_deref(), + cross_session: true, + min_score: Some(self.min_relevance_score), + ..Default::default() + }; + let entries = memory + .recall(user_message, CROSS_CHAT_LIMIT * 3, cross_session_opts) + .await + .unwrap_or_default(); + entries + .into_iter() + .filter(|e| e.id.starts_with("episodic-cross:")) + .filter(|e| { + // Fallback entries may carry a JSON session blob + // (`{"thread_id": "...", "client_id": "..."}`) rather + // than a bare thread_id, so the SQL-side exclusion + // can miss. Re-check on this side using the same + // normalization shape. + let Some(current_tid) = current_thread_id.as_deref() else { + return true; + }; + let Some(raw_sid) = e.session_id.as_deref() else { + return true; + }; + let sid_thread = serde_json::from_str::(raw_sid) + .ok() + .and_then(|v| { + v.get("thread_id") + .and_then(|t| t.as_str().map(|s| s.to_string())) + }) + .unwrap_or_else(|| raw_sid.to_string()); + sid_thread != current_tid + }) + .filter(|e| match e.score { + Some(score) => score >= self.min_relevance_score, + None => true, + }) + .take(CROSS_CHAT_LIMIT) + .map(|e| { + let sid = e + .session_id + .clone() + .unwrap_or_else(|| "unknown".to_string()); + (sid, e.content) + }) + .collect() }; - let prov = provenance_tag(&sid); - if !appended_cross_header { - // The header explicitly labels these snippets as historical so - // the model down-weights them when answering capability - // questions — see CROSS_CHAT_HEADER doc for the rationale and - // the cross-module wording contract. - if context.len() + CROSS_CHAT_HEADER.len() > budget { + + let mut appended_cross_header = false; + for (sid, content) in cross_hits { + let snippet = if content.chars().count() > CROSS_CHAT_SNIPPET_CHARS { + crate::openhuman::util::truncate_with_ellipsis( + &content, + CROSS_CHAT_SNIPPET_CHARS, + ) + } else { + content + }; + let prov = provenance_tag(&sid); + if !appended_cross_header { + // The header explicitly labels these snippets as historical so + // the model down-weights them when answering capability + // questions — see CROSS_CHAT_HEADER doc for the rationale and + // the cross-module wording contract. + if context.len() + CROSS_CHAT_HEADER.len() > budget { + break; + } + context.push_str(CROSS_CHAT_HEADER); + appended_cross_header = true; + } + let line = format!("- [{prov}] {snippet}\n"); + if context.len() + line.len() > budget { + tracing::debug!( + budget, + current_len = context.len(), + skipped_line_len = line.len(), + "[memory_loader] context budget reached while appending cross-chat context" + ); break; } - context.push_str(CROSS_CHAT_HEADER); - appended_cross_header = true; + context.push_str(&line); } - let line = format!("- [{prov}] {snippet}\n"); - if context.len() + line.len() > budget { - tracing::debug!( - budget, - current_len = context.len(), - skipped_line_len = line.len(), - "[memory_loader] context budget reached while appending cross-chat context" - ); - break; - } - context.push_str(&line); - } + } // end: include_agent_conversations (cross-chat context) if context.is_empty() { return Ok(String::new()); @@ -671,6 +701,72 @@ mod tests { ); } + #[tokio::test] + async fn agent_conversations_toggle_suppresses_prior_and_cross_chat_blocks() { + // A profile with include_agent_conversations = false must drop both the + // [Prior conversations] and [Cross-chat context] blocks, while leaving + // [User working memory] intact. + let mut mem = MockMemory::new(vec![ + MemoryEntry { + id: "id-work".into(), + key: "working.user.timezone".into(), + content: "Timezone is PT.".into(), + namespace: Some("test".into()), + category: MemoryCategory::Conversation, + timestamp: "2026-05-25T00:00:00Z".into(), + session_id: None, + score: None, + taint: Default::default(), + }, + MemoryEntry { + id: "id-prior".into(), + key: "high.preference.aaaaaaaaaaaa".into(), + content: "[high preference] I prefer Postgres.".into(), + namespace: Some(super::CONVERSATION_MEMORY_NAMESPACE.to_string()), + category: MemoryCategory::Conversation, + timestamp: "2026-04-22T00:00:00Z".into(), + session_id: Some("thr_old".into()), + score: Some(0.9), + taint: Default::default(), + }, + ]); + mem.cross_chat = vec![cross_chat_entry( + "1", + "thread-source", + "Cross chat about Redis", + Some(0.9), + )]; + + // Baseline: default loader surfaces all three blocks. + let baseline = DefaultMemoryLoader::default() + .load_context(&mem, "storage preferences") + .await + .expect("baseline loader"); + assert!(baseline.contains("[User working memory]")); + assert!(baseline.contains("[Prior conversations]")); + assert!(baseline.contains(CROSS_CHAT_HEADER.trim_end())); + + // Opted out: only working memory remains. + let gated = DefaultMemoryLoader::default() + .with_agent_conversations(false) + .load_context(&mem, "storage preferences") + .await + .expect("gated loader"); + assert!( + gated.contains("[User working memory]"), + "working memory must survive the toggle, got:\n{gated}" + ); + assert!( + !gated.contains("[Prior conversations]"), + "prior conversations must be suppressed, got:\n{gated}" + ); + assert!( + !gated.contains(CROSS_CHAT_HEADER.trim_end()), + "cross-chat must be suppressed, got:\n{gated}" + ); + assert!(!gated.contains("Postgres") && !gated.contains("Redis")); + } + #[tokio::test] async fn collect_recall_citations_filters_and_truncates_entries() { let mem = MockMemory::new(vec![ diff --git a/src/openhuman/channels/providers/web/run_task.rs b/src/openhuman/channels/providers/web/run_task.rs index f0a72ebb5..0d65f39da 100644 --- a/src/openhuman/channels/providers/web/run_task.rs +++ b/src/openhuman/channels/providers/web/run_task.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use crate::openhuman::agent::profiles::AgentProfileStore; use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::profiles::AgentProfileStore; use crate::openhuman::threads::turn_state::TurnStateStore; use super::ops::{key_for, THREAD_SESSIONS}; @@ -99,6 +99,7 @@ pub(crate) async fn run_chat_task( temperature, target_agent_id.clone(), provider_role, + &profile, ); // A forked (parallel) turn never reuses or evicts the shared cached agent — @@ -211,9 +212,21 @@ pub(crate) async fn run_chat_task( config.clone(), ); + // Scope source-memory recall to the active profile's allowlist for the + // duration of the turn (None = all). Nested inside the thread-id scope so + // every memory-tree query the agent makes this turn is gated. See + // memory::source_scope. + // `run_single`'s future is very large; box it so the two ambient-scope + // wrappers below hold a pointer rather than inlining the whole future into + // this already-large `run_chat_task` frame (which otherwise overflows the + // default test-thread stack — see the channels web-turn coverage tests). + let turn = Box::pin(agent.run_single(message)); let result = match crate::openhuman::inference::provider::thread_context::with_thread_id( thread_id.to_string(), - agent.run_single(message), + crate::openhuman::memory::source_scope::with_source_scope( + profile.memory_sources.clone(), + turn, + ), ) .await { diff --git a/src/openhuman/channels/providers/web/session.rs b/src/openhuman/channels/providers/web/session.rs index 919506d8d..43aec745a 100644 --- a/src/openhuman/channels/providers/web/session.rs +++ b/src/openhuman/channels/providers/web/session.rs @@ -1,9 +1,9 @@ use serde_json::json; use std::collections::HashSet; -use crate::openhuman::agent::profiles::{AgentProfile, DEFAULT_PROFILE_ID}; use crate::openhuman::agent::Agent; use crate::openhuman::config::Config; +use crate::openhuman::profiles::{AgentProfile, DEFAULT_PROFILE_ID}; use super::types::SessionCacheFingerprint; @@ -104,6 +104,7 @@ pub(super) fn build_session_agent( target_agent_id, reflection_chunks, composed_suffix, + Some(profile), ); agent_result @@ -184,6 +185,7 @@ pub(super) fn build_session_fingerprint( temperature: Option, target_agent_id: String, provider_role: &str, + profile: &AgentProfile, ) -> SessionCacheFingerprint { SessionCacheFingerprint { model_override, @@ -195,5 +197,8 @@ pub(super) fn build_session_fingerprint( target_agent_id, autonomy_signature: autonomy_signature(config), model_registry_signature: model_registry_signature(config), + // Any change to the resolved profile (id, allowlists, soul, …) changes + // this string and forces a session-agent rebuild — see the field doc. + profile_signature: crate::openhuman::profiles::profile_signature(profile), } } diff --git a/src/openhuman/channels/providers/web/types.rs b/src/openhuman/channels/providers/web/types.rs index 1e8f9e93e..a81422403 100644 --- a/src/openhuman/channels/providers/web/types.rs +++ b/src/openhuman/channels/providers/web/types.rs @@ -25,6 +25,13 @@ pub(crate) struct SessionCacheFingerprint { /// change) — without this the stale session would be reused. Mirrors /// [`Self::autonomy_signature`]. pub(super) model_registry_signature: String, + /// Serialized signature of the active agent profile. The cached `Agent` + /// bakes in the profile's tool/skill/MCP/connector visibility and SOUL/MEMORY + /// overrides at build time; switching profiles on the same thread keeps the + /// same model/agent/provider, so without this the previous profile's + /// capability surface would leak into the new profile's turns. Any change to + /// the resolved profile forces a rebuild. + pub(super) profile_signature: String, } pub(super) struct SessionEntry { diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index 06e64ecc1..99f6ce9bb 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -1453,6 +1453,7 @@ fn fp( provider_binding: provider_binding.to_string(), autonomy_signature: "sig-default".to_string(), model_registry_signature: "registry-default".to_string(), + profile_signature: "profile-default".to_string(), } } @@ -1484,6 +1485,21 @@ fn fingerprint_model_registry_change_is_cache_miss() { ); } +#[test] +fn fingerprint_profile_change_is_cache_miss() { + // Switching the active agent profile on the same thread keeps the same + // model/agent/provider, so without the profile signature the previous + // profile's tool/skill/MCP/connector visibility would leak into the new + // profile's turns. A different profile signature must force a rebuild. + let base = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6"); + let mut changed = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6"); + changed.profile_signature = "profile-after-switch".to_string(); + assert_ne!( + base, changed, + "a different profile signature must produce a cache miss → rebuild" + ); +} + #[test] fn fingerprint_identical_inputs_are_cache_hit() { let a = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6"); diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 0a9f99d8c..e0d3b96b5 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -318,6 +318,8 @@ pub async fn start_channels(mut config: Config) -> Result<()> { &config.action_dir, &config.agents, &config, + None, + None, )); let skills = crate::openhuman::workflows::load_workflow_metadata(&workspace); diff --git a/src/openhuman/mcp_client/registry.rs b/src/openhuman/mcp_client/registry.rs index 61fd9b417..377a76e11 100644 --- a/src/openhuman/mcp_client/registry.rs +++ b/src/openhuman/mcp_client/registry.rs @@ -160,6 +160,33 @@ impl McpServerRegistry { self.order.is_empty() } + /// Return a copy of this registry keeping only servers whose name appears + /// in `allowed` (case-insensitive). Used to scope the MCP surface to an + /// agent profile's `allowed_mcp_servers` allowlist. `None` is the caller's + /// "all servers" sentinel and should bypass this method entirely; an empty + /// slice yields an empty registry (the profile selected no servers). + pub fn retaining_servers(&self, allowed: &[String]) -> Self { + let allow_lc: std::collections::HashSet = allowed + .iter() + .map(|s| s.trim().to_ascii_lowercase()) + .collect(); + let mut filtered = Self::default(); + for name in &self.order { + if allow_lc.contains(&name.to_ascii_lowercase()) { + if let Some(def) = self.by_name.get(name) { + filtered.insert(def.clone()); + } + } + } + tracing::debug!( + before = self.order.len(), + after = filtered.order.len(), + allowlist = allowed.len(), + "[profiles] mcp registry scoped to profile allowlist" + ); + filtered + } + pub fn list(&self) -> Vec<&McpServerDefinition> { self.order .iter() @@ -383,6 +410,45 @@ mod tests { assert!(registry.is_empty()); } + fn config_with_servers(names: &[&str]) -> Config { + let mut config = Config::default(); + config.gitbooks.enabled = false; + for name in names { + config.mcp_client.servers.push(McpServerConfig { + name: (*name).into(), + endpoint: "https://example.com/mcp".into(), + command: String::new(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + description: None, + enabled: true, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + timeout_secs: 30, + auth: crate::openhuman::config::McpAuthConfig::None, + }); + } + config + } + + #[test] + fn retaining_servers_scopes_registry_to_allowlist_case_insensitively() { + let registry = + McpServerRegistry::from_config(&config_with_servers(&["docs", "github", "jira"])); + assert_eq!(registry.list().len(), 3); + + // Allowlist keeps only named servers, case-insensitively. + let scoped = registry.retaining_servers(&["Docs".into(), "jira".into()]); + let mut names: Vec<&str> = scoped.list().iter().map(|s| s.name.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, vec!["docs", "jira"]); + assert!(scoped.get("github").is_none()); + + // Empty allowlist yields an empty registry (profile selected nothing). + assert!(registry.retaining_servers(&[]).is_empty()); + } + #[test] fn server_definition_filters_allowed_tools() { let mut config = Config::default(); diff --git a/src/openhuman/meet_agent/brain/llm.rs b/src/openhuman/meet_agent/brain/llm.rs index 99f259e35..e927a9a9a 100644 --- a/src/openhuman/meet_agent/brain/llm.rs +++ b/src/openhuman/meet_agent/brain/llm.rs @@ -155,6 +155,7 @@ async fn get_or_build_agent_for_meet(request_id: &str) -> Result>; +} + +/// Normalize a raw allowlist into the task-local representation. Trims entries +/// and drops empties. `None` → unrestricted; `Some(vec)` → restricted (an empty +/// vec stays `Some(empty)` = "no sources"). +fn normalize(allowlist: Option>) -> Option> { + allowlist.map(|items| { + items + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) +} + +/// Run `fut` with `allowlist` available to any descendant call to +/// [`current_source_scope`]. `None` leaves recall unrestricted. +pub async fn with_source_scope(allowlist: Option>, fut: F) -> T +where + F: Future, +{ + let value = normalize(allowlist); + log::debug!( + "[memory:source_scope] entering scope: {}", + match &value { + None => "unrestricted".to_string(), + Some(set) => format!("{} source(s)", set.len()), + } + ); + SOURCE_SCOPE.scope(value, fut).await +} + +/// Return the ambient source-scope allowlist set by an enclosing +/// [`with_source_scope`], or `None` (unrestricted) when called outside one. +pub fn current_source_scope() -> Option> { + SOURCE_SCOPE.try_with(|v| v.clone()).ok().flatten() +} + +/// Whether `scope` is recallable under the ambient allowlist. `true` when there +/// is no active scope (unrestricted) or when the scope is explicitly allowed. +pub fn scope_allowed(scope: &str) -> bool { + match current_source_scope() { + None => true, + Some(set) => set.contains(scope), + } +} + +/// The tag every memory-source–ingested chunk carries (set by +/// `memory_sources::sync` and the github reader). Used as the discriminator so +/// the chunk-level gate only touches memory-SOURCE chunks and never working / +/// conversation / internal chunks. +const MEMORY_SOURCE_TAG: &str = "memory_sources"; + +/// Whether a memory-store chunk is recallable under the ambient allowlist, +/// given its `tags` and `source_id`. +/// +/// Fail-open for everything that is NOT a memory-source chunk: a chunk without +/// the `memory_sources` tag (working memory, conversation transcripts, internal +/// chunks) always passes. A tagged memory-source chunk passes iff its source +/// identifier is allowed — matched flexibly against either the raw `source_id` +/// (Composio / channel scopes like `slack:#eng`) or the registry id extracted +/// from a `mem_src::` composite (reader-based sources). `None` scope +/// is unrestricted. +pub fn chunk_source_allowed(tags: &[String], source_id: &str) -> bool { + match current_source_scope() { + None => true, + Some(set) => chunk_source_allowed_in(&set, tags, source_id), + } +} + +/// Pure form of [`chunk_source_allowed`] against an explicit allowlist `set`, +/// for callers that already hold the scope (e.g. `list_chunks`, which captures +/// it on the async side and filters DB rows before applying the row limit so a +/// disallowed-source-heavy prefix can't starve permitted rows). +pub fn chunk_source_allowed_in(set: &HashSet, tags: &[String], source_id: &str) -> bool { + let is_memory_source = tags.iter().any(|t| t == MEMORY_SOURCE_TAG); + if !is_memory_source { + return true; + } + if set.contains(source_id) { + return true; + } + crate::openhuman::memory::sync::extract_mem_src_id(source_id).is_some_and(|id| set.contains(id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unrestricted_outside_scope() { + assert!(current_source_scope().is_none()); + assert!(scope_allowed("anything")); + } + + #[tokio::test] + async fn restricts_to_allowlisted_scopes() { + with_source_scope( + Some(vec!["slack:#eng".into(), " gmail:me ".into()]), + async { + let set = current_source_scope().expect("scope set"); + assert_eq!(set.len(), 2); + assert!(scope_allowed("slack:#eng")); + assert!(scope_allowed("gmail:me")); // trimmed + assert!(!scope_allowed("notion:team")); + }, + ) + .await; + // Must not leak past the scope. + assert!(current_source_scope().is_none()); + assert!(scope_allowed("notion:team")); + } + + #[tokio::test] + async fn empty_allowlist_blocks_everything() { + with_source_scope(Some(vec![]), async { + assert!(current_source_scope().is_some()); + assert!(!scope_allowed("slack:#eng")); + }) + .await; + } + + #[tokio::test] + async fn explicit_none_is_unrestricted() { + with_source_scope(None, async { + assert!(current_source_scope().is_none()); + assert!(scope_allowed("slack:#eng")); + }) + .await; + } + + #[tokio::test] + async fn chunk_gate_passes_non_source_chunks_and_gates_tagged_ones() { + let src_tags = vec!["memory_sources".to_string(), "document".to_string()]; + let other_tags = vec!["conversation".to_string()]; + + with_source_scope( + Some(vec!["slack:#eng".into(), "src-rss-42".into()]), + async { + // Non-source chunk (no memory_sources tag) always passes. + assert!(chunk_source_allowed(&other_tags, "thr_123:user")); + // Composio/channel source chunk: raw source_id == scope. + assert!(chunk_source_allowed(&src_tags, "slack:#eng")); + assert!(!chunk_source_allowed(&src_tags, "gmail:alice")); + // Reader-based composite: extracted registry id matches. + assert!(chunk_source_allowed( + &src_tags, + "mem_src:src-rss-42:https://example.com/item-7" + )); + assert!(!chunk_source_allowed( + &src_tags, + "mem_src:src-folder-9:/notes/a.md" + )); + }, + ) + .await; + } + + #[tokio::test] + async fn chunk_gate_unrestricted_without_scope() { + let src_tags = vec!["memory_sources".to_string()]; + // Outside any scope, even tagged source chunks pass. + assert!(chunk_source_allowed(&src_tags, "gmail:alice")); + } + + #[tokio::test] + async fn chunk_gate_empty_allowlist_blocks_tagged_sources_only() { + let src_tags = vec!["memory_sources".to_string()]; + let other_tags: Vec = vec![]; + with_source_scope(Some(vec![]), async { + assert!(!chunk_source_allowed(&src_tags, "slack:#eng")); + // Non-source chunks still pass even under an empty allowlist. + assert!(chunk_source_allowed(&other_tags, "thr_1:user")); + }) + .await; + } +} diff --git a/src/openhuman/memory_search/tools/chunk_context.rs b/src/openhuman/memory_search/tools/chunk_context.rs index 3128c918c..ef484c0ac 100644 --- a/src/openhuman/memory_search/tools/chunk_context.rs +++ b/src/openhuman/memory_search/tools/chunk_context.rs @@ -88,11 +88,26 @@ impl Tool for MemoryChunkContextTool { let source_id = target.metadata.source_id.clone(); let source_kind = target.metadata.source_kind; - // Get all chunks from the same source, ordered by timestamp + // Per-profile memory-source gate: if the target chunk belongs to a + // source the active profile didn't allow, surface nothing (its window + // shares the same source). Non-source chunks always pass. + if !crate::openhuman::memory::source_scope::chunk_source_allowed( + &target.metadata.tags, + &source_id, + ) { + return Ok(ToolResult::success( + "Chunk is from a memory source not available to the active agent profile.", + )); + } + + // Get all chunks from the same source, ordered by timestamp. The + // source-scope gate also applies here (the target was already checked + // above; this keeps the window consistent). None = unrestricted. let source_query = ListChunksQuery { source_kind: Some(source_kind), source_id: Some(source_id.clone()), limit: Some(500), + source_scope: crate::openhuman::memory::source_scope::current_source_scope(), ..Default::default() }; let mut source_chunks = list_chunks(&config, &source_query) diff --git a/src/openhuman/memory_search/tools/vector_search.rs b/src/openhuman/memory_search/tools/vector_search.rs index 6423627b9..9602fb88a 100644 --- a/src/openhuman/memory_search/tools/vector_search.rs +++ b/src/openhuman/memory_search/tools/vector_search.rs @@ -142,7 +142,9 @@ impl Tool for MemoryVectorSearchTool { now_ms - (i64::from(days) * 86_400_000) }); - // Fetch candidate chunks with metadata filters + // Fetch candidate chunks with metadata filters. The per-profile + // memory-source gate is applied inside `list_chunks` (before the row + // limit), so disallowed-source chunks can't starve permitted ones. let query = ListChunksQuery { source_kind, source_id: None, @@ -150,6 +152,7 @@ impl Tool for MemoryVectorSearchTool { since_ms, until_ms: None, limit: Some(1000), + source_scope: crate::openhuman::memory::source_scope::current_source_scope(), }; let chunks = list_chunks(&config, &query) diff --git a/src/openhuman/memory_store/chunks/store.rs b/src/openhuman/memory_store/chunks/store.rs index 06596a5ca..5372ecb03 100644 --- a/src/openhuman/memory_store/chunks/store.rs +++ b/src/openhuman/memory_store/chunks/store.rs @@ -637,6 +637,12 @@ pub struct ListChunksQuery { pub until_ms: Option, /// Max rows to return (default 100 when `None`). pub limit: Option, + /// Per-profile memory-source allowlist. When `Some`, memory-source chunks + /// (those tagged `memory_sources`) whose source identifier is not in the set + /// are dropped *before* the row limit is applied, so a disallowed-source + /// prefix can't starve permitted rows. Non-source chunks always pass. `None` + /// = unrestricted (the default for every non-agent caller). + pub source_scope: Option>, } /// List chunks matching the provided filters, ordered by `timestamp` DESC. @@ -670,19 +676,45 @@ pub fn list_chunks(config: &Config, query: &ListChunksQuery) -> Result = bound .iter() .map(|b| b.as_ref() as &dyn rusqlite::ToSql) .collect(); - let rows = stmt + let mut rows = stmt .query_map(param_refs.as_slice(), row_to_chunk)? .collect::>>() .context("Failed to collect chunks")?; + if let Some(ref allowed) = query.source_scope { + let before = rows.len(); + rows.retain(|c| { + crate::openhuman::memory::source_scope::chunk_source_allowed_in( + allowed, + &c.metadata.tags, + &c.metadata.source_id, + ) + }); + if rows.len() != before { + log::debug!( + "[profiles] list_chunks source-scope filter: {before} -> {} row(s)", + rows.len() + ); + } + rows.truncate(requested_limit as usize); + } Ok(rows) }) } diff --git a/src/openhuman/memory_store/chunks/store_tests.rs b/src/openhuman/memory_store/chunks/store_tests.rs index 92efd1cad..51b533f97 100644 --- a/src/openhuman/memory_store/chunks/store_tests.rs +++ b/src/openhuman/memory_store/chunks/store_tests.rs @@ -68,6 +68,46 @@ fn upsert_persists_path_scope() { assert_eq!(got.metadata.path_scope.as_deref(), Some("notion:conn-1")); } +#[test] +fn list_chunks_source_scope_filters_before_limit() { + // Two disallowed-source chunks have NEWER timestamps (sorted first by DESC), + // and the single allowed-source chunk is older. With a naive post-limit + // filter and LIMIT 1 the allowed row would be starved; the before-limit gate + // inside list_chunks must still surface it. + let (_tmp, cfg) = test_config(); + let tag = || vec!["memory_sources".to_string(), "chat".to_string()]; + let mut bad1 = sample_chunk("slack:#secret", 0, 3_000); + bad1.metadata.tags = tag(); + let mut bad2 = sample_chunk("slack:#secret", 1, 2_000); + bad2.metadata.tags = tag(); + let mut good = sample_chunk("slack:#eng", 0, 1_000); + good.metadata.tags = tag(); + upsert_chunks(&cfg, &[bad1, bad2, good]).unwrap(); + + let mut allowed = std::collections::HashSet::new(); + allowed.insert("slack:#eng".to_string()); + let q = ListChunksQuery { + limit: Some(1), + source_scope: Some(allowed), + ..Default::default() + }; + let rows = list_chunks(&cfg, &q).unwrap(); + assert_eq!( + rows.len(), + 1, + "the allowed-source chunk must survive the gate" + ); + assert_eq!(rows[0].metadata.source_id, "slack:#eng"); + + // No scope → unrestricted: the newest (disallowed) chunk wins under LIMIT 1. + let unscoped = ListChunksQuery { + limit: Some(1), + ..Default::default() + }; + let rows = list_chunks(&cfg, &unscoped).unwrap(); + assert_eq!(rows[0].metadata.source_id, "slack:#secret"); +} + #[test] fn upsert_is_idempotent() { let (_tmp, cfg) = test_config(); diff --git a/src/openhuman/memory_store/retrieval/mod.rs b/src/openhuman/memory_store/retrieval/mod.rs index d0f2f190b..7629b5c26 100644 --- a/src/openhuman/memory_store/retrieval/mod.rs +++ b/src/openhuman/memory_store/retrieval/mod.rs @@ -128,6 +128,7 @@ impl RetrievalFacade { since_ms: filters.since_ms, until_ms: filters.until_ms, limit: filters.limit, + source_scope: None, }; let rows = list_chunks(config, &query)?; let Some(required) = filters.tags_all_of.as_ref() else { diff --git a/src/openhuman/memory_store/tools/raw_chunks.rs b/src/openhuman/memory_store/tools/raw_chunks.rs index 5601a146b..325ac0ab8 100644 --- a/src/openhuman/memory_store/tools/raw_chunks.rs +++ b/src/openhuman/memory_store/tools/raw_chunks.rs @@ -92,6 +92,8 @@ impl Tool for MemoryStoreRawChunksTool { )); } } + // The per-profile memory-source gate is applied inside `list_chunks` + // (before the row limit). None = unrestricted. let query = ListChunksQuery { source_kind, source_id: parsed.source_id, @@ -99,6 +101,7 @@ impl Tool for MemoryStoreRawChunksTool { since_ms: parsed.since_ms, until_ms: parsed.until_ms, limit: parsed.limit, + source_scope: crate::openhuman::memory::source_scope::current_source_scope(), }; let mut rows = list_chunks(&cfg, &query)?; if let Some(required) = parsed.tags_all_of.as_ref() { diff --git a/src/openhuman/memory_tree/retrieval/source.rs b/src/openhuman/memory_tree/retrieval/source.rs index e4f5e5982..5dcd25478 100644 --- a/src/openhuman/memory_tree/retrieval/source.rs +++ b/src/openhuman/memory_tree/retrieval/source.rs @@ -64,11 +64,21 @@ pub async fn query_source( let source_id_owned = source_id.map(|s| s.to_string()); let config_owned = config.clone(); + // Capture the per-profile memory-source allowlist HERE, in the async task + // that holds the `source_scope` task-local — `spawn_blocking` below runs on + // a separate thread that does NOT inherit task-locals, so we must thread it + // through explicitly. `None` = unrestricted. + let source_scope = crate::openhuman::memory::source_scope::current_source_scope(); // We need the full SummaryNode (with embedding) when semantic rerank // is on, so return both shapes from the blocking path. let (hits, scored_nodes) = tokio::task::spawn_blocking( move || -> Result<(Vec, Vec<(SummaryNode, String)>)> { - collect_hits_and_nodes(&config_owned, source_id_owned.as_deref(), source_kind) + collect_hits_and_nodes( + &config_owned, + source_id_owned.as_deref(), + source_kind, + source_scope.as_ref(), + ) }, ) .await @@ -109,8 +119,9 @@ fn collect_hits_and_nodes( config: &Config, source_id: Option<&str>, source_kind: Option, + source_scope: Option<&std::collections::HashSet>, ) -> Result<(Vec, Vec<(SummaryNode, String)>)> { - let trees = select_trees(config, source_id, source_kind)?; + let trees = select_trees(config, source_id, source_kind, source_scope)?; log::debug!("[retrieval::source] selected trees n={}", trees.len()); let mut hits: Vec = Vec::new(); @@ -209,8 +220,21 @@ fn select_trees( config: &Config, source_id: Option<&str>, source_kind: Option, + source_scope: Option<&std::collections::HashSet>, ) -> Result> { + // Per-profile memory-source gate: `source_scope` is the active profile's + // allowlist of recallable source scopes (None = unrestricted). + let allowed = |scope: &str| source_scope.is_none_or(|set| set.contains(scope)); + if let Some(id) = source_id { + // An explicit lookup for a source the active profile didn't allow + // surfaces nothing (fail-closed). + if !allowed(id) { + log::debug!( + "[retrieval::source] source_id={id} blocked by profile memory-source scope" + ); + return Ok(Vec::new()); + } return match store::get_tree_by_scope(config, TreeKind::Source, id)? { Some(t) => Ok(vec![t]), None => { @@ -222,6 +246,15 @@ fn select_trees( }; } let all = store::list_trees_by_kind(config, TreeKind::Source)?; + // Scope the candidate set to the active profile's allowlist (None = all). + let before = all.len(); + let all: Vec = all.into_iter().filter(|t| allowed(&t.scope)).collect(); + if all.len() != before { + log::debug!( + "[retrieval::source] profile memory-source scope: {before} -> {} tree(s)", + all.len() + ); + } if let Some(kind) = source_kind { let prefix = kind.as_str(); let filtered: Vec = all @@ -452,6 +485,62 @@ mod tests { assert_eq!(resp.hits.len(), 2); } + #[tokio::test] + async fn profile_source_scope_restricts_all_query_to_allowlisted_trees() { + use crate::openhuman::memory::source_scope::with_source_scope; + let (_tmp, cfg) = test_config(); + let ts = Utc::now(); + seed_source(&cfg, "slack:#eng", ts).await; + seed_source(&cfg, "gmail:alice@example.com", ts).await; + + // Allowlist only the Slack source: the all-trees query drops Gmail. + let resp = with_source_scope(Some(vec!["slack:#eng".into()]), async { + query_source(&cfg, None, None, None, None, 10).await + }) + .await + .unwrap(); + assert_eq!(resp.hits.len(), 1); + assert_eq!(resp.hits[0].tree_scope, "slack:#eng"); + } + + #[tokio::test] + async fn profile_source_scope_blocks_explicit_disallowed_source_id() { + use crate::openhuman::memory::source_scope::with_source_scope; + let (_tmp, cfg) = test_config(); + let ts = Utc::now(); + seed_source(&cfg, "slack:#eng", ts).await; + seed_source(&cfg, "gmail:alice@example.com", ts).await; + + // Explicit lookup of a source outside the allowlist surfaces nothing. + let blocked = with_source_scope(Some(vec!["slack:#eng".into()]), async { + query_source(&cfg, Some("gmail:alice@example.com"), None, None, None, 10).await + }) + .await + .unwrap(); + assert!(blocked.hits.is_empty()); + + // The allowlisted source still resolves within the same scope. + let allowed = with_source_scope(Some(vec!["slack:#eng".into()]), async { + query_source(&cfg, Some("slack:#eng"), None, None, None, 10).await + }) + .await + .unwrap(); + assert_eq!(allowed.hits.len(), 1); + } + + #[tokio::test] + async fn no_source_scope_leaves_recall_unrestricted() { + let (_tmp, cfg) = test_config(); + let ts = Utc::now(); + seed_source(&cfg, "slack:#eng", ts).await; + seed_source(&cfg, "gmail:alice@example.com", ts).await; + // Outside any with_source_scope, all trees remain visible. + let resp = query_source(&cfg, None, None, None, None, 10) + .await + .unwrap(); + assert_eq!(resp.hits.len(), 2); + } + #[tokio::test] async fn query_with_time_window_filters_old_hits() { let (_tmp, cfg) = test_config(); diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index de3ac39b3..ea1f0b473 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -139,6 +139,7 @@ pub async fn list_chunks_rpc( since_ms: req.since_ms, until_ms: req.until_ms, limit: req.limit, + source_scope: None, }; let rows = tokio::task::spawn_blocking({ let config = config.clone(); diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 50c564ef9..9ba8052fe 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -84,6 +84,7 @@ pub mod monitor; pub mod notifications; pub mod overlay; pub mod people; +pub mod profiles; pub mod prompt_injection; pub mod provider_surfaces; pub mod redirect_links; diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs new file mode 100644 index 000000000..cce146a6d --- /dev/null +++ b/src/openhuman/profiles/mod.rs @@ -0,0 +1,31 @@ +//! Profiles domain — persistent, user-selectable agent "flavours". +//! +//! Each profile carries a custom name + SOUL.md, runtime defaults (model, +//! temperature, system-prompt suffix), and configurable allowlists for tools, +//! skills, MCP servers, connectors, and memory sources. Selecting a profile +//! changes how the agent introduces itself, what it remembers, and what it can +//! do. State persists under `/agent_profiles.json`. +//! +//! Relocated from `openhuman::agent::profiles` / `::personality_paths` so the +//! domain is addressable on its own (`openhuman::profiles`). + +pub mod ops; +pub mod paths; +pub mod prompt_section; +mod schemas; +pub mod store; +pub mod types; + +pub use paths::{ + filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, + resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, + HasToolkit, PersonalityContext, +}; +pub use prompt_section::AgentProfilePromptSection; +pub use store::{built_in_profiles, load_profiles, AgentProfileStore}; +pub use types::{profile_signature, AgentProfile, AgentProfilesState, DEFAULT_PROFILE_ID}; + +pub use schemas::{ + all_controller_schemas as all_profiles_controller_schemas, + all_registered_controllers as all_profiles_registered_controllers, +}; diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs new file mode 100644 index 000000000..2dbe176a3 --- /dev/null +++ b/src/openhuman/profiles/ops.rs @@ -0,0 +1,232 @@ +//! Business logic for the `profiles` RPC surface. +//! +//! Per the OpenHuman domain contract, controller handlers in [`super::schemas`] +//! stay thin (deserialize + delegate) and the real work — config loading, +//! `agent_id` validation, and store mutation — lives here, returning the JSON +//! payload the controller emits. The persistence itself is owned by +//! [`AgentProfileStore`](super::store::AgentProfileStore). + +use serde_json::Value; + +use super::store::AgentProfileStore; +use super::types::{AgentProfile, AgentProfilesState}; +use crate::openhuman::config::rpc as config_rpc; + +/// Shape the `{ profiles, activeProfileId }` payload every profiles RPC returns. +fn state_payload(state: &AgentProfilesState) -> Value { + serde_json::json!({ + "profiles": state.profiles, + "activeProfileId": state.active_profile_id, + }) +} + +/// Resolve the workspace-scoped profile store from the loaded config. +async fn store() -> Result { + let config = config_rpc::load_config_with_timeout().await?; + Ok(AgentProfileStore::new(config.workspace_dir)) +} + +/// List all persistent profiles and the active id. +pub async fn list() -> Result { + let request_id = format!("profiles-list-{}", uuid::Uuid::new_v4()); + tracing::debug!(request_id = %request_id, "[profiles][ops] list entry"); + let state = store().await?.load().map_err(|e| { + tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] list error"); + e + })?; + tracing::debug!( + request_id = %request_id, + active_profile_id = %state.active_profile_id, + profile_count = state.profiles.len(), + "[profiles][ops] list ok" + ); + Ok(state_payload(&state)) +} + +/// Select the active profile by id. +pub async fn select(profile_id: &str) -> Result { + let request_id = format!("profile-select-{}", uuid::Uuid::new_v4()); + tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] select entry"); + let state = store().await?.select(profile_id).map_err(|e| { + tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] select error"); + e + })?; + tracing::debug!( + request_id = %request_id, + profile_id, + active_profile_id = %state.active_profile_id, + "[profiles][ops] select ok" + ); + Ok(state_payload(&state)) +} + +/// Create or update a profile. +/// +/// The profile's `agent_id` is validated against the global +/// [`AgentDefinitionRegistry`](crate::openhuman::agent::harness::AgentDefinitionRegistry) +/// when it is available. When the registry is *not* initialised we **fail +/// closed** for any non-default `agent_id` rather than persist a reference we +/// can't validate — otherwise a startup init-order race would be saved as a +/// permanently-broken profile. The implicit `orchestrator` default (and an empty +/// id, normalised to `orchestrator`) are always valid, mirroring the session +/// builder, so they are admitted without the registry. +pub async fn upsert(profile: AgentProfile) -> Result { + let request_id = format!("profile-upsert-{}", uuid::Uuid::new_v4()); + let agent_id = profile.agent_id.trim().to_string(); + tracing::debug!( + request_id = %request_id, + profile_id = %profile.id, + agent_id = %agent_id, + "[profiles][ops] upsert entry" + ); + match crate::openhuman::agent::harness::AgentDefinitionRegistry::global() { + Some(registry) => { + if !agent_id.is_empty() && registry.get(&agent_id).is_none() { + tracing::debug!( + request_id = %request_id, + agent_id = %agent_id, + "[profiles][ops] upsert unknown_agent" + ); + return Err(format!("agent definition '{agent_id}' not found")); + } + } + None => { + // No registry → can only admit the always-valid default agent. + if !agent_id.is_empty() && agent_id != DEFAULT_AGENT_ID { + tracing::debug!( + request_id = %request_id, + agent_id = %agent_id, + "[profiles][ops] upsert registry_unavailable fail_closed" + ); + return Err(format!( + "agent definition registry unavailable — cannot validate agent_id '{agent_id}'" + )); + } + } + } + let state = store().await?.upsert(profile).map_err(|e| { + tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert error"); + e + })?; + tracing::debug!( + request_id = %request_id, + active_profile_id = %state.active_profile_id, + profile_count = state.profiles.len(), + "[profiles][ops] upsert ok" + ); + Ok(state_payload(&state)) +} + +/// Delete a custom profile by id. +pub async fn delete(profile_id: &str) -> Result { + let request_id = format!("profile-delete-{}", uuid::Uuid::new_v4()); + tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] delete entry"); + let state = store().await?.delete(profile_id).map_err(|e| { + tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] delete error"); + e + })?; + tracing::debug!( + request_id = %request_id, + profile_id, + active_profile_id = %state.active_profile_id, + profile_count = state.profiles.len(), + "[profiles][ops] delete ok" + ); + Ok(state_payload(&state)) +} + +/// The implicit orchestrator agent that requires no registry entry — the +/// built-in default profile uses it and the session builder treats it as always +/// resolvable. +const DEFAULT_AGENT_ID: &str = "orchestrator"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK; + use crate::openhuman::profiles::DEFAULT_PROFILE_ID; + use serde_json::json; + + struct WorkspaceEnvGuard { + previous: Option, + } + impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + } + Self { previous } + } + } + impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => unsafe { std::env::set_var("OPENHUMAN_WORKSPACE", value) }, + None => unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE") }, + } + } + } + + fn profile(id: &str, agent_id: &str) -> AgentProfile { + let mut p = super::super::store::built_in_default_profile(); + p.id = id.to_string(); + p.name = id.to_string(); + p.agent_id = agent_id.to_string(); + p.built_in = false; + p.is_master = false; + p.memory_dir_suffix = None; + p + } + + #[tokio::test] + async fn upsert_default_agent_allowed_without_registry() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + // orchestrator is always valid even with no registry initialised. + let out = upsert(profile("writer", "orchestrator")) + .await + .expect("upsert"); + assert!(out["profiles"] + .as_array() + .unwrap() + .iter() + .any(|p| p["id"] == "writer")); + } + + #[tokio::test] + async fn upsert_unknown_agent_is_rejected() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + // A bogus non-default agent_id must never persist. Depending on whether + // another test already initialised the process-global registry, the + // rejection comes either from registry validation ("not found") or from + // the fail-closed no-registry path ("registry unavailable") — both are + // acceptable; the invariant is that it errors rather than saving. + let err = upsert(profile("bad", "__missing_agent__")) + .await + .expect_err("must reject unknown agent"); + assert!( + err.contains("registry unavailable") || err.contains("not found"), + "err: {err}" + ); + } + + #[tokio::test] + async fn list_select_delete_roundtrip() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + upsert(profile("writer", "orchestrator")) + .await + .expect("upsert"); + let selected = select("writer").await.expect("select"); + assert_eq!(selected["activeProfileId"], "writer"); + let listed = list().await.expect("list"); + assert_eq!(listed["activeProfileId"], "writer"); + let deleted = delete("writer").await.expect("delete"); + assert_eq!(deleted["activeProfileId"], json!(DEFAULT_PROFILE_ID)); + } +} diff --git a/src/openhuman/agent/personality_paths.rs b/src/openhuman/profiles/paths.rs similarity index 89% rename from src/openhuman/agent/personality_paths.rs rename to src/openhuman/profiles/paths.rs index ad9e8cbad..f6ab476c3 100644 --- a/src/openhuman/agent/personality_paths.rs +++ b/src/openhuman/profiles/paths.rs @@ -2,7 +2,7 @@ use std::path::{Component, Path}; -use crate::openhuman::agent::profiles::AgentProfile; +use super::types::AgentProfile; /// Reject path strings that could escape the workspace: absolute paths, /// root/prefix components, or any `..` segment. @@ -219,10 +219,15 @@ pub trait HasToolkit { fn toolkit_name(&self) -> &str; } +impl HasToolkit for crate::openhuman::agent::prompts::ConnectedIntegration { + fn toolkit_name(&self) -> &str { + &self.toolkit + } +} + #[cfg(test)] mod tests { use super::*; - use crate::openhuman::agent::profiles::AgentProfile; use tempfile::TempDir; fn test_profile(id: &str) -> AgentProfile { @@ -241,6 +246,10 @@ mod tests { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -391,4 +400,34 @@ mod tests { let filtered = filter_integrations(&all, Some(&allowed)); assert!(filtered.is_empty()); } + + fn connected_integration( + toolkit: &str, + ) -> crate::openhuman::agent::prompts::ConnectedIntegration { + crate::openhuman::agent::prompts::ConnectedIntegration { + toolkit: toolkit.to_string(), + description: String::new(), + tools: Vec::new(), + gated_tools: Vec::new(), + connected: true, + connections: Vec::new(), + non_active_status: None, + } + } + + #[test] + fn filter_connected_integrations_by_profile_allowlist() { + // The HasToolkit impl lets the per-profile connector gate reuse + // filter_integrations on the real ConnectedIntegration type. + let all = vec![ + connected_integration("gmail"), + connected_integration("slack"), + connected_integration("notion"), + ]; + assert_eq!(filter_integrations(&all, None).len(), 3); + let allow = vec!["gmail".to_string(), "notion".to_string()]; + let filtered = filter_integrations(&all, Some(&allow)); + let kept: Vec<&str> = filtered.iter().map(|c| c.toolkit_name()).collect(); + assert_eq!(kept, vec!["gmail", "notion"]); + } } diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs new file mode 100644 index 000000000..5d530cfa0 --- /dev/null +++ b/src/openhuman/profiles/prompt_section.rs @@ -0,0 +1,69 @@ +//! Prompt section that injects a profile's free-form persona blurb. + +use crate::openhuman::context::prompt::{PromptContext, PromptSection}; +use anyhow::Result; + +/// Renders a profile's `system_prompt_suffix` (or any free-form persona body) +/// as a `## Agent profile` block in the system prompt. +pub struct AgentProfilePromptSection { + body: String, +} + +impl AgentProfilePromptSection { + pub fn new(body: String) -> Self { + Self { body } + } +} + +impl PromptSection for AgentProfilePromptSection { + fn name(&self) -> &str { + "agent_profile" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + if self.body.trim().is_empty() { + return Ok(String::new()); + } + Ok(format!("## Agent profile\n\n{}", self.body.trim())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn prompt_section_renders_expected_text() { + let section = AgentProfilePromptSection::new(" Be concise. ".into()); + assert_eq!(section.name(), "agent_profile"); + let visible_tool_names = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("/tmp"), + model_name: "test-model", + agent_id: "orchestrator", + tools: &[], + workflows: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible_tool_names, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + }; + let rendered = section.build(&ctx).expect("render profile section"); + assert!(rendered.starts_with("## Agent profile")); + assert!(rendered.contains("Be concise.")); + + let empty = AgentProfilePromptSection::new(" ".into()); + assert_eq!(empty.build(&ctx).expect("empty profile section"), ""); + } +} diff --git a/src/openhuman/profiles/schemas.rs b/src/openhuman/profiles/schemas.rs new file mode 100644 index 000000000..5b7ed0f63 --- /dev/null +++ b/src/openhuman/profiles/schemas.rs @@ -0,0 +1,295 @@ +//! Controller schemas + handlers for the `profiles` RPC namespace. +//! +//! Methods: `openhuman.profiles_list`, `openhuman.profile_select`, +//! `openhuman.profile_upsert`, `openhuman.profile_delete`. + +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use super::types::AgentProfile; +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("list"), + schemas("select"), + schemas("upsert"), + schemas("delete"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("list"), + handler: handle_profiles_list, + }, + RegisteredController { + schema: schemas("select"), + handler: handle_profile_select, + }, + RegisteredController { + schema: schemas("upsert"), + handler: handle_profile_upsert, + }, + RegisteredController { + schema: schemas("delete"), + handler: handle_profile_delete, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "list" => ControllerSchema { + namespace: "profiles", + function: "list", + description: "List persistent agent profiles and the active profile id.", + inputs: vec![], + outputs: vec![json_output("profiles", "Agent profile state payload.")], + }, + "select" => ControllerSchema { + namespace: "profiles", + function: "select", + description: "Select the active persistent agent profile.", + inputs: vec![required_string("profile_id", "Agent profile id.")], + outputs: vec![json_output( + "profiles", + "Updated agent profile state payload.", + )], + }, + "upsert" => ControllerSchema { + namespace: "profiles", + function: "upsert", + description: "Create or update an agent profile. The `profile` payload may include \ + memory_sources, includeAgentConversations, allowedSkills, \ + allowedMcpServers, composioIntegrations, allowedTools, and soulMd; \ + an omitted/empty allowlist means \"all\".", + inputs: vec![FieldSchema { + name: "profile", + ty: TypeSchema::Json, + comment: "Agent profile payload.", + required: true, + }], + outputs: vec![json_output( + "profiles", + "Updated agent profile state payload.", + )], + }, + "delete" => ControllerSchema { + namespace: "profiles", + function: "delete", + description: "Delete a custom agent profile.", + inputs: vec![required_string("profile_id", "Agent profile id.")], + outputs: vec![json_output( + "profiles", + "Updated agent profile state payload.", + )], + }, + _ => ControllerSchema { + namespace: "profiles", + function: "unknown", + description: "Unknown profiles controller function.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +#[derive(Debug, Deserialize)] +struct ProfileSelectParams { + profile_id: String, +} + +#[derive(Debug, Deserialize)] +struct ProfileUpsertParams { + profile: AgentProfile, +} + +#[derive(Debug, Deserialize)] +struct ProfileDeleteParams { + profile_id: String, +} + +fn handle_profiles_list(_params: Map) -> ControllerFuture { + Box::pin(super::ops::list()) +} + +fn handle_profile_select(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + super::ops::select(&p.profile_id).await + }) +} + +fn handle_profile_upsert(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + super::ops::upsert(p.profile).await + }) +} + +fn handle_profile_delete(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + super::ops::delete(&p.profile_id).await + }) +} + +fn deserialize_params(params: Map) -> Result { + serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) +} + +fn required_string(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::String, + comment, + required: true, + } +} + +fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Json, + comment, + required: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK; + use crate::openhuman::profiles::DEFAULT_PROFILE_ID; + use serde_json::json; + + #[test] + fn controller_schema_inventory_is_stable() { + let schemas = all_controller_schemas(); + let functions: Vec<_> = schemas.iter().map(|schema| schema.function).collect(); + assert_eq!(functions, vec!["list", "select", "upsert", "delete"]); + assert_eq!(schemas.len(), all_registered_controllers().len()); + assert!(schemas.iter().all(|s| s.namespace == "profiles")); + } + + #[test] + fn unknown_function_falls_back() { + let unknown = schemas("nope"); + assert_eq!(unknown.function, "unknown"); + assert_eq!(unknown.outputs[0].name, "error"); + } + + struct WorkspaceEnvGuard { + previous: Option, + } + + impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + } + Self { previous } + } + } + + impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", value); + }, + None => unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + }, + } + } + } + + #[tokio::test] + async fn profile_handlers_persist_and_return_profile_state() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + + let upserted = handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": "writer", + "name": "Writer", + "description": "Draft concise copy", + "agentId": "orchestrator", + "modelOverride": "agentic-v1", + "temperature": 0.2, + "systemPromptSuffix": "Use a crisp tone.", + "allowedTools": ["todo"], + "memorySources": ["slack-eng"], + "allowedSkills": ["deep-research"], + "includeAgentConversations": false, + "builtIn": false, + }), + )])) + .await + .expect("profile upsert"); + assert_eq!(upserted["activeProfileId"], DEFAULT_PROFILE_ID); + let writer = upserted["profiles"] + .as_array() + .expect("profiles array") + .iter() + .find(|profile| profile["id"] == "writer") + .expect("writer profile present"); + assert_eq!(writer["memorySources"], json!(["slack-eng"])); + assert_eq!(writer["allowedSkills"], json!(["deep-research"])); + assert_eq!(writer["includeAgentConversations"], json!(false)); + + let selected = handle_profile_select(Map::from_iter([( + "profile_id".into(), + Value::String("writer".into()), + )])) + .await + .expect("profile select"); + assert_eq!(selected["activeProfileId"], "writer"); + + let listed = handle_profiles_list(Map::new()) + .await + .expect("profiles list"); + assert_eq!(listed["activeProfileId"], "writer"); + + let deleted = handle_profile_delete(Map::from_iter([( + "profile_id".into(), + Value::String("writer".into()), + )])) + .await + .expect("profile delete"); + assert_eq!(deleted["activeProfileId"], DEFAULT_PROFILE_ID); + } + + #[tokio::test] + async fn profile_upsert_rejects_unknown_registered_agent_id() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global_builtins(); + + let err = handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": "bad", + "name": "Bad", + "description": "", + "agentId": "__missing_agent__", + "builtIn": false, + }), + )])) + .await + .expect_err("unknown agent should fail before store write"); + assert!(err.contains("agent definition"), "err: {err}"); + } +} diff --git a/src/openhuman/agent/profiles.rs b/src/openhuman/profiles/store.rs similarity index 62% rename from src/openhuman/agent/profiles.rs rename to src/openhuman/profiles/store.rs index b5faa5f03..ad391e974 100644 --- a/src/openhuman/agent/profiles.rs +++ b/src/openhuman/profiles/store.rs @@ -1,68 +1,17 @@ -//! Persistent user-selectable agent profiles. +//! Persistence layer for agent profiles. //! -//! Profiles let the UI choose a primary agent persona plus runtime defaults -//! without editing built-in agent TOML. The state is stored under -//! `/agent_profiles.json` and merged with built-in profiles on load -//! so new releases can add defaults without overwriting user-created profiles. +//! State is stored under `/agent_profiles.json` and merged with +//! built-in profiles on load so new releases can add defaults without +//! overwriting user-created profiles. -use crate::openhuman::context::prompt::{PromptContext, PromptSection}; +use super::types::{AgentProfile, AgentProfilesState, DEFAULT_PROFILE_ID}; use anyhow::Result; -use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; const PROFILE_FILE: &str = "agent_profiles.json"; -pub const DEFAULT_PROFILE_ID: &str = "default"; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentProfile { - pub id: String, - pub name: String, - pub description: String, - pub agent_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_override: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_prompt_suffix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_tools: Option>, - #[serde(default)] - pub built_in: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub avatar_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub voice_id: Option, - /// Inline SOUL.md content for this personality. Falls back to workspace root. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub soul_md: Option, - /// Relative path to a personality-specific SOUL.md file (checked before inline). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub soul_md_path: Option, - /// Composio toolkit slugs this personality can access. None = all integrations. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub composio_integrations: Option>, - /// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory_dir_suffix: Option, - /// Whether this profile is the master orchestrator personality. - #[serde(default)] - pub is_master: bool, - /// Display order (lower = shown first). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sort_order: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentProfilesState { - pub active_profile_id: String, - pub profiles: Vec, -} #[derive(Debug, Clone)] pub struct AgentProfileStore { @@ -76,7 +25,7 @@ impl AgentProfileStore { pub fn load(&self) -> Result { let path = self.path(); - tracing::debug!(path = %path.display(), "[agent:profiles] load entry"); + tracing::debug!(path = %path.display(), "[profiles] load entry"); let state = if path.exists() { let mut buf = String::new(); fs::File::open(&path) @@ -84,7 +33,7 @@ impl AgentProfileStore { tracing::debug!( path = %path.display(), error = %e, - "[agent:profiles] load open_error" + "[profiles] load open_error" ); format!("open agent profiles {}: {e}", path.display()) })? @@ -93,7 +42,7 @@ impl AgentProfileStore { tracing::debug!( path = %path.display(), error = %e, - "[agent:profiles] load read_error" + "[profiles] load read_error" ); format!("read agent profiles {}: {e}", path.display()) })?; @@ -101,12 +50,12 @@ impl AgentProfileStore { tracing::debug!( path = %path.display(), error = %e, - "[agent:profiles] load parse_error" + "[profiles] load parse_error" ); format!("parse agent profiles {}: {e}", path.display()) })? } else { - tracing::debug!(path = %path.display(), "[agent:profiles] load default_state"); + tracing::debug!(path = %path.display(), "[profiles] load default_state"); AgentProfilesState::default() }; let state = normalise_state(state); @@ -114,7 +63,7 @@ impl AgentProfileStore { path = %path.display(), active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), - "[agent:profiles] load ok" + "[profiles] load ok" ); Ok(state) } @@ -123,14 +72,14 @@ impl AgentProfileStore { tracing::debug!( active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), - "[agent:profiles] save entry" + "[profiles] save entry" ); let state = normalise_state(state); let path = self.path(); let parent = path.parent().ok_or_else(|| { tracing::debug!( path = %path.display(), - "[agent:profiles] save invalid_path" + "[profiles] save invalid_path" ); format!("invalid agent profiles path {}", path.display()) })?; @@ -139,7 +88,7 @@ impl AgentProfileStore { path = %path.display(), parent = %parent.display(), error = %e, - "[agent:profiles] save create_dir_error" + "[profiles] save create_dir_error" ); format!("create agent profiles dir {}: {e}", parent.display()) })?; @@ -147,7 +96,7 @@ impl AgentProfileStore { tracing::debug!( parent = %parent.display(), error = %e, - "[agent:profiles] save tempfile_error" + "[profiles] save tempfile_error" ); format!( "create agent profiles tempfile in {}: {e}", @@ -155,22 +104,22 @@ impl AgentProfileStore { ) })?; let bytes = serde_json::to_vec_pretty(&state).map_err(|e| { - tracing::debug!(error = %e, "[agent:profiles] save serialize_error"); + tracing::debug!(error = %e, "[profiles] save serialize_error"); format!("serialize agent profiles: {e}") })?; tmp.write_all(&bytes).map_err(|e| { - tracing::debug!(error = %e, "[agent:profiles] save write_error"); + tracing::debug!(error = %e, "[profiles] save write_error"); format!("write agent profiles tempfile: {e}") })?; tmp.as_file().sync_all().map_err(|e| { - tracing::debug!(error = %e, "[agent:profiles] save fsync_error"); + tracing::debug!(error = %e, "[profiles] save fsync_error"); format!("fsync agent profiles tempfile: {e}") })?; tmp.persist(&path).map_err(|e| { tracing::debug!( path = %path.display(), error = %e, - "[agent:profiles] save persist_error" + "[profiles] save persist_error" ); format!("persist agent profiles {}: {e}", path.display()) })?; @@ -178,7 +127,7 @@ impl AgentProfileStore { path = %path.display(), active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), - "[agent:profiles] save ok" + "[profiles] save ok" ); Ok(state) } @@ -186,13 +135,13 @@ impl AgentProfileStore { pub fn select(&self, profile_id: &str) -> Result { let mut state = self.load()?; let profile_id = profile_id.trim(); - tracing::debug!(profile_id, "[agent:profiles] select entry"); + tracing::debug!(profile_id, "[profiles] select entry"); if !state.profiles.iter().any(|p| p.id == profile_id) { - tracing::debug!(profile_id, "[agent:profiles] select not_found"); + tracing::debug!(profile_id, "[profiles] select not_found"); return Err(format!("agent profile '{profile_id}' not found")); } state.active_profile_id = profile_id.to_string(); - tracing::debug!(profile_id, "[agent:profiles] select active_profile_changed"); + tracing::debug!(profile_id, "[profiles] select active_profile_changed"); self.save(state) } @@ -202,10 +151,10 @@ impl AgentProfileStore { tracing::debug!( profile_id = %profile.id, agent_id = %profile.agent_id, - "[agent:profiles] upsert entry" + "[profiles] upsert entry" ); let profile = if profile.id == DEFAULT_PROFILE_ID { - tracing::debug!("[agent:profiles] upsert built_in_default_merge"); + tracing::debug!("[profiles] upsert built_in_default_merge"); let mut default = built_in_default_profile(); default.name = profile.name; default.description = profile.description; @@ -218,6 +167,10 @@ impl AgentProfileStore { default.soul_md = profile.soul_md; default.soul_md_path = profile.soul_md_path; default.composio_integrations = profile.composio_integrations; + default.memory_sources = profile.memory_sources; + default.include_agent_conversations = profile.include_agent_conversations; + default.allowed_skills = profile.allowed_skills; + default.allowed_mcp_servers = profile.allowed_mcp_servers; // memory_dir_suffix stays as built-in default (don't let user override the default's suffix) default.sort_order = profile.sort_order; default @@ -274,10 +227,10 @@ impl AgentProfileStore { }; if let Some(existing) = state.profiles.iter_mut().find(|p| p.id == profile.id) { - tracing::debug!(profile_id = %profile.id, "[agent:profiles] upsert replace_existing"); + tracing::debug!(profile_id = %profile.id, "[profiles] upsert replace_existing"); *existing = profile; } else { - tracing::debug!(profile_id = %profile.id, "[agent:profiles] upsert insert_new"); + tracing::debug!(profile_id = %profile.id, "[profiles] upsert insert_new"); state.profiles.push(profile); } self.save(state) @@ -285,12 +238,12 @@ impl AgentProfileStore { pub fn delete(&self, profile_id: &str) -> Result { let profile_id = profile_id.trim(); - tracing::debug!(profile_id, "[agent:profiles] delete entry"); + tracing::debug!(profile_id, "[profiles] delete entry"); if built_in_profiles() .iter() .any(|profile| profile.id == profile_id) { - tracing::debug!(profile_id, "[agent:profiles] delete built_in_rejected"); + tracing::debug!(profile_id, "[profiles] delete built_in_rejected"); return Err(format!( "built-in agent profile '{profile_id}' cannot be deleted" )); @@ -299,20 +252,17 @@ impl AgentProfileStore { let before = state.profiles.len(); state.profiles.retain(|p| p.id != profile_id); if state.profiles.len() == before { - tracing::debug!(profile_id, "[agent:profiles] delete not_found"); + tracing::debug!(profile_id, "[profiles] delete not_found"); return Err(format!("agent profile '{profile_id}' not found")); } if state.active_profile_id == profile_id { state.active_profile_id = DEFAULT_PROFILE_ID.to_string(); - tracing::debug!( - profile_id, - "[agent:profiles] delete active_profile_fallback" - ); + tracing::debug!(profile_id, "[profiles] delete active_profile_fallback"); } tracing::debug!( profile_id, profile_count = state.profiles.len(), - "[agent:profiles] delete removed" + "[profiles] delete removed" ); self.save(state) } @@ -326,10 +276,7 @@ impl AgentProfileStore { .map(str::trim) .filter(|id| !id.is_empty()) .unwrap_or(state.active_profile_id.as_str()); - tracing::debug!( - requested_profile_id = requested, - "[agent:profiles] resolve entry" - ); + tracing::debug!(requested_profile_id = requested, "[profiles] resolve entry"); let profile = state .profiles .iter() @@ -346,7 +293,7 @@ impl AgentProfileStore { requested_profile_id = requested, resolved_profile_id = %profile.id, agent_id = %profile.agent_id, - "[agent:profiles] resolve ok" + "[profiles] resolve ok" ); Ok((state, profile)) } @@ -378,6 +325,10 @@ pub fn built_in_profiles() -> Vec { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -400,6 +351,10 @@ pub fn built_in_profiles() -> Vec { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -422,6 +377,10 @@ pub fn built_in_profiles() -> Vec { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -444,6 +403,10 @@ pub fn built_in_profiles() -> Vec { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -451,34 +414,7 @@ pub fn built_in_profiles() -> Vec { ] } -pub fn profile_signature(profile: &AgentProfile) -> String { - serde_json::to_string(profile).unwrap_or_else(|_| profile.id.clone()) -} - -pub struct AgentProfilePromptSection { - body: String, -} - -impl AgentProfilePromptSection { - pub fn new(body: String) -> Self { - Self { body } - } -} - -impl PromptSection for AgentProfilePromptSection { - fn name(&self) -> &str { - "agent_profile" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - if self.body.trim().is_empty() { - return Ok(String::new()); - } - Ok(format!("## Agent profile\n\n{}", self.body.trim())) - } -} - -fn built_in_default_profile() -> AgentProfile { +pub(crate) fn built_in_default_profile() -> AgentProfile { AgentProfile { id: DEFAULT_PROFILE_ID.to_string(), name: "Default".to_string(), @@ -494,6 +430,10 @@ fn built_in_default_profile() -> AgentProfile { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: Some("".into()), is_master: true, sort_order: None, @@ -504,7 +444,7 @@ fn normalise_state(state: AgentProfilesState) -> AgentProfilesState { tracing::trace!( active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), - "[agent:profiles] normalise_state entry" + "[profiles] normalise_state entry" ); let mut by_id: BTreeMap = built_in_profiles() .into_iter() @@ -550,6 +490,22 @@ fn normalise_state(state: AgentProfilesState) -> AgentProfilesState { } } +/// Trim + drop empty entries from an optional string-list allowlist; an empty +/// result normalises to `None` (the "all / unrestricted" sentinel). +fn normalise_allowlist(list: Option>) -> Option> { + let cleaned = list.map(|items| { + items + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>() + }); + match cleaned { + Some(items) if items.is_empty() => None, + other => other, + } +} + fn normalise_profile(mut profile: AgentProfile) -> AgentProfile { profile.id = slugify_profile_id(&profile.id); if profile.id.is_empty() { @@ -572,16 +528,7 @@ fn normalise_profile(mut profile: AgentProfile) -> AgentProfile { .system_prompt_suffix .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - profile.allowed_tools = profile.allowed_tools.map(|tools| { - tools - .into_iter() - .map(|tool| tool.trim().to_string()) - .filter(|tool| !tool.is_empty()) - .collect::>() - }); - if matches!(profile.allowed_tools.as_ref(), Some(tools) if tools.is_empty()) { - profile.allowed_tools = None; - } + profile.allowed_tools = normalise_allowlist(profile.allowed_tools); profile.avatar_url = profile .avatar_url .map(|s| s.trim().to_string()) @@ -598,16 +545,10 @@ fn normalise_profile(mut profile: AgentProfile) -> AgentProfile { .soul_md_path .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - profile.composio_integrations = profile.composio_integrations.map(|tools| { - tools - .into_iter() - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect::>() - }); - if matches!(profile.composio_integrations.as_ref(), Some(v) if v.is_empty()) { - profile.composio_integrations = None; - } + profile.composio_integrations = normalise_allowlist(profile.composio_integrations); + profile.memory_sources = normalise_allowlist(profile.memory_sources); + profile.allowed_skills = normalise_allowlist(profile.allowed_skills); + profile.allowed_mcp_servers = normalise_allowlist(profile.allowed_mcp_servers); // Note: `Some("")` is the sentinel used exclusively by the default profile // to indicate the legacy `memory/` directory (no suffix). `normalise_state` // re-applies it after the filter below, so any `Some("")` on a non-default @@ -662,35 +603,46 @@ impl Default for AgentProfilesState { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, ToolCallFormat}; - use std::collections::HashSet; use tempfile::tempdir; + /// Minimal custom profile literal for tests, with all allowlists unset. + fn custom(id: &str, name: &str, agent_id: &str) -> AgentProfile { + AgentProfile { + id: id.into(), + name: name.into(), + description: String::new(), + agent_id: agent_id.into(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + } + } + #[test] fn profile_store_roundtrips_active_profile_and_custom_entries() { let dir = tempdir().expect("tempdir"); let store = AgentProfileStore::new(dir.path().to_path_buf()); - let state = store - .upsert(AgentProfile { - id: " Custom Profile ".into(), - name: " Custom Profile ".into(), - description: " My custom profile ".into(), - agent_id: " planner ".into(), - model_override: Some(" agentic-v1 ".into()), - temperature: Some(0.25), - system_prompt_suffix: Some(" Be brief. ".into()), - allowed_tools: Some(vec![" todo ".into(), "".into()]), - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) - .expect("upsert"); + let mut profile = custom(" Custom Profile ", " Custom Profile ", " planner "); + profile.description = " My custom profile ".into(); + profile.model_override = Some(" agentic-v1 ".into()); + profile.temperature = Some(0.25); + profile.system_prompt_suffix = Some(" Be brief. ".into()); + profile.allowed_tools = Some(vec![" todo ".into(), "".into()]); + let state = store.upsert(profile).expect("upsert"); assert!(state.profiles.iter().any(|p| p.id == "custom-profile")); let selected = store.select("custom-profile").expect("select"); @@ -735,58 +687,49 @@ mod tests { #[test] fn normalise_state_falls_back_to_default_active_profile() { + let mut bad = custom(" ", " ", " "); + bad.description = " ignored ".into(); + bad.model_override = Some(" ".into()); + bad.system_prompt_suffix = Some(" ".into()); + bad.allowed_tools = Some(vec![" ".into()]); let state = normalise_state(AgentProfilesState { active_profile_id: "missing".into(), - profiles: vec![AgentProfile { - id: " ".into(), - name: " ".into(), - description: " ignored ".into(), - agent_id: " ".into(), - model_override: Some(" ".into()), - temperature: None, - system_prompt_suffix: Some(" ".into()), - allowed_tools: Some(vec![" ".into()]), - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }], + profiles: vec![bad], }); assert_eq!(state.active_profile_id, DEFAULT_PROFILE_ID); assert!(!state.profiles.iter().any(|profile| profile.id.is_empty())); } + #[test] + fn normalise_profile_drops_empty_allowlists_to_none() { + let mut profile = custom("scoped", "Scoped", "orchestrator"); + profile.memory_sources = Some(vec![" ".into(), "".into()]); + profile.allowed_skills = Some(vec![" deep-research ".into()]); + profile.allowed_mcp_servers = Some(vec![]); + profile.composio_integrations = Some(vec![" ".into()]); + let normalised = normalise_profile(profile); + assert_eq!(normalised.memory_sources, None); + assert_eq!( + normalised.allowed_skills.as_deref(), + Some(vec!["deep-research".to_string()].as_slice()) + ); + assert_eq!(normalised.allowed_mcp_servers, None); + assert_eq!(normalised.composio_integrations, None); + } + #[test] fn upsert_default_profile_preserves_builtin_default_identity() { let dir = tempdir().expect("tempdir"); let store = AgentProfileStore::new(dir.path().to_path_buf()); - let state = store - .upsert(AgentProfile { - id: DEFAULT_PROFILE_ID.into(), - name: " Default Custom ".into(), - description: " custom description ".into(), - agent_id: " planner ".into(), - model_override: Some(" agentic-v1 ".into()), - temperature: Some(0.3), - system_prompt_suffix: Some(" suffix ".into()), - allowed_tools: Some(vec![" todo ".into()]), - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) - .expect("upsert default"); + let mut profile = custom(DEFAULT_PROFILE_ID, " Default Custom ", " planner "); + profile.description = " custom description ".into(); + profile.model_override = Some(" agentic-v1 ".into()); + profile.temperature = Some(0.3); + profile.system_prompt_suffix = Some(" suffix ".into()); + profile.allowed_tools = Some(vec![" todo ".into()]); + profile.memory_sources = Some(vec!["slack-eng".into()]); + let state = store.upsert(profile).expect("upsert default"); let default = state .profiles .iter() @@ -796,6 +739,11 @@ mod tests { assert_eq!(default.agent_id, "orchestrator"); assert_eq!(default.name, "Default Custom"); assert_eq!(default.system_prompt_suffix.as_deref(), Some("suffix")); + // New allowlist fields round-trip through the default merge branch. + assert_eq!( + default.memory_sources.as_deref(), + Some(vec!["slack-eng".to_string()].as_slice()) + ); } #[test] @@ -825,25 +773,7 @@ mod tests { let dir = tempdir().expect("tempdir"); let store = AgentProfileStore::new(dir.path().to_path_buf()); store - .upsert(AgentProfile { - id: "writer".into(), - name: "Writer".into(), - description: String::new(), - agent_id: "planner".into(), - model_override: None, - temperature: None, - system_prompt_suffix: None, - allowed_tools: None, - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) + .upsert(custom("writer", "Writer", "planner")) .expect("upsert"); store.select("writer").expect("select"); @@ -853,70 +783,12 @@ mod tests { assert_eq!(fallback.id, DEFAULT_PROFILE_ID); } - #[test] - fn profile_signature_and_prompt_section_render_expected_text() { - let profile = built_in_profiles() - .into_iter() - .find(|profile| profile.id == "planner") - .expect("planner profile"); - let signature = profile_signature(&profile); - assert!(signature.contains("\"planner\"")); - - let section = AgentProfilePromptSection::new(" Be concise. ".into()); - assert_eq!(section.name(), "agent_profile"); - let visible_tool_names = HashSet::new(); - let ctx = PromptContext { - workspace_dir: std::path::Path::new("/tmp"), - model_name: "test-model", - agent_id: "orchestrator", - tools: &[], - workflows: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &visible_tool_names, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - connected_identities_md: String::new(), - include_profile: false, - include_memory_md: false, - curated_snapshot: None, - user_identity: None, - personality_soul_md: None, - personality_memory_md: None, - personality_roster: vec![], - }; - let rendered = section.build(&ctx).expect("render profile section"); - assert!(rendered.starts_with("## Agent profile")); - assert!(rendered.contains("Be concise.")); - - let empty = AgentProfilePromptSection::new(" ".into()); - assert_eq!(empty.build(&ctx).expect("empty profile section"), ""); - } - #[test] fn deleting_active_custom_profile_falls_back_to_default() { let dir = tempdir().expect("tempdir"); let store = AgentProfileStore::new(dir.path().to_path_buf()); store - .upsert(AgentProfile { - id: "tmp".into(), - name: "Tmp".into(), - description: String::new(), - agent_id: "orchestrator".into(), - model_override: None, - temperature: None, - system_prompt_suffix: None, - allowed_tools: None, - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) + .upsert(custom("tmp", "Tmp", "orchestrator")) .expect("upsert"); store.select("tmp").expect("select"); let state = store.delete("tmp").expect("delete"); @@ -930,50 +802,14 @@ mod tests { let store = AgentProfileStore::new(dir.path().to_path_buf()); // First custom profile gets "-1" let state = store - .upsert(AgentProfile { - id: "alice".into(), - name: "Alice".into(), - description: "First personality".into(), - agent_id: "orchestrator".into(), - model_override: None, - temperature: None, - system_prompt_suffix: None, - allowed_tools: None, - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) + .upsert(custom("alice", "Alice", "orchestrator")) .expect("upsert alice"); let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); assert_eq!(alice.memory_dir_suffix.as_deref(), Some("-1")); // Second custom profile gets "-2" let state = store - .upsert(AgentProfile { - id: "bob".into(), - name: "Bob".into(), - description: "Second personality".into(), - agent_id: "orchestrator".into(), - model_override: None, - temperature: None, - system_prompt_suffix: None, - allowed_tools: None, - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) + .upsert(custom("bob", "Bob", "orchestrator")) .expect("upsert bob"); let bob = state.profiles.iter().find(|p| p.id == "bob").unwrap(); assert_eq!(bob.memory_dir_suffix.as_deref(), Some("-2")); @@ -981,54 +817,17 @@ mod tests { // Delete alice, create charlie — should reuse "-1" store.delete("alice").expect("delete alice"); let state = store - .upsert(AgentProfile { - id: "charlie".into(), - name: "Charlie".into(), - description: "Third personality".into(), - agent_id: "orchestrator".into(), - model_override: None, - temperature: None, - system_prompt_suffix: None, - allowed_tools: None, - built_in: false, - avatar_url: None, - voice_id: None, - soul_md: None, - soul_md_path: None, - composio_integrations: None, - memory_dir_suffix: None, - is_master: false, - sort_order: None, - }) + .upsert(custom("charlie", "Charlie", "orchestrator")) .expect("upsert charlie"); let charlie = state.profiles.iter().find(|p| p.id == "charlie").unwrap(); assert_eq!(charlie.memory_dir_suffix.as_deref(), Some("-1")); } - #[test] - fn backwards_compat_deserialize_without_new_fields() { - let json = r#"{ - "activeProfileId": "default", - "profiles": [{ - "id": "default", - "name": "Default", - "description": "The standard OpenHuman orchestrator.", - "agentId": "orchestrator", - "builtIn": true - }] - }"#; - let state: AgentProfilesState = serde_json::from_str(json).expect("deserialize"); - let profile = &state.profiles[0]; - assert_eq!(profile.avatar_url, None); - assert_eq!(profile.voice_id, None); - assert_eq!(profile.memory_dir_suffix, None); - assert!(!profile.is_master); - } - #[test] fn default_profile_has_master_and_memory_suffix() { let default = built_in_default_profile(); assert!(default.is_master); assert_eq!(default.memory_dir_suffix.as_deref(), Some("")); + assert!(default.include_agent_conversations); } } diff --git a/src/openhuman/profiles/types.rs b/src/openhuman/profiles/types.rs new file mode 100644 index 000000000..319faf9b5 --- /dev/null +++ b/src/openhuman/profiles/types.rs @@ -0,0 +1,148 @@ +//! Serde domain types for persistent agent profiles. +//! +//! Profiles let the UI choose a primary agent persona plus runtime defaults +//! (model, temperature, soul, allowed tools/skills/connectors, memory sources) +//! without editing built-in agent TOML. + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_PROFILE_ID: &str = "default"; + +/// A user-selectable agent "flavour". +/// +/// `None` on any allowlist field (`allowed_tools`, `allowed_skills`, +/// `allowed_mcp_servers`, `composio_integrations`, `memory_sources`) is the +/// "all / unrestricted" sentinel. An empty vec normalises to `None`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfile { + pub id: String, + pub name: String, + pub description: String, + pub agent_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_override: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt_suffix: Option, + /// Tool `name()` allowlist this profile can see. None = all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, + #[serde(default)] + pub built_in: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub voice_id: Option, + /// Inline SOUL.md content for this personality. Falls back to workspace root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub soul_md: Option, + /// Relative path to a personality-specific SOUL.md file (checked before inline). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub soul_md_path: Option, + /// Composio toolkit slugs this personality can access. None = all integrations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub composio_integrations: Option>, + /// Memory-source entry ids this profile recalls from. None = all sources. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_sources: Option>, + /// Whether this profile recalls prior agent conversations / cross-chat + /// context. Default true (preserves legacy behaviour). + #[serde(default = "default_true")] + pub include_agent_conversations: bool, + /// Skill / workflow ids this profile can list and run. None = all skills. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_skills: Option>, + /// MCP server names this profile can reach. None = all configured servers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_mcp_servers: Option>, + /// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_dir_suffix: Option, + /// Whether this profile is the master orchestrator personality. + #[serde(default)] + pub is_master: bool, + /// Display order (lower = shown first). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort_order: Option, +} + +/// serde default for `include_agent_conversations` (true). +pub(crate) fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfilesState { + pub active_profile_id: String, + pub profiles: Vec, +} + +/// A stable, serialized signature of a profile — used as a cache/identity key +/// for prompt construction. +pub fn profile_signature(profile: &AgentProfile) -> String { + serde_json::to_string(profile).unwrap_or_else(|_| profile.id.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn profile_signature_includes_id() { + let profile = AgentProfile { + id: "planner".into(), + name: "Planner".into(), + description: String::new(), + agent_id: "planner".into(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: true, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + }; + assert!(profile_signature(&profile).contains("\"planner\"")); + } + + #[test] + fn backwards_compat_deserialize_without_new_fields() { + // Pre-profiles-feature payload: none of the new allowlist fields, no + // include_agent_conversations. Must deserialize with safe defaults. + let json = json!({ + "activeProfileId": "default", + "profiles": [{ + "id": "default", + "name": "Default", + "description": "The standard OpenHuman orchestrator.", + "agentId": "orchestrator", + "builtIn": true + }] + }); + let state: AgentProfilesState = serde_json::from_value(json).expect("deserialize"); + let profile = &state.profiles[0]; + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.voice_id, None); + assert_eq!(profile.memory_dir_suffix, None); + assert_eq!(profile.memory_sources, None); + assert_eq!(profile.allowed_skills, None); + assert_eq!(profile.allowed_mcp_servers, None); + // Defaults to true so existing users keep cross-chat recall. + assert!(profile.include_agent_conversations); + assert!(!profile.is_master); + } +} diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs index 9cb342f25..2e2a22b59 100644 --- a/src/openhuman/runtime_node/ops.rs +++ b/src/openhuman/runtime_node/ops.rs @@ -80,6 +80,8 @@ fn build_runtime_tools(config: &Config) -> Result>, String> { &config.action_dir, &config.agents, config, + None, + None, ); debug!( tool_count = built.len(), diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 73df0702c..5374ec043 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -76,10 +76,16 @@ pub fn all_tools( action_dir, agents, root_config, + None, + None, ) } /// Create full tool registry including memory tools. +/// +/// `skill_allowlist` / `mcp_allowlist` scope the skill (workflow) and MCP-server +/// surfaces to an active agent profile's selection. `None` for either means +/// "all" (the default for every non-profile caller). #[allow(clippy::implicit_hasher, clippy::too_many_arguments)] pub fn all_tools_with_runtime( config: Arc, @@ -92,6 +98,8 @@ pub fn all_tools_with_runtime( action_dir: &std::path::Path, agents: &HashMap, root_config: &crate::openhuman::config::Config, + skill_allowlist: Option<&std::collections::HashSet>, + mcp_allowlist: Option<&[String]>, ) -> Vec> { // Build a session-scoped managed Node.js bootstrap once, so ShellTool, // NodeExecTool, and NpmExecTool all share the same memoised resolution @@ -187,7 +195,7 @@ pub fn all_tools_with_runtime( // Both wrap `skill_runtime::spawn_workflow_run_background` + // `await_run_outcome` — the same spawn path `openhuman.workflows_run` // JSON-RPC uses, so RPC and tool callers stay in sync. - Box::new(RunWorkflowTool::new()), + Box::new(RunWorkflowTool::new().with_skill_allowlist(skill_allowlist.cloned())), Box::new(AwaitWorkflowTool::new()), Box::new(CurrentTimeTool::new()), // Deterministic time-expression → timestamp resolver. `current_time` @@ -313,8 +321,13 @@ pub fn all_tools_with_runtime( // above, so it is not duplicated. Reads ship default-ON; the // create/install/uninstall mutators ship default-OFF via // `tools::user_filter` (install also fetches remote content). - Box::new(WorkflowListTool::new(config.clone())), - Box::new(WorkflowDescribeTool::new(config.clone())), + Box::new( + WorkflowListTool::new(config.clone()).with_skill_allowlist(skill_allowlist.cloned()), + ), + Box::new( + WorkflowDescribeTool::new(config.clone()) + .with_skill_allowlist(skill_allowlist.cloned()), + ), // Skill registry tools — browse/search/install from remote registries. // Browse and search are read-only (default-ON); install is a write // operation (fetches remote content and writes to disk). @@ -326,7 +339,10 @@ pub fn all_tools_with_runtime( // Skill runtime probes — resolve the reusable Node/Python runtimes // that skill execution relies on before a script-backed skill runs. Box::new(SkillRuntimeResolveRuntimesTool::new(config.clone())), - Box::new(WorkflowReadResourceTool::new(config.clone())), + Box::new( + WorkflowReadResourceTool::new(config.clone()) + .with_skill_allowlist(skill_allowlist.cloned()), + ), Box::new(WorkflowRecentRunsTool::new(config.clone())), Box::new(WorkflowReadRunLogTool::new(config.clone())), Box::new(WorkflowCreateTool::new(config.clone())), @@ -592,7 +608,15 @@ pub fn all_tools_with_runtime( // gitbooks — answers questions about OpenHuman by calling the // GitBook MCP server. Two tools mirroring the upstream MCP tools. - if root_config.gitbooks.enabled { + // Gitbooks is modelled as a legacy MCP server (`McpServerRegistry`), so it + // honours the same per-profile `mcp_allowlist`: a profile that scopes its + // MCP servers and omits "gitbooks" must not see this surface either. + let gitbooks_allowed = mcp_allowlist.is_none_or(|allowed| { + allowed + .iter() + .any(|name| name.eq_ignore_ascii_case("gitbooks")) + }); + if root_config.gitbooks.enabled && gitbooks_allowed { tools.push(Box::new(GitbooksSearchTool::new( root_config.gitbooks.endpoint.clone(), root_config.gitbooks.timeout_secs, @@ -602,6 +626,8 @@ pub fn all_tools_with_runtime( root_config.gitbooks.timeout_secs, ))); tracing::debug!("[gitbooks] registered gitbooks_search + gitbooks_get_page"); + } else if root_config.gitbooks.enabled { + tracing::debug!("[profiles] gitbooks tools suppressed by profile mcp allowlist"); } // MCP setup-agent tool surface (search/get/request_secret/test/install). @@ -621,8 +647,15 @@ pub fn all_tools_with_runtime( // Generic remote MCP bridge tools. These let the agent enumerate // named MCP servers and forward `tools/call` through the core // instead of hardcoding one bespoke MCP integration per server. - let mcp_registry = - Arc::new(crate::openhuman::mcp_client::McpServerRegistry::from_config(root_config)); + let mcp_registry = { + let base = crate::openhuman::mcp_client::McpServerRegistry::from_config(root_config); + // Scope the MCP surface to the active profile's allowlist. `None` keeps + // every configured server; `Some(&[])` yields an empty registry. + match mcp_allowlist { + Some(allowed) => Arc::new(base.retaining_servers(allowed)), + None => Arc::new(base), + } + }; if !mcp_registry.is_empty() { tools.push(Box::new(McpListServersTool::new(Arc::clone(&mcp_registry)))); tools.push(Box::new(McpListToolsTool::new(Arc::clone(&mcp_registry)))); diff --git a/src/openhuman/workflows/tools.rs b/src/openhuman/workflows/tools.rs index d06076582..bd2474736 100644 --- a/src/openhuman/workflows/tools.rs +++ b/src/openhuman/workflows/tools.rs @@ -49,17 +49,38 @@ fn read_workflow_id(args: &serde_json::Value) -> anyhow::Result { .map_err(|_| anyhow::anyhow!("missing required string argument `workflow_id`")) } +/// Skill/workflow allowlist applied per agent profile. `None` = all skills are +/// visible (the default). `Some(set)` restricts to the named `dir_name` slugs. +type SkillAllowlist = Option>; + +/// Whether `dir_name` passes the optional per-profile skill allowlist. +fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { + match allowlist { + None => true, + Some(set) => set.contains(dir_name), + } +} + /// List installed skills. pub struct WorkflowListTool { workspace_dir: PathBuf, + skill_allowlist: SkillAllowlist, } impl WorkflowListTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + skill_allowlist: None, } } + + /// Scope the listed workflows to a per-profile allowlist of `dir_name` + /// slugs. `None` leaves all workflows visible. + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } } #[async_trait] @@ -83,7 +104,15 @@ impl Tool for WorkflowListTool { log::debug!("[tool][workflows] list invoked"); let home = dirs::home_dir(); let trusted = is_workspace_trusted(&self.workspace_dir); - let workflows = discover_workflows(home.as_deref(), Some(&self.workspace_dir), trusted); + let mut workflows = discover_workflows(home.as_deref(), Some(&self.workspace_dir), trusted); + if self.skill_allowlist.is_some() { + let before = workflows.len(); + workflows.retain(|w| skill_allowed(&self.skill_allowlist, &w.dir_name)); + log::debug!( + "[profiles] list_workflows scoped to profile allowlist: before={before} after={}", + workflows.len() + ); + } Ok(ToolResult::success(serde_json::to_string(&json!({ "count": workflows.len(), "workflows": workflows, @@ -98,14 +127,22 @@ impl Tool for WorkflowListTool { /// Describe one skill (definition + declared inputs). pub struct WorkflowDescribeTool { workspace_dir: PathBuf, + skill_allowlist: SkillAllowlist, } impl WorkflowDescribeTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + skill_allowlist: None, } } + + /// Scope describe access to a per-profile allowlist of `dir_name` slugs. + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } } #[async_trait] @@ -132,6 +169,12 @@ impl Tool for WorkflowDescribeTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] describe invoked"); let skill_id = read_workflow_id(&args)?; + if !skill_allowed(&self.skill_allowlist, &skill_id) { + log::debug!("[profiles] describe_workflow blocked by profile allowlist: {skill_id}"); + return Ok(ToolResult::error(format!( + "describe_workflow: workflow `{skill_id}` is not available to the active agent profile" + ))); + } let def = get_workflow(&self.workspace_dir, &skill_id) .ok_or_else(|| anyhow::anyhow!("describe_workflow: workflow `{skill_id}` not found"))?; Ok(ToolResult::success(serde_json::to_string(&json!({ @@ -149,14 +192,24 @@ impl Tool for WorkflowDescribeTool { /// Read a bundled resource file from a skill. pub struct WorkflowReadResourceTool { workspace_dir: PathBuf, + skill_allowlist: SkillAllowlist, } impl WorkflowReadResourceTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + skill_allowlist: None, } } + + /// Scope resource reads to a per-profile allowlist of `dir_name` slugs, so a + /// restricted profile can't exfiltrate the bundled scripts/docs of a + /// workflow outside its skill set. + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } } #[async_trait] @@ -186,6 +239,14 @@ impl Tool for WorkflowReadResourceTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] read_resource invoked"); let skill_id = read_workflow_id(&args)?; + if !skill_allowed(&self.skill_allowlist, &skill_id) { + log::debug!( + "[profiles] read_workflow_resource blocked by profile allowlist: {skill_id}" + ); + return Ok(ToolResult::error(format!( + "read_workflow_resource: workflow `{skill_id}` is not available to the active agent profile" + ))); + } let relative_path = read_required_str(&args, "relative_path")?; let content = read_workflow_resource(&self.workspace_dir, &skill_id, Path::new(&relative_path)) @@ -485,6 +546,39 @@ mod tests { Arc::new(Config::default()) } + #[test] + fn skill_allowed_respects_optional_allowlist() { + // None = all skills visible. + assert!(skill_allowed(&None, "deep-research")); + // Some(set) restricts to named dir_name slugs. + let set: std::collections::HashSet = + ["deep-research".to_string()].into_iter().collect(); + assert!(skill_allowed(&Some(set.clone()), "deep-research")); + assert!(!skill_allowed(&Some(set), "ship-and-babysit")); + // Empty allowlist blocks everything (profile selected no skills). + assert!(!skill_allowed( + &Some(std::collections::HashSet::new()), + "anything" + )); + } + + #[tokio::test] + async fn describe_workflow_blocks_disallowed_skill_before_lookup() { + let allow: std::collections::HashSet = + ["allowed-skill".to_string()].into_iter().collect(); + let tool = WorkflowDescribeTool::new(cfg()).with_skill_allowlist(Some(allow)); + let res = tool + .execute(json!({ "workflow_id": "blocked-skill" })) + .await + .expect("execute"); + assert!(res.is_error, "disallowed skill must return an error result"); + let text = serde_json::to_string(&res.content).expect("serialize content"); + assert!( + text.contains("not available to the active agent profile"), + "expected profile-allowlist rejection, got: {text}" + ); + } + #[test] fn names_and_levels() { let c = cfg(); diff --git a/tests/domain_modules_e2e.rs b/tests/domain_modules_e2e.rs index a5dfadb9a..b4bf2ce8f 100644 --- a/tests/domain_modules_e2e.rs +++ b/tests/domain_modules_e2e.rs @@ -451,11 +451,11 @@ async fn config_agent_tools_and_threads_mutation_paths_round_trip() { let profiles_initial = rpc( &harness.rpc_base, 31_001, - "openhuman.agent_profiles_list", + "openhuman.profiles_list", json!({}), ) .await; - let initial = ok(&profiles_initial, "agent_profiles_list initial"); + let initial = ok(&profiles_initial, "profiles_list initial"); assert_eq!( initial.get("activeProfileId").and_then(Value::as_str), Some("default") @@ -464,7 +464,7 @@ async fn config_agent_tools_and_threads_mutation_paths_round_trip() { let upsert_profile = rpc( &harness.rpc_base, 31_002, - "openhuman.agent_profile_upsert", + "openhuman.profiles_upsert", json!({ "profile": { "id": "E2E Planner", @@ -480,7 +480,7 @@ async fn config_agent_tools_and_threads_mutation_paths_round_trip() { }), ) .await; - let upserted = ok(&upsert_profile, "agent_profile_upsert"); + let upserted = ok(&upsert_profile, "profiles_upsert"); assert!( upserted .get("profiles") @@ -494,12 +494,12 @@ async fn config_agent_tools_and_threads_mutation_paths_round_trip() { let select_profile = rpc( &harness.rpc_base, 31_003, - "openhuman.agent_profile_select", + "openhuman.profiles_select", json!({ "profile_id": "e2e-planner" }), ) .await; assert_eq!( - ok(&select_profile, "agent_profile_select") + ok(&select_profile, "profiles_select") .get("activeProfileId") .and_then(Value::as_str), Some("e2e-planner") @@ -508,11 +508,11 @@ async fn config_agent_tools_and_threads_mutation_paths_round_trip() { let delete_profile = rpc( &harness.rpc_base, 31_004, - "openhuman.agent_profile_delete", + "openhuman.profiles_delete", json!({ "profile_id": "e2e-planner" }), ) .await; - let deleted = ok(&delete_profile, "agent_profile_delete"); + let deleted = ok(&delete_profile, "profiles_delete"); assert!( deleted .get("profiles") diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index 3d91bfa90..b3896ca0c 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -59,18 +59,10 @@ use openhuman_core::openhuman::agent::multimodal::{ contains_image_markers, count_image_markers, extract_ollama_image_payload, parse_image_markers, prepare_messages_for_provider, MultimodalError, }; -use openhuman_core::openhuman::agent::personality_paths::{ - filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, - resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, - HasToolkit, PersonalityContext, -}; use openhuman_core::openhuman::agent::pformat::{ build_registry, parse_call as parse_pformat_call, render_signature, render_signature_from_tool, PFormatParamType, PFormatRegistry, PFormatToolParams, }; -use openhuman_core::openhuman::agent::profiles::{ - AgentProfile, AgentProfileStore, AgentProfilesState, DEFAULT_PROFILE_ID, -}; use openhuman_core::openhuman::agent::prompts::{ render_ambient_environment, render_subagent_system_prompt, render_tools, ConnectedIntegration, GatedIntegrationTool, LearnedContextData, NamespaceSummary, PersonalityRosterEntry, @@ -176,6 +168,17 @@ use openhuman_core::openhuman::inference::{ DeviceProfile, }; use openhuman_core::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, RecallOpts}; +use openhuman_core::openhuman::profiles::{ + all_profiles_controller_schemas, all_profiles_registered_controllers, +}; +use openhuman_core::openhuman::profiles::{ + filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, + resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, + HasToolkit, PersonalityContext, +}; +use openhuman_core::openhuman::profiles::{ + AgentProfile, AgentProfileStore, AgentProfilesState, DEFAULT_PROFILE_ID, +}; use openhuman_core::openhuman::security::SecurityPolicy; use openhuman_core::openhuman::todos::ops::BoardLocation; use openhuman_core::openhuman::tools::{Tool, ToolResult, ToolSpec}; @@ -1116,6 +1119,14 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { .iter() .all(|controller| controller.rpc_method_name().starts_with("openhuman.agent_"))); + // Profiles moved to their own top-level domain (`openhuman.profiles_*`). + let profile_schemas = all_profiles_controller_schemas(); + let profiles = all_profiles_registered_controllers(); + assert_eq!(profile_schemas.len(), profiles.len()); + assert!(profiles.iter().all(|controller| controller + .rpc_method_name() + .starts_with("openhuman.profiles_"))); + let status = call(controller(®istered, "server_status"), json!({})) .await .expect("server status"); @@ -1155,7 +1166,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { assert_eq!(reload.pointer("/status"), Some(&json!("noop"))); assert_eq!(reload.pointer("/registry_initialised"), Some(&json!(true))); - let list = call(controller(®istered, "profiles_list"), json!({})) + let list = call(controller(&profiles, "list"), json!({})) .await .expect("profiles list"); assert_eq!( @@ -1170,7 +1181,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { .any(|profile| profile.pointer("/id") == Some(&json!("research")))); let unknown_agent = call( - controller(®istered, "profile_upsert"), + controller(&profiles, "upsert"), json!({ "profile": { "id": "Bad Agent", @@ -1185,7 +1196,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { assert!(unknown_agent.contains("agent definition 'unknown-agent-id' not found")); let upserted = call( - controller(®istered, "profile_upsert"), + controller(&profiles, "upsert"), json!({ "profile": { "id": " My Research Profile ", @@ -1220,7 +1231,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { ); let selected = call( - controller(®istered, "profile_select"), + controller(&profiles, "select"), json!({ "profile_id": "my-research-profile" }), ) .await @@ -1231,7 +1242,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { ); let missing_select = call( - controller(®istered, "profile_select"), + controller(&profiles, "select"), json!({ "profile_id": "missing-profile" }), ) .await @@ -1239,7 +1250,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { assert!(missing_select.contains("agent profile 'missing-profile' not found")); let delete_builtin = call( - controller(®istered, "profile_delete"), + controller(&profiles, "delete"), json!({ "profile_id": DEFAULT_PROFILE_ID }), ) .await @@ -1247,7 +1258,7 @@ async fn agent_registry_and_profile_controllers_cover_success_and_errors() { assert!(delete_builtin.contains("built-in agent profile")); let deleted = call( - controller(®istered, "profile_delete"), + controller(&profiles, "delete"), json!({ "profile_id": "my-research-profile" }), ) .await @@ -1359,6 +1370,10 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { soul_md: Some(" inline soul ".to_string()), soul_md_path: None, composio_integrations: Some(vec![" gmail ".to_string(), String::new()]), + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: true, sort_order: Some(50), @@ -1393,6 +1408,10 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { soul_md: None, soul_md_path: None, composio_integrations: Some(vec![]), + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, @@ -1410,6 +1429,10 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { let reused = store .upsert(AgentProfile { + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, description: "updated".to_string(), ..second_profile.clone() @@ -1719,6 +1742,10 @@ fn agent_personality_paths_cover_safe_fallbacks_and_integration_filters() { soul_md: Some("inline soul".into()), soul_md_path: Some("personality-soul.md".into()), composio_integrations: Some(vec!["gmail".into(), "slack".into()]), + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: Some("-7".into()), is_master: false, sort_order: Some(10), @@ -3471,7 +3498,7 @@ async fn agent_public_tools_cover_validation_and_metadata_paths() { assert!(clarification.output().contains("Which target?")); assert!(clarification.output().contains("unit, coverage")); - let run_workflow = RunWorkflowTool; + let run_workflow = RunWorkflowTool::new(); assert_eq!(run_workflow.name(), RUN_WORKFLOW_TOOL_NAME); assert_eq!( run_workflow.parameters_schema().pointer("/required/0"), diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index 69e6740a8..599454aa0 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -18,14 +18,6 @@ use std::sync::Arc; use serde_json::json; use tempfile::tempdir; -use openhuman_core::openhuman::agent::personality_paths::{ - filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, - resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, - HasToolkit, PersonalityContext, -}; -use openhuman_core::openhuman::agent::profiles::{ - built_in_profiles, AgentProfile, AgentProfileStore, DEFAULT_PROFILE_ID, -}; use openhuman_core::openhuman::agent::prompts::types::LearnedContextData; use openhuman_core::openhuman::agent::prompts::{ IdentitySection, PersonalityRosterEntry, PersonalityRosterSection, PromptContext, @@ -36,6 +28,14 @@ use openhuman_core::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory}; use openhuman_core::openhuman::memory_conversations::{ ensure_thread, list_threads, update_thread_title, ConversationStore, CreateConversationThread, }; +use openhuman_core::openhuman::profiles::{ + built_in_profiles, AgentProfile, AgentProfileStore, DEFAULT_PROFILE_ID, +}; +use openhuman_core::openhuman::profiles::{ + filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, + resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, + HasToolkit, PersonalityContext, +}; // ───────────────────────────────────────────────────────────────────────────── // Test helpers @@ -57,6 +57,10 @@ fn make_profile(id: &str, name: &str) -> AgentProfile { soul_md: None, soul_md_path: None, composio_integrations: None, + memory_sources: None, + include_agent_conversations: true, + allowed_skills: None, + allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, diff --git a/tests/worker_b_domain_e2e.rs b/tests/worker_b_domain_e2e.rs index c93e6e490..924bf1230 100644 --- a/tests/worker_b_domain_e2e.rs +++ b/tests/worker_b_domain_e2e.rs @@ -257,10 +257,10 @@ async fn worker_b_schema_catalog_exposes_all_controller_methods() { "openhuman.agent_get_definition", "openhuman.agent_reload_definitions", "openhuman.agent_triage_evaluate", - "openhuman.agent_profiles_list", - "openhuman.agent_profile_select", - "openhuman.agent_profile_upsert", - "openhuman.agent_profile_delete", + "openhuman.profiles_list", + "openhuman.profiles_select", + "openhuman.profiles_upsert", + "openhuman.profiles_delete", "openhuman.tools_composio_execute", "openhuman.tools_web_search", "openhuman.tools_seltz_search", @@ -487,7 +487,7 @@ async fn agent_definitions_profiles_and_validation_paths_are_reachable() { "not found", ), ( - "openhuman.agent_profile_upsert", + "openhuman.profiles_upsert", json!({ "profile": { "id": "bad-worker-b-profile", @@ -501,7 +501,7 @@ async fn agent_definitions_profiles_and_validation_paths_are_reachable() { "not found", ), ( - "openhuman.agent_profile_select", + "openhuman.profiles_select", json!({ "profile_id": "missing-worker-b-profile" }), "not found", ), @@ -539,12 +539,12 @@ async fn agent_definitions_profiles_and_validation_paths_are_reachable() { let profiles = rpc( &harness.rpc_base, 20_200, - "openhuman.agent_profiles_list", + "openhuman.profiles_list", json!({}), ) .await; assert_eq!( - ok(&profiles, "agent_profiles_list") + ok(&profiles, "profiles_list") .get("activeProfileId") .and_then(Value::as_str), Some("default") diff --git a/tests/worker_b_raw_coverage_e2e.rs b/tests/worker_b_raw_coverage_e2e.rs index 0afaba629..014c8eadd 100644 --- a/tests/worker_b_raw_coverage_e2e.rs +++ b/tests/worker_b_raw_coverage_e2e.rs @@ -496,7 +496,7 @@ async fn agent_profile_lifecycle_persists_custom_profile_and_validates_delete() let upsert = rpc( &harness.rpc_base, 301, - "openhuman.agent_profile_upsert", + "openhuman.profiles_upsert", json!({ "profile": { "id": "worker-b-custom", @@ -517,7 +517,7 @@ async fn agent_profile_lifecycle_persists_custom_profile_and_validates_delete() }), ) .await; - let profiles = ok(&upsert, "agent_profile_upsert") + let profiles = ok(&upsert, "profiles_upsert") .get("profiles") .and_then(Value::as_array) .expect("profiles after upsert"); @@ -534,12 +534,12 @@ async fn agent_profile_lifecycle_persists_custom_profile_and_validates_delete() let select = rpc( &harness.rpc_base, 302, - "openhuman.agent_profile_select", + "openhuman.profiles_select", json!({ "profile_id": "worker-b-custom" }), ) .await; assert_eq!( - ok(&select, "agent_profile_select") + ok(&select, "profiles_select") .get("activeProfileId") .and_then(Value::as_str), Some("worker-b-custom") @@ -548,7 +548,7 @@ async fn agent_profile_lifecycle_persists_custom_profile_and_validates_delete() let delete_default = rpc( &harness.rpc_base, 303, - "openhuman.agent_profile_delete", + "openhuman.profiles_delete", json!({ "profile_id": "default" }), ) .await; @@ -560,12 +560,12 @@ async fn agent_profile_lifecycle_persists_custom_profile_and_validates_delete() let delete_custom = rpc( &harness.rpc_base, 304, - "openhuman.agent_profile_delete", + "openhuman.profiles_delete", json!({ "profile_id": "worker-b-custom" }), ) .await; assert_eq!( - ok(&delete_custom, "agent_profile_delete") + ok(&delete_custom, "profiles_delete") .get("activeProfileId") .and_then(Value::as_str), Some("default"),