mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
+4
-1
@@ -91,7 +91,10 @@ OPENHUMAN_TEMPERATURE=0.7
|
||||
# entity-extraction reasons, and learning reflections. Accepts UI locale tags
|
||||
# such as zh-CN or a language name. Leave unset for default behavior.
|
||||
# OPENHUMAN_OUTPUT_LANGUAGE=zh-CN
|
||||
# [optional] Skill + agent tool execution timeout in seconds (default 120, max 3600)
|
||||
# [optional] Operator override for the tool/agent action timeout, in seconds
|
||||
# (default 120, range 1-3600). When set to a valid value it overrides the
|
||||
# in-app "Action timeout" setting (Settings -> Agent OS access), which persists
|
||||
# to [agent].agent_timeout_secs. Leave unset to let the UI control it.
|
||||
# OPENHUMAN_TOOL_TIMEOUT_SECS=
|
||||
# [optional] Headless update restart contract: self_replace | supervisor
|
||||
# OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=self_replace
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type AutonomyLevel,
|
||||
isTauri,
|
||||
openhumanGetAgentSettings,
|
||||
openhumanGetAutonomySettings,
|
||||
openhumanUpdateAgentSettings,
|
||||
openhumanUpdateAutonomySettings,
|
||||
type TrustedAccess,
|
||||
type TrustedRoot,
|
||||
@@ -59,6 +61,18 @@ const AgentAccessPanel = () => {
|
||||
const [newRootPath, setNewRootPath] = useState('');
|
||||
const [newRootAccess, setNewRootAccess] = useState<TrustedAccess>('read');
|
||||
|
||||
// Action timeout (the tool/action wall-clock limit, issue #3100). Held as the
|
||||
// raw input string so the field can be edited freely; validated on save.
|
||||
const [timeoutInput, setTimeoutInput] = useState('');
|
||||
const [timeoutEnvOverride, setTimeoutEnvOverride] = useState(false);
|
||||
const [timeoutMin, setTimeoutMin] = useState(1);
|
||||
const [timeoutMax, setTimeoutMax] = useState(3600);
|
||||
// Last persisted value, kept so blur/Enter can no-op when nothing changed.
|
||||
const [savedTimeoutSecs, setSavedTimeoutSecs] = useState<number | null>(null);
|
||||
const [timeoutError, setTimeoutError] = useState<string | null>(null);
|
||||
const [timeoutSavedNote, setTimeoutSavedNote] = useState<string | null>(null);
|
||||
const timeoutSeqRef = useRef(0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -75,19 +89,31 @@ const AgentAccessPanel = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await openhumanGetAutonomySettings();
|
||||
const autonomyResp = await openhumanGetAutonomySettings();
|
||||
if (cancelled) return;
|
||||
setLevel(resp.result.level);
|
||||
setWorkspaceOnly(resp.result.workspace_only);
|
||||
setRequireTaskPlanApproval(resp.result.require_task_plan_approval ?? true);
|
||||
setTrustedRoots(resp.result.trusted_roots ?? []);
|
||||
setAutoApprove(resp.result.auto_approve ?? []);
|
||||
setLevel(autonomyResp.result.level);
|
||||
setWorkspaceOnly(autonomyResp.result.workspace_only);
|
||||
setRequireTaskPlanApproval(autonomyResp.result.require_task_plan_approval ?? true);
|
||||
setTrustedRoots(autonomyResp.result.trusted_roots ?? []);
|
||||
setAutoApprove(autonomyResp.result.auto_approve ?? []);
|
||||
} catch (e) {
|
||||
if (!cancelled)
|
||||
setError(e instanceof Error ? e.message : t('settings.agentAccess.loadError'));
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
try {
|
||||
const agentResp = await openhumanGetAgentSettings();
|
||||
if (cancelled) return;
|
||||
setTimeoutInput(String(agentResp.result.agent_timeout_secs));
|
||||
setSavedTimeoutSecs(agentResp.result.agent_timeout_secs);
|
||||
setTimeoutEnvOverride(agentResp.result.env_override);
|
||||
setTimeoutMin(agentResp.result.min_timeout_secs);
|
||||
setTimeoutMax(agentResp.result.max_timeout_secs);
|
||||
} catch {
|
||||
// Non-fatal: autonomy controls still render; timeout section
|
||||
// stays at defaults and the user can try saving manually.
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
@@ -186,6 +212,46 @@ const AgentAccessPanel = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Persist the action timeout on blur / Enter. Validates the integer range
|
||||
// client-side (the core re-validates) and no-ops when unchanged. Separate
|
||||
// from the autonomy `persist` path so a timeout edit can't clobber the
|
||||
// autonomy block and vice-versa.
|
||||
const commitTimeout = async () => {
|
||||
if (!isTauri()) return;
|
||||
const trimmed = timeoutInput.trim();
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed) || parsed < timeoutMin || parsed > timeoutMax) {
|
||||
setTimeoutError(`${t('settings.agentAccess.timeout.invalid')} (${timeoutMin}–${timeoutMax})`);
|
||||
setTimeoutSavedNote(null);
|
||||
return;
|
||||
}
|
||||
if (savedTimeoutSecs !== null && parsed === savedTimeoutSecs) {
|
||||
// Normalize the field (e.g. strip whitespace / leading zeros) but skip the RPC.
|
||||
setTimeoutInput(String(parsed));
|
||||
setTimeoutError(null);
|
||||
return;
|
||||
}
|
||||
const seq = ++timeoutSeqRef.current;
|
||||
const draftAtCommit = timeoutInput;
|
||||
setTimeoutError(null);
|
||||
setTimeoutSavedNote(null);
|
||||
try {
|
||||
await openhumanUpdateAgentSettings({ agent_timeout_secs: parsed });
|
||||
if (timeoutSeqRef.current === seq) {
|
||||
setSavedTimeoutSecs(parsed);
|
||||
// Only snap the field value back if the user hasn't typed further.
|
||||
if (timeoutInput === draftAtCommit) {
|
||||
setTimeoutInput(String(parsed));
|
||||
}
|
||||
setTimeoutSavedNote(t('settings.agentAccess.saved'));
|
||||
}
|
||||
} catch (e) {
|
||||
if (timeoutSeqRef.current === seq) {
|
||||
setTimeoutError(e instanceof Error ? e.message : t('settings.agentAccess.saveError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
@@ -292,6 +358,54 @@ const AgentAccessPanel = () => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Action timeout — wall-clock limit for a single tool/action.
|
||||
Extend it when large local models get cut off mid-response
|
||||
(issue #3100). Persists independently of the autonomy block. */}
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.timeout.label')}
|
||||
</h2>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.timeout.desc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={timeoutMin}
|
||||
max={timeoutMax}
|
||||
step={1}
|
||||
value={timeoutInput}
|
||||
disabled={timeoutEnvOverride}
|
||||
onChange={e => setTimeoutInput(e.target.value)}
|
||||
onBlur={() => void commitTimeout()}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void commitTimeout();
|
||||
}
|
||||
}}
|
||||
aria-label={t('settings.agentAccess.timeout.label')}
|
||||
className="w-28 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-sm disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.timeout.unit')} ({timeoutMin}–{timeoutMax})
|
||||
</span>
|
||||
</div>
|
||||
{timeoutEnvOverride && (
|
||||
<p className="rounded border border-amber/40 bg-amber/5 dark:bg-amber/10 p-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('settings.agentAccess.timeout.envOverride')}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-h-[1.25rem] text-xs" aria-live="polite">
|
||||
{timeoutError ? (
|
||||
<span className="text-coral-600 dark:text-coral-300">{timeoutError}</span>
|
||||
) : timeoutSavedNote ? (
|
||||
<span className="text-sage-700 dark:text-sage-300">✓ {timeoutSavedNote}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Granted folders (trusted roots) — extra read/write reach. */}
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
|
||||
@@ -3,9 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import {
|
||||
type AgentSettings,
|
||||
type AutonomySettings,
|
||||
isTauri,
|
||||
openhumanGetAgentSettings,
|
||||
openhumanGetAutonomySettings,
|
||||
openhumanUpdateAgentSettings,
|
||||
openhumanUpdateAutonomySettings,
|
||||
} from '../../../../utils/tauriCommands';
|
||||
import AgentAccessPanel from '../AgentAccessPanel';
|
||||
@@ -22,6 +25,15 @@ const autonomy = (overrides: Partial<AutonomySettings> = {}): AutonomySettings =
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const agentSettings = (overrides: Partial<AgentSettings> = {}): AgentSettings => ({
|
||||
agent_timeout_secs: 120,
|
||||
effective_timeout_secs: 120,
|
||||
env_override: false,
|
||||
min_timeout_secs: 1,
|
||||
max_timeout_secs: 3600,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
@@ -39,11 +51,15 @@ vi.mock('../../../../utils/tauriCommands', async () => {
|
||||
isTauri: vi.fn(() => true),
|
||||
openhumanGetAutonomySettings: vi.fn(),
|
||||
openhumanUpdateAutonomySettings: vi.fn(),
|
||||
openhumanGetAgentSettings: vi.fn(),
|
||||
openhumanUpdateAgentSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGet = vi.mocked(openhumanGetAutonomySettings);
|
||||
const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings);
|
||||
const mockGetAgent = vi.mocked(openhumanGetAgentSettings);
|
||||
const mockUpdateAgent = vi.mocked(openhumanUpdateAgentSettings);
|
||||
|
||||
describe('AgentAccessPanel', () => {
|
||||
beforeEach(() => {
|
||||
@@ -51,6 +67,8 @@ describe('AgentAccessPanel', () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
mockGet.mockResolvedValue({ result: autonomy(), logs: [] });
|
||||
mockUpdate.mockResolvedValue({ result: {} as never, logs: [] });
|
||||
mockGetAgent.mockResolvedValue({ result: agentSettings(), logs: [] });
|
||||
mockUpdateAgent.mockResolvedValue({ result: {} as never, logs: [] });
|
||||
});
|
||||
|
||||
it('loads settings on mount and renders the three access tiers', async () => {
|
||||
@@ -163,5 +181,49 @@ describe('AgentAccessPanel', () => {
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
expect(await screen.findByText('Access mode')).toBeInTheDocument();
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
expect(mockGetAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the configured action timeout into the input', async () => {
|
||||
mockGetAgent.mockResolvedValue({
|
||||
result: agentSettings({ agent_timeout_secs: 300 }),
|
||||
logs: [],
|
||||
});
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
const input = (await screen.findByLabelText('Action timeout')) as HTMLInputElement;
|
||||
expect(input.value).toBe('300');
|
||||
});
|
||||
|
||||
it('persists a changed action timeout on blur', async () => {
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
const input = await screen.findByLabelText('Action timeout');
|
||||
fireEvent.change(input, { target: { value: '300' } });
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(mockUpdateAgent).toHaveBeenCalledWith({ agent_timeout_secs: 300 }));
|
||||
});
|
||||
|
||||
it('rejects an out-of-range timeout without calling the RPC', async () => {
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
const input = await screen.findByLabelText('Action timeout');
|
||||
fireEvent.change(input, { target: { value: '99999' } });
|
||||
fireEvent.blur(input);
|
||||
expect(await screen.findByText(/within the allowed range/i)).toBeInTheDocument();
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-persist when the timeout is unchanged', async () => {
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
const input = await screen.findByLabelText('Action timeout');
|
||||
fireEvent.blur(input); // value still the loaded 120
|
||||
await waitFor(() => expect(mockGetAgent).toHaveBeenCalled());
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the timeout input and warns when an env override is active', async () => {
|
||||
mockGetAgent.mockResolvedValue({ result: agentSettings({ env_override: true }), logs: [] });
|
||||
renderWithProviders(<AgentAccessPanel />);
|
||||
const input = (await screen.findByLabelText('Action timeout')) as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(screen.getByText(/OPENHUMAN_TOOL_TIMEOUT_SECS/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3533,6 +3533,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'الموافقة على خطة العمل المطلوبة',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'وقف أمام عميل معين يقوم بتنفيذ موجز عمل مشرف على عميل',
|
||||
'settings.agentAccess.timeout.label': 'مهلة الإجراء',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'المدة التي يُسمح خلالها بتشغيل أداة أو إجراء واحد قبل إلغائه. زِد هذه القيمة إذا كان نموذج محلي كبير يُقاطَع قبل أن ينهي رده.',
|
||||
'settings.agentAccess.timeout.unit': 'ثوانٍ',
|
||||
'settings.agentAccess.timeout.invalid': 'أدخل عدداً صحيحاً من الثواني ضمن النطاق المسموح به',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'متغير البيئة OPENHUMAN_TOOL_TIMEOUT_SECS يتجاوز هذا الإعداد، لذا لن يكون للتغييرات هنا أي تأثير حتى يتم إلغاء ضبطه.',
|
||||
'settings.agentAccess.grantedFolders': 'الملفات الممنوحة',
|
||||
'settings.agentAccess.alwaysAllow': 'الأدوات المتدنية دائما',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3597,6 +3597,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'কাজের পরিকল্পনা অনুমোদন প্রয়োজন',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'নির্ধারিত কর্মের পূর্বে একটি author-ed কর্মের সঞ্চালনার পূর্বে কর্ম স্থগিত করা হবে।',
|
||||
'settings.agentAccess.timeout.label': 'অ্যাকশন টাইমআউট',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'একটি একক টুল বা অ্যাকশন বাতিল হওয়ার আগে কতক্ষণ চলতে পারে। বড় লোকাল মডেল উত্তর শেষ করার আগেই থেমে গেলে এটি বাড়ান।',
|
||||
'settings.agentAccess.timeout.unit': 'সেকেন্ড',
|
||||
'settings.agentAccess.timeout.invalid': 'অনুমোদিত পরিসরের মধ্যে সেকেন্ডের একটি পূর্ণসংখ্যা লিখুন',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'OPENHUMAN_TOOL_TIMEOUT_SECS এনভায়রনমেন্ট ভেরিয়েবলটি এই সেটিং ওভাররাইড করছে, তাই এটি আনসেট না করা পর্যন্ত এখানে পরিবর্তনের কোনো প্রভাব পড়বে না।',
|
||||
'settings.agentAccess.grantedFolders': 'ফোল্ডার',
|
||||
'settings.agentAccess.alwaysAllow': 'সর্বদা অপসারণযোগ্য সরঞ্জাম',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3691,6 +3691,14 @@ const messages: TranslationMap = {
|
||||
'Erfordern Sie die Genehmigung des Aufgabenplans',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Pausieren Sie, bevor ein zugewiesener Agent ein vom Agenten verfasstes Aufgaben-Briefing ausführt.',
|
||||
'settings.agentAccess.timeout.label': 'Aktions-Timeout',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Wie lange ein einzelnes Werkzeug oder eine Aktion laufen darf, bevor sie abgebrochen wird. Erhöhen Sie diesen Wert, wenn ein großes lokales Modell unterbrochen wird, bevor es seine Antwort beendet.',
|
||||
'settings.agentAccess.timeout.unit': 'Sekunden',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Geben Sie eine ganze Zahl von Sekunden innerhalb des zulässigen Bereichs ein',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'Die Umgebungsvariable OPENHUMAN_TOOL_TIMEOUT_SECS überschreibt diese Einstellung, daher haben Änderungen hier keine Wirkung, bis sie entfernt wird.',
|
||||
'settings.agentAccess.grantedFolders': 'Erteilte Ordner',
|
||||
'settings.agentAccess.alwaysAllow': 'Immer erlaubte Werkzeuge',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3859,6 +3859,14 @@ const en: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Pause before an assigned agent executes an agent-authored task brief.',
|
||||
'settings.agentAccess.timeout.label': 'Action timeout',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'How long a single tool or action may run before it is cancelled. Increase this if a large local model is interrupted before it finishes responding.',
|
||||
'settings.agentAccess.timeout.unit': 'seconds',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Enter a whole number of seconds within the allowed range',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'The OPENHUMAN_TOOL_TIMEOUT_SECS environment variable is overriding this setting, so changes here have no effect until it is unset.',
|
||||
'settings.agentAccess.grantedFolders': 'Granted folders',
|
||||
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3663,6 +3663,14 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Requerir la aprobación del plan de tareas',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Pausa antes de que un agente asignado ejecute un breve tarea elaborada por el agente.',
|
||||
'settings.agentAccess.timeout.label': 'Tiempo de espera de la acción',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Cuánto tiempo puede ejecutarse una sola herramienta o acción antes de cancelarse. Aumenta este valor si un modelo local grande se interrumpe antes de terminar su respuesta.',
|
||||
'settings.agentAccess.timeout.unit': 'segundos',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Introduce un número entero de segundos dentro del rango permitido',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'La variable de entorno OPENHUMAN_TOOL_TIMEOUT_SECS está anulando este ajuste, por lo que los cambios aquí no tendrán efecto hasta que se elimine.',
|
||||
'settings.agentAccess.grantedFolders': 'Carpetas concedidas',
|
||||
'settings.agentAccess.alwaysAllow': 'Herramientas siempre permitidas',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3676,6 +3676,14 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': "Exiger l'approbation du plan de tâche",
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
"Pause avant qu'un agent assigné n'exécute un briefing de tâche rédigé par un agent.",
|
||||
'settings.agentAccess.timeout.label': "Délai d'expiration de l'action",
|
||||
'settings.agentAccess.timeout.desc':
|
||||
"Durée pendant laquelle un seul outil ou une seule action peut s'exécuter avant d'être annulé. Augmentez cette valeur si un grand modèle local est interrompu avant d'avoir terminé sa réponse.",
|
||||
'settings.agentAccess.timeout.unit': 'secondes',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Saisissez un nombre entier de secondes dans la plage autorisée',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
"La variable d'environnement OPENHUMAN_TOOL_TIMEOUT_SECS remplace ce paramètre ; les modifications effectuées ici n'auront donc aucun effet tant qu'elle n'est pas supprimée.",
|
||||
'settings.agentAccess.grantedFolders': 'Dossiers accordés',
|
||||
'settings.agentAccess.alwaysAllow': 'Outils toujours autorisés',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3604,6 +3604,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'कार्य योजना अनुमोदन की आवश्यकता',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'एक निर्धारित एजेंट से पहले रोकें एक एजेंट-लेखित कार्य संक्षिप्त निष्पादित करता है।',
|
||||
'settings.agentAccess.timeout.label': 'क्रिया टाइमआउट',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'किसी एकल टूल या क्रिया को रद्द होने से पहले कितनी देर चलने दिया जाए। यदि कोई बड़ा लोकल मॉडल अपना उत्तर पूरा करने से पहले रुक जाता है तो इसे बढ़ाएँ।',
|
||||
'settings.agentAccess.timeout.unit': 'सेकंड',
|
||||
'settings.agentAccess.timeout.invalid': 'अनुमत सीमा के भीतर सेकंड की एक पूर्ण संख्या दर्ज करें',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'OPENHUMAN_TOOL_TIMEOUT_SECS एनवायरनमेंट वेरिएबल इस सेटिंग को ओवरराइड कर रहा है, इसलिए जब तक इसे अनसेट नहीं किया जाता, यहाँ किए गए बदलावों का कोई असर नहीं होगा।',
|
||||
'settings.agentAccess.grantedFolders': 'स्वीकृत फ़ोल्डर',
|
||||
'settings.agentAccess.alwaysAllow': 'हमेशा की अनुमति उपकरण',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3612,6 +3612,14 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Perlu persetujuan rencana tugas',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Jeda sebelum agen yang ditugaskan mengeksekusi suatu tugas singkat.',
|
||||
'settings.agentAccess.timeout.label': 'Batas waktu tindakan',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Berapa lama satu alat atau tindakan boleh berjalan sebelum dibatalkan. Tingkatkan nilai ini jika model lokal besar terhenti sebelum selesai merespons.',
|
||||
'settings.agentAccess.timeout.unit': 'detik',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Masukkan bilangan bulat detik dalam rentang yang diizinkan',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'Variabel lingkungan OPENHUMAN_TOOL_TIMEOUT_SECS menggantikan pengaturan ini, sehingga perubahan di sini tidak berpengaruh hingga variabel tersebut dihapus.',
|
||||
'settings.agentAccess.grantedFolders': 'Folder yang diberikan',
|
||||
'settings.agentAccess.alwaysAllow': 'Selalu-diperbolehkan alat',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3656,6 +3656,14 @@ const messages: TranslationMap = {
|
||||
"Richiedere l'approvazione del piano di lavoro",
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
"Pausa prima che un agente assegnato esegua un brief del compito scritto dall'agente.",
|
||||
'settings.agentAccess.timeout.label': "Timeout dell'azione",
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Per quanto tempo un singolo strumento o azione può essere eseguito prima di essere annullato. Aumenta questo valore se un modello locale di grandi dimensioni viene interrotto prima di completare la risposta.',
|
||||
'settings.agentAccess.timeout.unit': 'secondi',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
"Inserisci un numero intero di secondi entro l'intervallo consentito",
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
"La variabile d'ambiente OPENHUMAN_TOOL_TIMEOUT_SECS sta sovrascrivendo questa impostazione, quindi le modifiche qui non avranno effetto finché non viene rimossa.",
|
||||
'settings.agentAccess.grantedFolders': 'Cartelle concesse',
|
||||
'settings.agentAccess.alwaysAllow': 'Strumenti sempre consentiti',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3566,6 +3566,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': '작업 계획 승인 필요',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'할당된 에이전트가 에이전트가 작성한 작업 브리프를 실행하기 전에 일시 중지합니다.',
|
||||
'settings.agentAccess.timeout.label': '작업 제한 시간',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'단일 도구나 작업이 취소되기 전까지 실행될 수 있는 시간입니다. 대형 로컬 모델이 응답을 끝내기 전에 중단되는 경우 이 값을 늘리세요.',
|
||||
'settings.agentAccess.timeout.unit': '초',
|
||||
'settings.agentAccess.timeout.invalid': '허용된 범위 내에서 정수 초를 입력하세요',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'OPENHUMAN_TOOL_TIMEOUT_SECS 환경 변수가 이 설정을 재정의하고 있으므로, 해당 변수를 해제하기 전까지 여기서의 변경 사항은 적용되지 않습니다.',
|
||||
'settings.agentAccess.grantedFolders': '허용된 폴더',
|
||||
'settings.agentAccess.alwaysAllow': '항상 허용된 도구',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3660,6 +3660,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Wymagaj zatwierdzenia planu zadania',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Wstrzymaj, zanim przypisany agent wykona opis zadania utworzony przez agenta.',
|
||||
'settings.agentAccess.timeout.label': 'Limit czasu akcji',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Jak długo pojedyncze narzędzie lub akcja może działać przed anulowaniem. Zwiększ tę wartość, jeśli duży lokalny model jest przerywany, zanim zakończy odpowiedź.',
|
||||
'settings.agentAccess.timeout.unit': 'sekundy',
|
||||
'settings.agentAccess.timeout.invalid': 'Wprowadź całkowitą liczbę sekund w dozwolonym zakresie',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'Zmienna środowiskowa OPENHUMAN_TOOL_TIMEOUT_SECS zastępuje to ustawienie, więc zmiany tutaj nie odniosą skutku, dopóki nie zostanie ona usunięta.',
|
||||
'settings.agentAccess.grantedFolders': 'Przyznane foldery',
|
||||
'settings.agentAccess.alwaysAllow': 'Zawsze dozwolone narzędzia',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3657,6 +3657,14 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Exigir aprovação do plano de tarefas',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Pausa antes que um agente designado execute um briefing de tarefa elaborado pelo agente.',
|
||||
'settings.agentAccess.timeout.label': 'Tempo limite da ação',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Por quanto tempo uma única ferramenta ou ação pode ser executada antes de ser cancelada. Aumente este valor se um modelo local grande for interrompido antes de terminar a resposta.',
|
||||
'settings.agentAccess.timeout.unit': 'segundos',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Insira um número inteiro de segundos dentro do intervalo permitido',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'A variável de ambiente OPENHUMAN_TOOL_TIMEOUT_SECS está substituindo esta configuração, portanto as alterações aqui não terão efeito até que ela seja removida.',
|
||||
'settings.agentAccess.grantedFolders': 'Pastas concedidas',
|
||||
'settings.agentAccess.alwaysAllow': 'Ferramentas sempre permitidas',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3626,6 +3626,14 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': 'Требовать утверждения плана задач',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'Сделайте паузу перед тем, как назначенный агент выполнит задание, созданное агентом.',
|
||||
'settings.agentAccess.timeout.label': 'Тайм-аут действия',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'Сколько времени может выполняться отдельный инструмент или действие до отмены. Увеличьте это значение, если крупная локальная модель прерывается до завершения ответа.',
|
||||
'settings.agentAccess.timeout.unit': 'секунды',
|
||||
'settings.agentAccess.timeout.invalid':
|
||||
'Введите целое число секунд в пределах допустимого диапазона',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'Переменная окружения OPENHUMAN_TOOL_TIMEOUT_SECS переопределяет эту настройку, поэтому изменения здесь не вступят в силу, пока она не будет сброшена.',
|
||||
'settings.agentAccess.grantedFolders': 'Предоставленные папки',
|
||||
'settings.agentAccess.alwaysAllow': 'Всегда разрешенные инструменты',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -3427,6 +3427,13 @@ const messages: TranslationMap = {
|
||||
'settings.agentAccess.requireTaskPlanApproval.label': '要求批准任务计划',
|
||||
'settings.agentAccess.requireTaskPlanApproval.desc':
|
||||
'在指定智能体执行由智能体编写的任务简报前暂停。',
|
||||
'settings.agentAccess.timeout.label': '操作超时',
|
||||
'settings.agentAccess.timeout.desc':
|
||||
'单个工具或操作在被取消前可运行的时长。如果大型本地模型在完成响应前被中断,请增大此值。',
|
||||
'settings.agentAccess.timeout.unit': '秒',
|
||||
'settings.agentAccess.timeout.invalid': '请输入允许范围内的整数秒数',
|
||||
'settings.agentAccess.timeout.envOverride':
|
||||
'环境变量 OPENHUMAN_TOOL_TIMEOUT_SECS 正在覆盖此设置,因此在取消该变量之前,此处的更改不会生效。',
|
||||
'settings.agentAccess.grantedFolders': '已授权文件夹',
|
||||
'settings.agentAccess.alwaysAllow': '始终允许的工具',
|
||||
'settings.agentAccess.alwaysAllowDesc':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const CORE_RPC_METHODS = {
|
||||
configGet: 'openhuman.config_get',
|
||||
configGetAgentSettings: 'openhuman.config_get_agent_settings',
|
||||
configGetAnalyticsSettings: 'openhuman.config_get_analytics_settings',
|
||||
configGetAutonomySettings: 'openhuman.config_get_autonomy_settings',
|
||||
configGetComposioTriggerSettings: 'openhuman.config_get_composio_trigger_settings',
|
||||
@@ -8,6 +9,7 @@ export const CORE_RPC_METHODS = {
|
||||
configGetSearchSettings: 'openhuman.config_get_search_settings',
|
||||
configUpdateSearchSettings: 'openhuman.config_update_search_settings',
|
||||
configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all',
|
||||
configUpdateAgentSettings: 'openhuman.config_update_agent_settings',
|
||||
configUpdateAnalyticsSettings: 'openhuman.config_update_analytics_settings',
|
||||
configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings',
|
||||
configUpdateBrowserSettings: 'openhuman.config_update_browser_settings',
|
||||
|
||||
@@ -357,6 +357,48 @@ export async function openhumanUpdateAutonomySettings(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent execution settings (action/tool timeout) ──────────────────────────
|
||||
|
||||
/** Agent execution settings as returned by config_get_agent_settings. */
|
||||
export interface AgentSettings {
|
||||
/** Configured wall-clock timeout for a single tool/action, in seconds. */
|
||||
agent_timeout_secs: number;
|
||||
/** Runtime-effective timeout (may differ from configured when env-overridden). */
|
||||
effective_timeout_secs: number;
|
||||
/** True when OPENHUMAN_TOOL_TIMEOUT_SECS overrides the configured value. */
|
||||
env_override: boolean;
|
||||
/** Lowest accepted timeout (seconds). */
|
||||
min_timeout_secs: number;
|
||||
/** Highest accepted timeout (seconds). */
|
||||
max_timeout_secs: number;
|
||||
}
|
||||
|
||||
/** Partial update — omitted fields are left unchanged. */
|
||||
export interface AgentSettingsUpdate {
|
||||
agent_timeout_secs?: number;
|
||||
}
|
||||
|
||||
export async function openhumanGetAgentSettings(): Promise<CommandResponse<AgentSettings>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<AgentSettings>>({
|
||||
method: CORE_RPC_METHODS.configGetAgentSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanUpdateAgentSettings(
|
||||
update: AgentSettingsUpdate
|
||||
): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
|
||||
method: CORE_RPC_METHODS.configUpdateAgentSettings,
|
||||
params: update,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanUpdateLocalAiSettings(
|
||||
update: LocalAiSettingsUpdate
|
||||
): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
|
||||
@@ -471,6 +471,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` (this PR) | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `<meeting_transcript source="untrusted_external_audio">` delimiters (#1920) |
|
||||
| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip |
|
||||
| 13.1.5 | Approval History | VU | `app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx`, `app/src/services/api/approvalApi.test.ts` (this PR) | ✅ | Was ❌ — read-only audit surface over `approval_list_recent_decisions`; covers loaded/empty/error/refresh states, per-decision badge, and the bare-array vs `{result,logs}` envelope normalization |
|
||||
| 13.1.6 | Action Timeout | VU, RU, RI | `app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx`, `src/openhuman/tool_timeout/mod.rs`, `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs` (this PR) | ✅ | New (#3100) — UI control over `config_get/update_agent_settings` for `[agent].agent_timeout_secs`; covers load/persist-on-blur/range-rejection/no-op/env-override-disable, the runtime-mutable `tool_timeout` resolver + env precedence, ops apply/reject, and the RPC roundtrip |
|
||||
|
||||
### 13.2 Automation & Channels
|
||||
|
||||
|
||||
@@ -1436,6 +1436,19 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Stable,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "agent.action_timeout",
|
||||
name: "Action Timeout",
|
||||
domain: "agent",
|
||||
category: CapabilityCategory::Settings,
|
||||
description: "Set how long a single tool or action may run before it is cancelled \
|
||||
(1–3600 seconds, default 120). Increase it when a large local model is \
|
||||
interrupted before finishing its response. Applies to the next tool call \
|
||||
without a restart; the OPENHUMAN_TOOL_TIMEOUT_SECS env var still overrides it.",
|
||||
how_to: "Settings → Agent OS access → Action timeout",
|
||||
status: CapabilityStatus::Stable,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "security.always_allow_tool",
|
||||
name: "Always Allow a Tool",
|
||||
|
||||
@@ -227,6 +227,17 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
)),
|
||||
config.workspace_dir.clone(),
|
||||
);
|
||||
// Seed the live tool-execution timeout from the persisted `[agent]` config so
|
||||
// a user-configured value (Settings → Agent OS access → Action timeout) is in
|
||||
// effect from the first tool call. `OPENHUMAN_TOOL_TIMEOUT_SECS`, when set,
|
||||
// still overrides this inside `set_tool_timeout_secs`.
|
||||
let effective_timeout =
|
||||
crate::openhuman::tool_timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs);
|
||||
tracing::debug!(
|
||||
configured = config.agent.agent_timeout_secs,
|
||||
effective = effective_timeout,
|
||||
"[startup] seeded tool-execution timeout from config"
|
||||
);
|
||||
// Phase 1 of #1401: audit logger is wired with defaults so emission paths
|
||||
// are exercised at runtime. A follow-up promotes `SecurityConfig` (and
|
||||
// therefore the `audit` knob) onto the runtime `Config` schema so users
|
||||
|
||||
@@ -380,6 +380,17 @@ pub struct AutonomySettingsPatch {
|
||||
pub require_task_plan_approval: Option<bool>,
|
||||
}
|
||||
|
||||
/// Partial update for the `[agent]` block. Currently carries the single
|
||||
/// user-facing `agent_timeout_secs` knob (the tool/action wall-clock timeout);
|
||||
/// other `AgentConfig` fields are not yet UI-exposed. `None` leaves the value
|
||||
/// unchanged.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AgentSettingsPatch {
|
||||
/// Tool/action wall-clock timeout in seconds. Validated to
|
||||
/// `tool_timeout::MIN_TIMEOUT_SECS..=tool_timeout::MAX_TIMEOUT_SECS`.
|
||||
pub agent_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BrowserSettingsPatch {
|
||||
pub enabled: Option<bool>,
|
||||
@@ -949,6 +960,82 @@ pub async fn get_autonomy_settings() -> Result<RpcOutcome<serde_json::Value>, St
|
||||
Ok(RpcOutcome::single_log(value, "autonomy settings read"))
|
||||
}
|
||||
|
||||
/// Updates the `[agent]` block (currently the `agent_timeout_secs` tool/action
|
||||
/// wall-clock timeout).
|
||||
///
|
||||
/// After persisting, pushes the new value into the live
|
||||
/// [`crate::openhuman::tool_timeout`] runtime so subsequent tool calls honour
|
||||
/// it without a core restart. The `OPENHUMAN_TOOL_TIMEOUT_SECS` env var, when
|
||||
/// set, still overrides the config value (the push is a no-op in that case).
|
||||
/// Returns the updated config snapshot.
|
||||
pub async fn apply_agent_settings(
|
||||
config: &mut Config,
|
||||
update: AgentSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
use crate::openhuman::tool_timeout::{MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
|
||||
|
||||
if let Some(timeout_secs) = update.agent_timeout_secs {
|
||||
if !(MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS).contains(&timeout_secs) {
|
||||
log::warn!(
|
||||
"[config][agent] rejected agent_timeout_secs={timeout_secs} (valid {MIN_TIMEOUT_SECS}..={MAX_TIMEOUT_SECS})"
|
||||
);
|
||||
return Err(format!(
|
||||
"agent_timeout_secs must be between {MIN_TIMEOUT_SECS} and {MAX_TIMEOUT_SECS} seconds (got {timeout_secs})"
|
||||
));
|
||||
}
|
||||
config.agent.agent_timeout_secs = timeout_secs;
|
||||
}
|
||||
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Push the persisted value into the live tool-timeout runtime so the change
|
||||
// takes effect on the next tool call without restarting the core. The env
|
||||
// override (if any) still wins inside `set_tool_timeout_secs`.
|
||||
let effective =
|
||||
crate::openhuman::tool_timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs);
|
||||
log::debug!(
|
||||
"[config][agent] agent settings saved; agent_timeout_secs={} effective={}s",
|
||||
config.agent.agent_timeout_secs,
|
||||
effective
|
||||
);
|
||||
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
snapshot,
|
||||
vec![format!(
|
||||
"agent settings saved to {}",
|
||||
config.config_path.display()
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies agent settings updates, and saves it.
|
||||
pub async fn load_and_apply_agent_settings(
|
||||
update: AgentSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
apply_agent_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Returns the agent execution settings (currently the action timeout) plus the
|
||||
/// runtime-effective value and whether the `OPENHUMAN_TOOL_TIMEOUT_SECS` env var
|
||||
/// is overriding the configured value, so the UI can explain a no-op control.
|
||||
pub async fn get_agent_settings() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
// Ensure the runtime timeout is seeded from the persisted config so the
|
||||
// `effective_timeout_secs` field is correct even if startup didn't seed it
|
||||
// (e.g. in CLI invocations or tests that skip the full boot sequence).
|
||||
crate::openhuman::tool_timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs);
|
||||
let value = serde_json::json!({
|
||||
"agent_timeout_secs": config.agent.agent_timeout_secs,
|
||||
"effective_timeout_secs": crate::openhuman::tool_timeout::tool_execution_timeout_secs(),
|
||||
"env_override": crate::openhuman::tool_timeout::env_override_active(),
|
||||
"min_timeout_secs": crate::openhuman::tool_timeout::MIN_TIMEOUT_SECS,
|
||||
"max_timeout_secs": crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS,
|
||||
});
|
||||
Ok(RpcOutcome::single_log(value, "agent settings read"))
|
||||
}
|
||||
|
||||
/// Updates the analytics-related settings in the configuration.
|
||||
pub async fn apply_analytics_settings(
|
||||
config: &mut Config,
|
||||
|
||||
@@ -1403,3 +1403,89 @@ async fn add_auto_approve_tool_appends_then_dedupes() {
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
}
|
||||
}
|
||||
|
||||
// ── agent settings (action/tool timeout, issue #3100) ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_agent_settings_updates_timeout_and_persists_snapshot() {
|
||||
// ENV_LOCK: `set_tool_timeout_secs` reads OPENHUMAN_TOOL_TIMEOUT_SECS and
|
||||
// mutates the process-global timeout; serialize against other env-touching
|
||||
// tests and ensure no operator override is masking the config value.
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_TOOL_TIMEOUT_SECS");
|
||||
}
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
|
||||
let outcome = apply_agent_settings(
|
||||
&mut cfg,
|
||||
AgentSettingsPatch {
|
||||
agent_timeout_secs: Some(300),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply agent settings");
|
||||
|
||||
assert_eq!(cfg.agent.agent_timeout_secs, 300);
|
||||
assert_eq!(
|
||||
outcome.value["config"]["agent"]["agent_timeout_secs"],
|
||||
serde_json::json!(300)
|
||||
);
|
||||
assert!(outcome
|
||||
.logs
|
||||
.iter()
|
||||
.any(|l| l.contains("agent settings saved to")));
|
||||
// With no env override, the live runtime now reflects the saved value.
|
||||
assert_eq!(
|
||||
crate::openhuman::tool_timeout::tool_execution_timeout_secs(),
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_agent_settings_rejects_out_of_range_timeout() {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let original = cfg.agent.agent_timeout_secs;
|
||||
|
||||
// Zero would disable the timeout — rejected.
|
||||
let err = apply_agent_settings(
|
||||
&mut cfg,
|
||||
AgentSettingsPatch {
|
||||
agent_timeout_secs: Some(0),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("zero timeout should be rejected");
|
||||
assert!(err.contains("between"), "unexpected error: {err}");
|
||||
|
||||
// Above the 3600s ceiling — rejected.
|
||||
let err = apply_agent_settings(
|
||||
&mut cfg,
|
||||
AgentSettingsPatch {
|
||||
agent_timeout_secs: Some(99_999),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("over-max timeout should be rejected");
|
||||
assert!(err.contains("between"), "unexpected error: {err}");
|
||||
|
||||
// The config value is untouched after a rejected update.
|
||||
assert_eq!(cfg.agent.agent_timeout_secs, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_agent_settings_none_leaves_timeout_unchanged() {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
cfg.agent.agent_timeout_secs = 250;
|
||||
|
||||
apply_agent_settings(&mut cfg, AgentSettingsPatch::default())
|
||||
.await
|
||||
.expect("apply no-op agent settings");
|
||||
|
||||
assert_eq!(cfg.agent.agent_timeout_secs, 250);
|
||||
}
|
||||
|
||||
@@ -212,12 +212,28 @@ pub struct AgentConfig {
|
||||
/// `DEFAULT_TOOL_RESULT_BUDGET_BYTES` (16 KiB).
|
||||
#[serde(default = "default_tool_result_budget_bytes")]
|
||||
pub tool_result_budget_bytes: usize,
|
||||
|
||||
/// Wall-clock timeout, in seconds, for a single tool/action execution
|
||||
/// (and the per-agent delegated chat call). Bounded to
|
||||
/// `tool_timeout::MIN_TIMEOUT_SECS..=tool_timeout::MAX_TIMEOUT_SECS`
|
||||
/// (`1..=3600`); the default is `tool_timeout::DEFAULT_TIMEOUT_SECS`
|
||||
/// (120). Surfaced in **Settings → Agent OS access → Action timeout** so
|
||||
/// users running large local models can extend it without editing config
|
||||
/// files (issue #3100). Pushed into the live
|
||||
/// [`crate::openhuman::tool_timeout`] runtime on save; the
|
||||
/// `OPENHUMAN_TOOL_TIMEOUT_SECS` env var still overrides it when set.
|
||||
#[serde(default = "default_agent_timeout_secs")]
|
||||
pub agent_timeout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_tool_result_budget_bytes() -> usize {
|
||||
crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES
|
||||
}
|
||||
|
||||
fn default_agent_timeout_secs() -> u64 {
|
||||
crate::openhuman::tool_timeout::DEFAULT_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
fn default_agent_max_tool_iterations() -> usize {
|
||||
10
|
||||
}
|
||||
@@ -284,6 +300,7 @@ impl Default for AgentConfig {
|
||||
memory_window: None,
|
||||
channel_permissions: std::collections::HashMap::new(),
|
||||
tool_result_budget_bytes: default_tool_result_budget_bytes(),
|
||||
agent_timeout_secs: default_agent_timeout_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,12 @@ struct AutonomySettingsUpdate {
|
||||
require_task_plan_approval: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentSettingsUpdate {
|
||||
/// Tool/action wall-clock timeout in seconds (1–3600). Validated server-side.
|
||||
agent_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("get_config"),
|
||||
@@ -254,6 +260,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("get_composio_trigger_settings"),
|
||||
schemas("get_autonomy_settings"),
|
||||
schemas("update_autonomy_settings"),
|
||||
schemas("get_agent_settings"),
|
||||
schemas("update_agent_settings"),
|
||||
schemas("update_search_settings"),
|
||||
schemas("get_search_settings"),
|
||||
]
|
||||
@@ -385,6 +393,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("update_autonomy_settings"),
|
||||
handler: handle_update_autonomy_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_agent_settings"),
|
||||
handler: handle_get_agent_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_agent_settings"),
|
||||
handler: handle_update_agent_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_search_settings"),
|
||||
handler: handle_update_search_settings,
|
||||
@@ -620,6 +636,28 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"get_agent_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_agent_settings",
|
||||
description: "Read agent execution settings: the action/tool wall-clock timeout, the runtime-effective value, and whether the OPENHUMAN_TOOL_TIMEOUT_SECS env var overrides it.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"settings",
|
||||
"Agent settings: agent_timeout_secs, effective_timeout_secs, env_override, min_timeout_secs, max_timeout_secs.",
|
||||
)],
|
||||
},
|
||||
"update_agent_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_agent_settings",
|
||||
description: "Update agent execution settings. Currently the action/tool wall-clock timeout (seconds). Applies to the next tool call without a restart; the OPENHUMAN_TOOL_TIMEOUT_SECS env var still overrides it when set.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "agent_timeout_secs",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Wall-clock timeout for a single tool/action execution, in seconds (1–3600). Extend this when large local models are interrupted before finishing.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"update_browser_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_browser_settings",
|
||||
@@ -1252,6 +1290,48 @@ fn handle_update_autonomy_settings(params: Map<String, Value>) -> ControllerFutu
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_agent_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
log::debug!("[config][rpc] get_agent_settings enter");
|
||||
match config_rpc::get_agent_settings().await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] get_agent_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] get_agent_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_agent_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_agent_settings enter");
|
||||
let update = match deserialize_params::<AgentSettingsUpdate>(params) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_agent_settings invalid params: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let patch = config_rpc::AgentSettingsPatch {
|
||||
agent_timeout_secs: update.agent_timeout_secs,
|
||||
};
|
||||
match config_rpc::load_and_apply_agent_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] update_agent_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_agent_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_browser_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let update = deserialize_params::<BrowserSettingsUpdate>(params)?;
|
||||
|
||||
@@ -45,6 +45,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() {
|
||||
"get_meet_settings",
|
||||
"update_autonomy_settings",
|
||||
"get_autonomy_settings",
|
||||
"get_agent_settings",
|
||||
"update_agent_settings",
|
||||
"agent_server_status",
|
||||
"reset_local_data",
|
||||
"get_onboarding_completed",
|
||||
|
||||
@@ -1,46 +1,58 @@
|
||||
# tool_timeout
|
||||
|
||||
Process-wide wall-clock timeout policy for tool execution (the node/tool runtime and the agent loop). It resolves a single bounded timeout value once per process from the `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable and exposes it as seconds and as a `Duration` for callers that wrap individual tool calls in a timeout. There is no per-tool or per-session override — it is one global, immutable value.
|
||||
Process-wide wall-clock timeout policy for tool execution (the node/tool runtime and the agent loop). It resolves a single bounded timeout value and exposes it as seconds and as a `Duration` for callers that wrap individual tool calls in a timeout. The value is **runtime-mutable**: the UI (via the `config.update_agent_settings` RPC) can change it without a core restart, and the change takes effect on the next tool call.
|
||||
|
||||
## Resolution order
|
||||
|
||||
Highest precedence first:
|
||||
|
||||
1. `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable — operator override. When set to a valid value (`1..=3600`) it always wins; config pushes are ignored while it is present.
|
||||
2. The persisted config value (`[agent].agent_timeout_secs`), pushed in via `set_tool_timeout_secs` at startup and on every `config.update_agent_settings` RPC.
|
||||
3. The built-in `DEFAULT_TIMEOUT_SECS` (`120`) default.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Read `OPENHUMAN_TOOL_TIMEOUT_SECS` once and cache it (via `OnceLock`).
|
||||
- Bound the value to `1..=3600` seconds; fall back to the `120`s default on missing, non-numeric, zero, negative, or out-of-range input.
|
||||
- Hold the effective timeout in a process-global `AtomicU64`, seeded lazily from env/default on first read.
|
||||
- Bound every candidate value to `1..=3600` seconds; fall back to the `120`s default on missing, non-numeric, zero, negative, or out-of-range input.
|
||||
- Let the persisted config drive the value at runtime while keeping the operator env var as an always-wins override.
|
||||
- Provide the timeout to callers in two shapes: raw seconds (for logging / matching frontend timeouts) and `Duration` (for `tokio::time::timeout`-style wrapping).
|
||||
- Keep parsing logic pure and testable, isolated from global-state resolution.
|
||||
- Keep parsing/resolution logic pure and testable, isolated from global-state mutation.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/openhuman/tool_timeout/mod.rs` | Entire module: constants, env parsing, cached resolution, public accessors, and inline unit tests. |
|
||||
| `src/openhuman/tool_timeout/mod.rs` | Entire module: constants, env parsing, pure resolver, atomic-backed runtime value, setter, public accessors, and inline unit tests. |
|
||||
|
||||
## Public surface
|
||||
|
||||
- `parse_tool_timeout_secs(raw: Option<&str>) -> u64` — pure parser; bounds to `1..=3600`, else returns the `120`s default. Split out so unit tests avoid racing on `OnceLock` or mutating the process env.
|
||||
- `tool_execution_timeout_secs() -> u64` — resolved timeout in seconds (cached). Used for logging and matching frontend timeouts.
|
||||
- `tool_execution_timeout_duration() -> Duration` — same resolved value as a `Duration`.
|
||||
|
||||
Internal (not exported): `DEFAULT_SECS = 120`, `MAX_SECS = 3600`, `ENV_VAR = "OPENHUMAN_TOOL_TIMEOUT_SECS"`, and `resolved_secs()` which lazily initializes the `OnceLock<u64>`.
|
||||
- `parse_tool_timeout_secs(raw: Option<&str>) -> u64` — pure parser; bounds to `1..=3600`, else returns the `120`s default.
|
||||
- `set_tool_timeout_secs(config_secs: u64) -> u64` — push a config-sourced value into the runtime atomic, honouring the env override. Returns the effective value stored. Called at startup and on each config update.
|
||||
- `env_override_active() -> bool` — `true` when `OPENHUMAN_TOOL_TIMEOUT_SECS` is set to a valid override (so UI changes are ignored). Surfaced to the settings panel.
|
||||
- `tool_execution_timeout_secs() -> u64` — effective timeout in seconds (read fresh each call).
|
||||
- `tool_execution_timeout_duration() -> Duration` — same effective value as a `Duration`.
|
||||
- Constants: `DEFAULT_TIMEOUT_SECS = 120`, `MIN_TIMEOUT_SECS = 1`, `MAX_TIMEOUT_SECS = 3600`, `ENV_VAR = "OPENHUMAN_TOOL_TIMEOUT_SECS"`.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `OPENHUMAN_TOOL_TIMEOUT_SECS` — integer seconds, valid range `1..=3600`. Anything outside that (missing, non-numeric, `0`, negative, `> 3600`) resolves to the `120`s default. Read directly from `std::env::var`, not through the TOML `Config` struct. Resolved once per process; subsequent env changes have no effect.
|
||||
- `[agent].agent_timeout_secs` (config TOML) — integer seconds, valid range `1..=3600`, default `120`. Editable live via **Settings → Agent OS access → Action timeout** or the `config.update_agent_settings` RPC.
|
||||
- `OPENHUMAN_TOOL_TIMEOUT_SECS` (env) — operator override with the same range. When valid it overrides the config value; an invalid value is ignored so the config value still applies.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None on other `openhuman`/`core` modules. Uses only `std` (`std::sync::OnceLock`, `std::time::Duration`, `std::env`).
|
||||
- `log` for the debug trace on config pushes. Otherwise only `std` (`std::sync::atomic::AtomicU64`, `std::time::Duration`, `std::env`).
|
||||
|
||||
## Used by
|
||||
|
||||
- `src/openhuman/agent/tools/delegate.rs` — imports `tool_execution_timeout_secs`.
|
||||
- `src/openhuman/agent/harness/tool_loop.rs` — uses both `tool_execution_timeout_duration` (to bound a tool call) and `tool_execution_timeout_secs` (for logging).
|
||||
- `src/openhuman/agent/harness/subagent_runner/ops.rs` — uses `tool_execution_timeout_duration`.
|
||||
- `src/openhuman/agent/harness/harness_gap_tests.rs` — pins `parse_tool_timeout_secs` default/boundary behavior.
|
||||
- `src/openhuman/agent/harness/engine/tools.rs` — `run_one_tool` wraps every tool `execute()` in `tokio::time::timeout(tool_execution_timeout_duration(), …)` and logs `tool_execution_timeout_secs()`. This is the single per-tool-call enforcement point, reached by the main loop, the session executor, and the sub-agent inner loop.
|
||||
- `src/openhuman/agent/tools/delegate.rs` — bounds the delegated provider chat call with `tool_execution_timeout_secs`.
|
||||
- `src/openhuman/config/ops.rs` — `apply_agent_settings` calls `set_tool_timeout_secs` after persisting; `get_agent_settings` reports `effective_timeout_secs` / `env_override`.
|
||||
- `src/openhuman/channels/runtime/startup.rs` — seeds the runtime value from config at core boot.
|
||||
- `src/openhuman/agent/harness/harness_gap_tests.rs` — pins `parse_tool_timeout_secs` default/boundary behaviour.
|
||||
|
||||
## Notes / gotchas
|
||||
|
||||
- The value is cached in an `OnceLock` on first read, so it is effectively immutable for the process lifetime — changing the env var at runtime does nothing.
|
||||
- The value is read fresh on every tool call, so a config change takes effect on the **next** tool call. A `tokio::time::timeout` already in flight keeps the deadline it captured.
|
||||
- `0` is deliberately rejected (it would mean "disable timeout") and falls back to the default rather than disabling.
|
||||
- No RPC controllers, agent tools, event-bus subscribers, or persisted state — this is a pure policy/config helper, intentionally not a full domain with `types.rs`/`ops.rs`/`schemas.rs`.
|
||||
- The default (`120`s) is documented in the module docstring; keep it in sync with any frontend timeout that must match.
|
||||
- A present-but-invalid env value (non-numeric / `0` / out of range) counts as "no override", so the config value still applies — only a valid env value overrides.
|
||||
- The default (`120`s) must stay in sync with any frontend timeout that mirrors it (`app/src/utils/config.ts` `TOOL_TIMEOUT_SECS`).
|
||||
|
||||
@@ -1,41 +1,121 @@
|
||||
//! Wall-clock timeouts for tool execution (node/tool runtime + agent loop).
|
||||
//! Wall-clock timeout for tool execution (node/tool runtime + agent loop).
|
||||
//!
|
||||
//! Override with the `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable (1–3600; default 120).
|
||||
//! Resolution order, highest precedence first:
|
||||
//! 1. `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable (operator override).
|
||||
//! 2. The value pushed in from the persisted config via [`set_tool_timeout_secs`]
|
||||
//! (driven by the UI / `config.update_agent_settings` RPC).
|
||||
//! 3. The built-in [`DEFAULT_TIMEOUT_SECS`] (120) default.
|
||||
//!
|
||||
//! The effective value lives in a process-global [`AtomicU64`] and is read
|
||||
//! fresh on every tool call, so a UI change takes effect on the **next** tool
|
||||
//! call without a restart. The operator env var, when set to a valid value,
|
||||
//! always wins — config pushes are ignored while it is present (logged).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_SECS: u64 = 120;
|
||||
const MAX_SECS: u64 = 3600;
|
||||
const ENV_VAR: &str = "OPENHUMAN_TOOL_TIMEOUT_SECS";
|
||||
/// Default tool-execution timeout in seconds when nothing else is configured.
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
/// Smallest accepted timeout. `0` would disable the timeout entirely, so it is
|
||||
/// rejected and falls back to the default.
|
||||
pub const MIN_TIMEOUT_SECS: u64 = 1;
|
||||
/// Largest accepted timeout (1 hour) — guards against typos that would make a
|
||||
/// hung tool wedge a session indefinitely.
|
||||
pub const MAX_TIMEOUT_SECS: u64 = 3600;
|
||||
/// Operator override env var. Takes precedence over the persisted config value.
|
||||
pub const ENV_VAR: &str = "OPENHUMAN_TOOL_TIMEOUT_SECS";
|
||||
|
||||
/// Effective timeout in seconds. `0` is the "not yet seeded" sentinel: the
|
||||
/// first read resolves env/default and stores it. Config pushes overwrite it
|
||||
/// (unless the env override is active).
|
||||
static RUNTIME_SECS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Parse a raw env-var value into a bounded timeout.
|
||||
///
|
||||
/// Testable split from [`resolved_secs`]: this function is pure and never
|
||||
/// touches global state, so unit tests can exercise every path without
|
||||
/// racing on `OnceLock` or needing to mutate the process environment.
|
||||
/// Testable split from the global resolution: this function is pure and never
|
||||
/// touches global state, so unit tests can exercise every path without racing
|
||||
/// on the atomic or mutating the process environment.
|
||||
///
|
||||
/// - `None` or a non-numeric string returns [`DEFAULT_SECS`].
|
||||
/// - Values outside `1..=MAX_SECS` are rejected (returns [`DEFAULT_SECS`]).
|
||||
/// - `None` or a non-numeric string returns [`DEFAULT_TIMEOUT_SECS`].
|
||||
/// - Values outside `MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS` are rejected (returns
|
||||
/// [`DEFAULT_TIMEOUT_SECS`]).
|
||||
/// - Valid values pass through unchanged.
|
||||
pub fn parse_tool_timeout_secs(raw: Option<&str>) -> u64 {
|
||||
raw.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|&n| (1..=MAX_SECS).contains(&n))
|
||||
.unwrap_or(DEFAULT_SECS)
|
||||
.filter(|&n| (MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS).contains(&n))
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
fn resolved_secs() -> u64 {
|
||||
static SECS: OnceLock<u64> = OnceLock::new();
|
||||
*SECS.get_or_init(|| parse_tool_timeout_secs(std::env::var(ENV_VAR).ok().as_deref()))
|
||||
/// The operator env override, if `ENV_VAR` is set to a value inside the valid
|
||||
/// range. A present-but-invalid env value (non-numeric, `0`, out of range) is
|
||||
/// treated as "no override" so the config value still applies.
|
||||
fn env_override_from(raw: Option<&str>) -> Option<u64> {
|
||||
raw.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|&n| (MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS).contains(&n))
|
||||
}
|
||||
|
||||
/// Seconds — used for logging and matching frontend timeouts.
|
||||
/// Pure resolver used by both seeding and config pushes. Env override wins;
|
||||
/// otherwise the (bounded) config value applies.
|
||||
fn resolve_effective(config_secs: u64, env_raw: Option<&str>) -> u64 {
|
||||
match env_override_from(env_raw) {
|
||||
Some(env) => env,
|
||||
None => parse_tool_timeout_secs(Some(&config_secs.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env() -> Option<String> {
|
||||
std::env::var(ENV_VAR).ok()
|
||||
}
|
||||
|
||||
/// `true` when the operator env var is set to a valid override, meaning UI /
|
||||
/// config changes to the timeout are ignored in favour of it. Surfaced to the
|
||||
/// frontend so the settings panel can explain why its control has no effect.
|
||||
pub fn env_override_active() -> bool {
|
||||
env_override_from(read_env().as_deref()).is_some()
|
||||
}
|
||||
|
||||
/// Resolve the effective timeout, seeding the atomic from env/default on first
|
||||
/// read. Concurrent first reads converge on the same seed value.
|
||||
fn current_secs() -> u64 {
|
||||
let v = RUNTIME_SECS.load(Ordering::Relaxed);
|
||||
if v == 0 {
|
||||
let seeded = resolve_effective(DEFAULT_TIMEOUT_SECS, read_env().as_deref());
|
||||
RUNTIME_SECS.store(seeded, Ordering::Relaxed);
|
||||
seeded
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a config-sourced timeout into the runtime. The operator env override,
|
||||
/// when active, always wins and `config_secs` is ignored (logged at debug).
|
||||
/// Returns the effective value stored after the call. Idempotent and safe to
|
||||
/// call repeatedly (e.g. at startup and on every config update).
|
||||
pub fn set_tool_timeout_secs(config_secs: u64) -> u64 {
|
||||
let env_raw = read_env();
|
||||
let effective = resolve_effective(config_secs, env_raw.as_deref());
|
||||
RUNTIME_SECS.store(effective, Ordering::Relaxed);
|
||||
if env_override_from(env_raw.as_deref()).is_some() {
|
||||
log::debug!(
|
||||
"[tool_timeout] config update ignored: env {ENV_VAR}={effective}s overrides requested {config_secs}s"
|
||||
);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[tool_timeout] runtime timeout set to {effective}s (requested {config_secs}s)"
|
||||
);
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
/// Effective timeout in seconds — used for logging and matching frontend
|
||||
/// timeouts. Read fresh on every call.
|
||||
pub fn tool_execution_timeout_secs() -> u64 {
|
||||
resolved_secs()
|
||||
current_secs()
|
||||
}
|
||||
|
||||
/// Effective timeout as a [`Duration`] for `tokio::time::timeout`-style callers.
|
||||
pub fn tool_execution_timeout_duration() -> Duration {
|
||||
Duration::from_secs(resolved_secs())
|
||||
Duration::from_secs(current_secs())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -44,42 +124,83 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_when_env_missing() {
|
||||
assert_eq!(parse_tool_timeout_secs(None), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(None), DEFAULT_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_when_value_not_numeric() {
|
||||
assert_eq!(parse_tool_timeout_secs(Some("not-a-number")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("12x")), DEFAULT_SECS);
|
||||
assert_eq!(
|
||||
parse_tool_timeout_secs(Some("not-a-number")),
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("")), DEFAULT_TIMEOUT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("12x")), DEFAULT_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_when_value_zero() {
|
||||
// 0 seconds would disable the timeout — reject and fall back.
|
||||
assert_eq!(parse_tool_timeout_secs(Some("0")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("0")), DEFAULT_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_when_value_above_max() {
|
||||
assert_eq!(parse_tool_timeout_secs(Some("3601")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("99999999999")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("3601")), DEFAULT_TIMEOUT_SECS);
|
||||
assert_eq!(
|
||||
parse_tool_timeout_secs(Some("99999999999")),
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_when_value_negative_or_signed() {
|
||||
// Negative values fail u64 parse and fall back to default.
|
||||
assert_eq!(parse_tool_timeout_secs(Some("-5")), DEFAULT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("-5")), DEFAULT_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_values_at_boundaries() {
|
||||
assert_eq!(parse_tool_timeout_secs(Some("1")), 1);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("3600")), MAX_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("1")), MIN_TIMEOUT_SECS);
|
||||
assert_eq!(parse_tool_timeout_secs(Some("3600")), MAX_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_midrange_value() {
|
||||
assert_eq!(parse_tool_timeout_secs(Some("300")), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_takes_precedence_over_config() {
|
||||
// When the env var holds a valid value it wins over the config value.
|
||||
assert_eq!(resolve_effective(300, Some("600")), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_value_used_when_env_absent_or_invalid() {
|
||||
// No env → config drives the effective value (bounded).
|
||||
assert_eq!(resolve_effective(300, None), 300);
|
||||
// Present-but-invalid env (non-numeric / 0 / out of range) is ignored,
|
||||
// so the config value still applies.
|
||||
assert_eq!(resolve_effective(300, Some("nonsense")), 300);
|
||||
assert_eq!(resolve_effective(300, Some("0")), 300);
|
||||
assert_eq!(resolve_effective(300, Some("4000")), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_value_is_bounded() {
|
||||
// An out-of-range config value falls back to the default rather than
|
||||
// being applied verbatim.
|
||||
assert_eq!(resolve_effective(0, None), DEFAULT_TIMEOUT_SECS);
|
||||
assert_eq!(resolve_effective(99_999, None), DEFAULT_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_from_rejects_invalid() {
|
||||
assert_eq!(env_override_from(None), None);
|
||||
assert_eq!(env_override_from(Some("")), None);
|
||||
assert_eq!(env_override_from(Some("0")), None);
|
||||
assert_eq!(env_override_from(Some("abc")), None);
|
||||
assert_eq!(env_override_from(Some("3601")), None);
|
||||
assert_eq!(env_override_from(Some("120")), Some(120));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2783,6 +2783,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() {
|
||||
vec![
|
||||
"openhuman.config_agent_server_status",
|
||||
"openhuman.config_get",
|
||||
"openhuman.config_get_agent_settings",
|
||||
"openhuman.config_get_analytics_settings",
|
||||
"openhuman.config_get_autonomy_settings",
|
||||
"openhuman.config_get_client_config",
|
||||
@@ -2799,6 +2800,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() {
|
||||
"openhuman.config_resolve_api_url",
|
||||
"openhuman.config_set_browser_allow_all",
|
||||
"openhuman.config_set_onboarding_completed",
|
||||
"openhuman.config_update_agent_settings",
|
||||
"openhuman.config_update_analytics_settings",
|
||||
"openhuman.config_update_autonomy_settings",
|
||||
"openhuman.config_update_browser_settings",
|
||||
|
||||
@@ -9081,6 +9081,132 @@ async fn json_rpc_config_autonomy_settings_roundtrip() {
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// Issue #3100 — the agent/action timeout must be readable and writable over
|
||||
/// RPC (the surface the Settings → Agent OS access "Action timeout" control
|
||||
/// uses), with the same bounds the UI shows and a validation error on garbage.
|
||||
#[tokio::test]
|
||||
async fn json_rpc_config_agent_timeout_settings_roundtrip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
// No operator override so the config value drives the effective timeout.
|
||||
let _timeout_env_guard = EnvVarGuard::unset("OPENHUMAN_TOOL_TIMEOUT_SECS");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// GET → defaults: 120s, bounds 1..=3600, no env override.
|
||||
let initial = post_json_rpc(
|
||||
&rpc_base,
|
||||
7101,
|
||||
"openhuman.config_get_agent_settings",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let initial_outer = assert_no_jsonrpc_error(&initial, "get_agent_settings initial");
|
||||
let initial_result = initial_outer
|
||||
.get("result")
|
||||
.unwrap_or_else(|| panic!("missing result: {initial_outer}"));
|
||||
assert_eq!(
|
||||
initial_result
|
||||
.get("agent_timeout_secs")
|
||||
.and_then(Value::as_u64),
|
||||
Some(120),
|
||||
"default timeout should be 120, got: {initial_outer}"
|
||||
);
|
||||
assert_eq!(
|
||||
initial_result
|
||||
.get("min_timeout_secs")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
initial_result
|
||||
.get("max_timeout_secs")
|
||||
.and_then(Value::as_u64),
|
||||
Some(3600)
|
||||
);
|
||||
assert_eq!(
|
||||
initial_result.get("env_override").and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
// UPDATE → 300s.
|
||||
let update = post_json_rpc(
|
||||
&rpc_base,
|
||||
7102,
|
||||
"openhuman.config_update_agent_settings",
|
||||
json!({ "agent_timeout_secs": 300 }),
|
||||
)
|
||||
.await;
|
||||
assert_no_jsonrpc_error(&update, "update_agent_settings");
|
||||
|
||||
// GET again → 300, and the runtime-effective value tracks it.
|
||||
let after = post_json_rpc(
|
||||
&rpc_base,
|
||||
7103,
|
||||
"openhuman.config_get_agent_settings",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let after_outer = assert_no_jsonrpc_error(&after, "get_agent_settings after");
|
||||
let after_result = after_outer
|
||||
.get("result")
|
||||
.unwrap_or_else(|| panic!("missing result: {after_outer}"));
|
||||
assert_eq!(
|
||||
after_result
|
||||
.get("agent_timeout_secs")
|
||||
.and_then(Value::as_u64),
|
||||
Some(300),
|
||||
"expected 300 after update, got: {after_outer}"
|
||||
);
|
||||
assert_eq!(
|
||||
after_result
|
||||
.get("effective_timeout_secs")
|
||||
.and_then(Value::as_u64),
|
||||
Some(300),
|
||||
"effective timeout should track the saved value, got: {after_outer}"
|
||||
);
|
||||
|
||||
// Invalid value (0 disables the timeout) → JSON-RPC error envelope.
|
||||
let bad = post_json_rpc(
|
||||
&rpc_base,
|
||||
7104,
|
||||
"openhuman.config_update_agent_settings",
|
||||
json!({ "agent_timeout_secs": 0 }),
|
||||
)
|
||||
.await;
|
||||
let bad_err = assert_jsonrpc_error(&bad, "update_agent_settings bad value");
|
||||
let err_message = bad_err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("error object missing message: {bad_err}"));
|
||||
assert!(
|
||||
err_message.contains("between"),
|
||||
"expected range validation error in: {err_message}"
|
||||
);
|
||||
|
||||
// Restore the process-global timeout so later tests in this binary don't
|
||||
// inherit the 300s value set above (the AtomicU64 is per-process, not per-test).
|
||||
openhuman_core::openhuman::tool_timeout::set_tool_timeout_secs(
|
||||
openhuman_core::openhuman::tool_timeout::DEFAULT_TIMEOUT_SECS,
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port-conflict recovery E2E
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user