mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(profiles): top-level agent profiles — soul, memory, skills, MCP, connectors (#3632)
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||
}));
|
||||
|
||||
const mockUpsert = vi.mocked(agentProfilesApi.upsert);
|
||||
|
||||
function profile(overrides: Partial<AgentProfile> = {}): 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(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/settings/profiles/new" element={<ProfileEditorPage />} />
|
||||
<Route path="/settings/profiles/edit/:id" element={<ProfileEditorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Allowlist>(null);
|
||||
const [composioIntegrations, setComposioIntegrations] = useState<Allowlist>(null);
|
||||
const [allowedSkills, setAllowedSkills] = useState<Allowlist>(null);
|
||||
const [allowedMcpServers, setAllowedMcpServers] = useState<Allowlist>(null);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader title={title} showBackButton onBack={backToList} breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="p-4">
|
||||
{notFound ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('settings.profiles.editor.notFound')}
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={backToList}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Identity */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="profile-name"
|
||||
label={t('settings.profiles.editor.name')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="profile-name"
|
||||
autoFocus={isCreate}
|
||||
value={name}
|
||||
onChange={e => handleName(e.target.value)}
|
||||
aria-label={t('settings.profiles.editor.name')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isCreate ? (
|
||||
<SettingsRow
|
||||
htmlFor="profile-id"
|
||||
label={t('settings.profiles.editor.id')}
|
||||
description={t('settings.profiles.editor.idHint')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="profile-id"
|
||||
mono
|
||||
value={profileId}
|
||||
onChange={e => {
|
||||
setIdTouched(true);
|
||||
setProfileId(e.target.value);
|
||||
}}
|
||||
aria-label={t('settings.profiles.editor.id')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SettingsRow
|
||||
label={t('settings.profiles.editor.id')}
|
||||
control={
|
||||
<code className="font-mono text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{profileId}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow
|
||||
htmlFor="profile-description"
|
||||
label={t('settings.profiles.editor.description')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextArea
|
||||
id="profile-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
aria-label={t('settings.profiles.editor.description')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Soul */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="profile-soul"
|
||||
label={t('settings.profiles.editor.soul')}
|
||||
description={t('settings.profiles.editor.soulHint')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextArea
|
||||
id="profile-soul"
|
||||
value={soulMd}
|
||||
onChange={e => setSoulMd(e.target.value)}
|
||||
rows={6}
|
||||
aria-label={t('settings.profiles.editor.soul')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Runtime defaults */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="profile-base-agent"
|
||||
label={t('settings.profiles.editor.baseAgent')}
|
||||
description={t('settings.profiles.editor.baseAgentHint')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="profile-base-agent"
|
||||
mono
|
||||
value={agentId}
|
||||
onChange={e => setAgentId(e.target.value)}
|
||||
aria-label={t('settings.profiles.editor.baseAgent')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="profile-model"
|
||||
label={t('settings.profiles.editor.model')}
|
||||
description={t('settings.profiles.editor.modelHint')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="profile-model"
|
||||
mono
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder={MODEL_HINTS.join(', ')}
|
||||
aria-label={t('settings.profiles.editor.model')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="profile-temperature"
|
||||
label={t('settings.profiles.editor.temperature')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="profile-temperature"
|
||||
value={temperature}
|
||||
onChange={e => setTemperature(e.target.value)}
|
||||
placeholder="0.0 – 1.0"
|
||||
aria-label={t('settings.profiles.editor.temperature')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="profile-suffix"
|
||||
label={t('settings.profiles.editor.systemPromptSuffix')}
|
||||
stacked
|
||||
control={
|
||||
<SettingsTextArea
|
||||
id="profile-suffix"
|
||||
value={systemPromptSuffix}
|
||||
onChange={e => setSystemPromptSuffix(e.target.value)}
|
||||
rows={2}
|
||||
aria-label={t('settings.profiles.editor.systemPromptSuffix')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Memory */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
label={t('settings.profiles.editor.agentConversations')}
|
||||
description={t('settings.profiles.editor.agentConversationsHint')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="profile-agent-conversations"
|
||||
checked={includeAgentConversations}
|
||||
onCheckedChange={setIncludeAgentConversations}
|
||||
aria-label={t('settings.profiles.editor.agentConversations')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AllowlistField
|
||||
label={t('settings.profiles.editor.memorySources')}
|
||||
hint={t('settings.profiles.editor.memorySourcesHint')}
|
||||
value={memorySources}
|
||||
onChange={setMemorySources}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Capabilities */}
|
||||
<SettingsSection>
|
||||
<AllowlistField
|
||||
label={t('settings.profiles.editor.connectors')}
|
||||
hint={t('settings.profiles.editor.connectorsHint')}
|
||||
value={composioIntegrations}
|
||||
onChange={setComposioIntegrations}
|
||||
/>
|
||||
<AllowlistField
|
||||
label={t('settings.profiles.editor.skills')}
|
||||
hint={t('settings.profiles.editor.skillsHint')}
|
||||
value={allowedSkills}
|
||||
onChange={setAllowedSkills}
|
||||
/>
|
||||
<AllowlistField
|
||||
label={t('settings.profiles.editor.mcpServers')}
|
||||
hint={t('settings.profiles.editor.mcpServersHint')}
|
||||
value={allowedMcpServers}
|
||||
onChange={setAllowedMcpServers}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={backToList}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!canSubmit}>
|
||||
{submitting
|
||||
? t('settings.profiles.editor.saving')
|
||||
: isCreate
|
||||
? t('common.create')
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* "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 (
|
||||
<SettingsRow
|
||||
label={label}
|
||||
description={hint}
|
||||
stacked
|
||||
control={
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex overflow-hidden rounded-md border border-neutral-200 text-xs dark:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className={`px-3 py-1 font-medium transition-colors ${
|
||||
!restricted
|
||||
? 'bg-ocean-500 text-white'
|
||||
: 'bg-white text-neutral-600 dark:bg-neutral-900 dark:text-neutral-300'
|
||||
}`}>
|
||||
{t('settings.profiles.editor.all')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value ?? [])}
|
||||
className={`px-3 py-1 font-medium transition-colors ${
|
||||
restricted
|
||||
? 'bg-ocean-500 text-white'
|
||||
: 'bg-white text-neutral-600 dark:bg-neutral-900 dark:text-neutral-300'
|
||||
}`}>
|
||||
{t('settings.profiles.editor.selected')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{restricted && (
|
||||
<div className="rounded-md border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
<div className="mb-1.5 flex flex-wrap gap-1.5">
|
||||
{items.map(item => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2.5 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.profiles.editor.removeAria').replace('{item}', item)}
|
||||
onClick={() => onChange(items.filter(x => x !== item))}
|
||||
className="rounded-full text-neutral-400 hover:text-coral-600 dark:text-neutral-500 dark:hover:text-coral-300">
|
||||
<LuX className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<SettingsTextField
|
||||
mono
|
||||
value={draft}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileEditorPage;
|
||||
@@ -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<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title, action }: { title: string; action?: React.ReactNode }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{action}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockList = vi.mocked(agentProfilesApi.list);
|
||||
const mockSelect = vi.mocked(agentProfilesApi.select);
|
||||
const mockDelete = vi.mocked(agentProfilesApi.delete);
|
||||
|
||||
function profile(overrides: Partial<AgentProfile> = {}): 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(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ProfilesPanel />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.profiles.title')}
|
||||
showBackButton
|
||||
onBack={() => navigate('/settings')}
|
||||
breadcrumbs={breadcrumbs}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/profiles/new')}>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t('settings.profiles.new')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.profiles.subtitle')}
|
||||
</p>
|
||||
|
||||
{(actionError || error) && (
|
||||
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{actionError || `${t('settings.profiles.loadError')}: ${error}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{profiles.length === 0 ? (
|
||||
status === 'loading' ? (
|
||||
<div className="flex items-center justify-center py-12 text-neutral-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState label={t('settings.profiles.empty')} />
|
||||
)
|
||||
) : (
|
||||
<SettingsSection>
|
||||
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{profiles.map(profile => {
|
||||
const isActive = profile.id === activeId;
|
||||
return (
|
||||
<li
|
||||
key={profile.id}
|
||||
className="flex items-center justify-between gap-3 py-3 first:pt-1 last:pb-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-sage-100 px-2 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
||||
{t('settings.profiles.active')}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{profile.builtIn
|
||||
? t('settings.profiles.sourceBuiltIn')
|
||||
: t('settings.profiles.sourceCustom')}
|
||||
</span>
|
||||
</div>
|
||||
{profile.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{profile.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
{!isActive && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void setActive(profile.id)}>
|
||||
{t('settings.profiles.setActive')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/settings/profiles/edit/${profile.id}`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
{!profile.builtIn && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void remove(profile.id)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesPanel;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
|
||||
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
|
||||
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
|
||||
<Route path="profiles" element={wrapSettingsPage(<ProfilesPanel />)} />
|
||||
<Route path="profiles/new" element={wrapSettingsPage(<ProfileEditorPage />)} />
|
||||
<Route path="profiles/edit/:id" element={wrapSettingsPage(<ProfileEditorPage />)} />
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
{/* Developer Options */}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,14 +19,14 @@ function unwrapEnvelope<T>(response: Envelope<T> | T): T {
|
||||
export const agentProfilesApi = {
|
||||
list: async (): Promise<AgentProfilesResponse> => {
|
||||
const response = await callCoreRpc<Envelope<AgentProfilesResponse>>({
|
||||
method: 'openhuman.agent_profiles_list',
|
||||
method: 'openhuman.profiles_list',
|
||||
});
|
||||
return unwrapEnvelope(response);
|
||||
},
|
||||
|
||||
select: async (profileId: string): Promise<AgentProfilesResponse> => {
|
||||
const response = await callCoreRpc<Envelope<AgentProfilesResponse>>({
|
||||
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<AgentProfilesResponse> => {
|
||||
const response = await callCoreRpc<Envelope<AgentProfilesResponse>>({
|
||||
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<AgentProfilesResponse> => {
|
||||
const response = await callCoreRpc<Envelope<AgentProfilesResponse>>({
|
||||
method: 'openhuman.agent_profile_delete',
|
||||
method: 'openhuman.profiles_delete',
|
||||
params: { profile_id: profileId },
|
||||
});
|
||||
return unwrapEnvelope(response);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -124,6 +124,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<ControllerSchema> {
|
||||
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());
|
||||
|
||||
@@ -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<Vec<crate::openhuman::subconscious::SourceChunk>>,
|
||||
profile_prompt_suffix: Option<String>,
|
||||
profile: Option<&crate::openhuman::profiles::AgentProfile>,
|
||||
) -> Result<Self> {
|
||||
let target_def: Option<crate::openhuman::agent::harness::definition::AgentDefinition> =
|
||||
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<Vec<crate::openhuman::subconscious::SourceChunk>>,
|
||||
profile_prompt_suffix: Option<String>,
|
||||
read_only_tools_only: bool,
|
||||
profile: Option<&crate::openhuman::profiles::AgentProfile>,
|
||||
) -> Result<Self> {
|
||||
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<dyn host_runtime::RuntimeAdapter> =
|
||||
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<std::collections::HashSet<String>> = profile
|
||||
.and_then(|p| p.allowed_skills.clone())
|
||||
.map(|v| v.into_iter().collect());
|
||||
let profile_mcp_allowlist: Option<Vec<String>> =
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<AgentChatParams>(params)?;
|
||||
@@ -439,206 +361,6 @@ fn handle_triage_evaluate(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_profiles_list(_params: Map<String, Value>) -> 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<String, Value>) -> 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::<ProfileSelectParams>(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<String, Value>) -> 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::<ProfileUpsertParams>(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<String, Value>) -> 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::<ProfileDeleteParams>(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<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
@@ -687,8 +409,6 @@ fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String>
|
||||
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<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<crate::openhuman::profiles::AgentProfile>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
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<std::collections::HashSet<String>>,
|
||||
) -> 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);
|
||||
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
/// 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::<serde_json::Value>(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::<serde_json::Value>(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![
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<f64>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
@@ -155,6 +155,7 @@ async fn get_or_build_agent_for_meet(request_id: &str) -> Result<Arc<TokioMutex<
|
||||
"orchestrator",
|
||||
None,
|
||||
Some(MEET_VOICE_DIRECTIVE.to_string()),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("[meet-agent] orchestrator build failed: {e}"))?;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod query;
|
||||
pub mod read_rpc;
|
||||
pub mod remember;
|
||||
pub mod schema;
|
||||
pub mod source_scope;
|
||||
pub mod sync;
|
||||
pub mod tools;
|
||||
pub mod util;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Ambient per-turn allowlist of memory-source scopes an agent may recall from.
|
||||
//!
|
||||
//! Agent profiles can restrict which memory sources a flavour recalls (the
|
||||
//! `AgentProfile::memory_sources` allowlist). Threading that allowlist through
|
||||
//! every memory tool and the deep `select_trees` retrieval layer would touch
|
||||
//! dozens of call sites, so — mirroring [`thread_context`] — the channel sets a
|
||||
//! [`tokio::task_local`] around the agent turn and the source-tree retrieval
|
||||
//! reads it.
|
||||
//!
|
||||
//! Semantics:
|
||||
//! - `None` scope (outside any [`with_source_scope`], or `with_source_scope(None, …)`)
|
||||
//! means **unrestricted** — every source tree is visible. This is the default
|
||||
//! for cron, sub-agents, the CLI, and any profile that left `memory_sources`
|
||||
//! unset.
|
||||
//! - `Some(set)` restricts recall to source trees whose `scope` string is in the
|
||||
//! set. An empty set surfaces nothing (the profile selected no sources).
|
||||
//!
|
||||
//! The allowlist entries are matched against tree `scope` strings — the same
|
||||
//! identifiers the `memory_tree_query_source` tool accepts as `source_id`.
|
||||
//!
|
||||
//! [`thread_context`]: crate::openhuman::inference::provider::thread_context
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use crate::openhuman::memory::source_scope::{with_source_scope, current_source_scope};
|
||||
//!
|
||||
//! with_source_scope(Some(vec!["slack:#eng".into()]), async {
|
||||
//! assert!(current_source_scope().unwrap().contains("slack:#eng"));
|
||||
//! }).await;
|
||||
//! ```
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
|
||||
tokio::task_local! {
|
||||
static SOURCE_SCOPE: Option<HashSet<String>>;
|
||||
}
|
||||
|
||||
/// 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<Vec<String>>) -> Option<HashSet<String>> {
|
||||
allowlist.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<HashSet<String>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `fut` with `allowlist` available to any descendant call to
|
||||
/// [`current_source_scope`]. `None` leaves recall unrestricted.
|
||||
pub async fn with_source_scope<F, T>(allowlist: Option<Vec<String>>, fut: F) -> T
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
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<HashSet<String>> {
|
||||
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:<id>:<item>` 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<String>, 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<String> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -637,6 +637,12 @@ pub struct ListChunksQuery {
|
||||
pub until_ms: Option<i64>,
|
||||
/// Max rows to return (default 100 when `None`).
|
||||
pub limit: Option<usize>,
|
||||
/// 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<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
/// List chunks matching the provided filters, ordered by `timestamp` DESC.
|
||||
@@ -670,19 +676,45 @@ pub fn list_chunks(config: &Config, query: &ListChunksQuery) -> Result<Vec<Chunk
|
||||
sql.push_str(" AND timestamp_ms <= ?");
|
||||
bound.push(Box::new(until_ms));
|
||||
}
|
||||
let limit = normalized_limit(query.limit);
|
||||
let requested_limit = normalized_limit(query.limit);
|
||||
// When a profile source-scope is active, fetch a wider candidate set and
|
||||
// apply the gate in Rust *before* truncating, so a disallowed-source
|
||||
// prefix can't push permitted rows past the requested limit. Otherwise
|
||||
// the SQL LIMIT alone is correct and cheap.
|
||||
let sql_limit = if query.source_scope.is_some() {
|
||||
MAX_LIST_LIMIT as i64
|
||||
} else {
|
||||
requested_limit
|
||||
};
|
||||
sql.push_str(" ORDER BY timestamp_ms DESC, seq_in_source ASC LIMIT ?");
|
||||
bound.push(Box::new(limit));
|
||||
bound.push(Box::new(sql_limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = 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::<rusqlite::Result<Vec<_>>>()
|
||||
.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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<RetrievalHit>, 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<SourceKind>,
|
||||
source_scope: Option<&std::collections::HashSet<String>>,
|
||||
) -> Result<(Vec<RetrievalHit>, 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<RetrievalHit> = Vec::new();
|
||||
@@ -209,8 +220,21 @@ fn select_trees(
|
||||
config: &Config,
|
||||
source_id: Option<&str>,
|
||||
source_kind: Option<SourceKind>,
|
||||
source_scope: Option<&std::collections::HashSet<String>>,
|
||||
) -> Result<Vec<Tree>> {
|
||||
// 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<Tree> = 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<Tree> = 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `<workspace>/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,
|
||||
};
|
||||
@@ -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<AgentProfileStore, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<std::ffi::OsString>,
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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"), "");
|
||||
}
|
||||
}
|
||||
@@ -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<ControllerSchema> {
|
||||
vec![
|
||||
schemas("list"),
|
||||
schemas("select"),
|
||||
schemas("upsert"),
|
||||
schemas("delete"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(super::ops::list())
|
||||
}
|
||||
|
||||
fn handle_profile_select(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<ProfileSelectParams>(params)?;
|
||||
super::ops::select(&p.profile_id).await
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_profile_upsert(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<ProfileUpsertParams>(params)?;
|
||||
super::ops::upsert(p.profile).await
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_profile_delete(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<ProfileDeleteParams>(params)?;
|
||||
super::ops::delete(&p.profile_id).await
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
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<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! `<workspace>/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 `<workspace>/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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt_suffix: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub built_in: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice_id: Option<String>,
|
||||
/// Inline SOUL.md content for this personality. Falls back to workspace root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub soul_md: Option<String>,
|
||||
/// 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<String>,
|
||||
/// Composio toolkit slugs this personality can access. None = all integrations.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub composio_integrations: Option<Vec<String>>,
|
||||
/// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_dir_suffix: Option<String>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentProfilesState {
|
||||
pub active_profile_id: String,
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentProfileStore {
|
||||
@@ -76,7 +25,7 @@ impl AgentProfileStore {
|
||||
|
||||
pub fn load(&self) -> Result<AgentProfilesState, String> {
|
||||
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<AgentProfilesState, String> {
|
||||
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<AgentProfilesState, String> {
|
||||
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<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,
|
||||
@@ -400,6 +351,10 @@ pub fn built_in_profiles() -> Vec<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,
|
||||
@@ -422,6 +377,10 @@ pub fn built_in_profiles() -> Vec<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,
|
||||
@@ -444,6 +403,10 @@ pub fn built_in_profiles() -> Vec<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,
|
||||
@@ -451,34 +414,7 @@ pub fn built_in_profiles() -> Vec<AgentProfile> {
|
||||
]
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String, AgentProfile> = 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<Vec<String>>) -> Option<Vec<String>> {
|
||||
let cleaned = list.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt_suffix: Option<String>,
|
||||
/// Tool `name()` allowlist this profile can see. None = all tools.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub built_in: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice_id: Option<String>,
|
||||
/// Inline SOUL.md content for this personality. Falls back to workspace root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub soul_md: Option<String>,
|
||||
/// 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<String>,
|
||||
/// Composio toolkit slugs this personality can access. None = all integrations.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub composio_integrations: Option<Vec<String>>,
|
||||
/// Memory-source entry ids this profile recalls from. None = all sources.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_sources: Option<Vec<String>>,
|
||||
/// 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<Vec<String>>,
|
||||
/// MCP server names this profile can reach. None = all configured servers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_mcp_servers: Option<Vec<String>>,
|
||||
/// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_dir_suffix: Option<String>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
/// 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<AgentProfile>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@ fn build_runtime_tools(config: &Config) -> Result<Vec<Box<dyn Tool>>, String> {
|
||||
&config.action_dir,
|
||||
&config.agents,
|
||||
config,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
debug!(
|
||||
tool_count = built.len(),
|
||||
|
||||
@@ -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<Config>,
|
||||
@@ -92,6 +98,8 @@ pub fn all_tools_with_runtime(
|
||||
action_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
root_config: &crate::openhuman::config::Config,
|
||||
skill_allowlist: Option<&std::collections::HashSet<String>>,
|
||||
mcp_allowlist: Option<&[String]>,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
// 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))));
|
||||
|
||||
@@ -49,17 +49,38 @@ fn read_workflow_id(args: &serde_json::Value) -> anyhow::Result<String> {
|
||||
.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<std::collections::HashSet<String>>;
|
||||
|
||||
/// 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<Config>) -> 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<Config>) -> 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<ToolResult> {
|
||||
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<Config>) -> 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<ToolResult> {
|
||||
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<String> =
|
||||
["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<String> =
|
||||
["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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user