Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guides/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,10 @@ first-turn prompt and speeds up the first reply.

It activates only on the `doc` profile (the registered `doc` agent, the SDK with
`ChatAgentConfig(prompt_profile="doc")`, or `gaia eval agent --agent-type doc`).
Turn it on with the config field or an environment variable — the env var wins,
which is handy for the eval harness:
Turn it on with the config field, an environment variable, or the Agent UI
**Settings → Dynamic Tools (Beta)** toggle. The env var wins over both — when it
is set, the UI toggle reflects the effective value and disables itself — which is
handy for the eval harness:

```python
from gaia.agents.chat.agent import ChatAgent, ChatAgentConfig
Expand Down
5 changes: 5 additions & 0 deletions docs/plans/tool-loader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ A malformed `*_TAU` / `*_MAX` value raises at construction (no silent default).
The loader is built **only** for `prompt_profile == "doc"` with the toggle on;
otherwise `self.tool_loader is None` and the agent stays on the legacy path.

As of #1798 the `dynamic_tools` enable knob is also reachable as a **Beta** toggle
in the Agent UI **Settings** panel (default off). `GAIA_DYNAMIC_TOOLS` still wins
when set — the toggle then reflects the effective value and disables — so τ and
the cap stay env-only tuning.

**CORE (10, always-on, cap- & eviction-exempt)** — defined in
[`tool_bundles.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/chat/tool_bundles.py):
`remember`, `recall`, `update_memory`, `forget`, `search_past_conversations`,
Expand Down
23 changes: 19 additions & 4 deletions src/gaia/agents/chat/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@
logger = get_logger(__name__)


def dynamic_tools_env_override() -> Optional[bool]:
"""Parse the ``GAIA_DYNAMIC_TOOLS`` override, or ``None`` when it is unset.

Returns the parsed boolean (truthy set ``1``/``true``/``yes``/``on``,
case-insensitive) when the env var is set, else ``None`` to signal "no
override — fall back to the persisted/config value". The UI settings
router reuses this so the env-wins precedence and the truthy set never
drift between the agent resolver and the toggle that surfaces it.
"""
raw = os.getenv("GAIA_DYNAMIC_TOOLS")
if raw is None:
return None
return raw.strip().lower() in ("1", "true", "yes", "on")


@dataclass
class ChatAgentConfig:
"""Configuration for ChatAgent."""
Expand Down Expand Up @@ -460,10 +475,10 @@ def _maybe_build_tool_loader(self) -> Optional[ToolLoader]:

def _resolve_dynamic_tools_enabled(self) -> bool:
"""Toggle: ``GAIA_DYNAMIC_TOOLS`` (truthy) wins over the config field."""
raw = os.getenv("GAIA_DYNAMIC_TOOLS")
if raw is None:
return bool(self.config.dynamic_tools)
return raw.strip().lower() in ("1", "true", "yes", "on")
override = dynamic_tools_env_override()
if override is not None:
return override
return bool(self.config.dynamic_tools)

def _resolve_dynamic_tools_threshold(self) -> float:
"""Threshold: ``GAIA_DYNAMIC_TOOLS_TAU`` wins; malformed value fails loudly."""
Expand Down
5 changes: 5 additions & 0 deletions src/gaia/apps/webui/src/components/ConnectorsSection.css
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,11 @@
outline-offset: 2px;
}

.toggle-switch input:disabled + .toggle-track {
opacity: 0.5;
cursor: not-allowed;
}

.grant-scope-warning {
display: flex;
align-items: center;
Expand Down
72 changes: 72 additions & 0 deletions src/gaia/apps/webui/src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export function SettingsPage() {
const [justSaved, setJustSaved] = useState(false);
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Dynamic Tools (Beta) toggle — #1798
const [dynamicTools, setDynamicTools] = useState(false);
const [dynamicToolsLocked, setDynamicToolsLocked] = useState(false);
const [savingDynamicTools, setSavingDynamicTools] = useState(false);
const [dynamicToolsError, setDynamicToolsError] = useState<string | null>(null);

useEffect(() => {
log.system.info('Checking system status...');
const t = log.system.time();
Expand Down Expand Up @@ -66,6 +72,8 @@ export function SettingsPage() {
const value = s.custom_model ?? '';
setCustomModel(value);
setSavedCustomModel(value);
setDynamicTools(s.dynamic_tools);
setDynamicToolsLocked(s.dynamic_tools_locked);
})
.catch((err) => {
log.system.error('Failed to load settings', err);
Expand Down Expand Up @@ -101,6 +109,30 @@ export function SettingsPage() {
}
}, [customModel]);

const toggleDynamicTools = useCallback(async () => {
if (dynamicToolsLocked || savingDynamicTools) return;
const previous = dynamicTools;
const next = !previous;
setDynamicTools(next); // optimistic
setSavingDynamicTools(true);
setDynamicToolsError(null);
try {
log.system.info('Saving dynamic_tools setting', { dynamic_tools: next });
const updated = await api.updateSettings({ dynamic_tools: next });
// Trust the server's effective value (env override may win).
setDynamicTools(updated.dynamic_tools);
setDynamicToolsLocked(updated.dynamic_tools_locked);
} catch (err) {
// No silent fallback: revert the optimistic flip and surface the error.
const msg = err instanceof Error ? err.message : String(err);
log.system.error('Failed to save dynamic_tools', err);
setDynamicTools(previous);
setDynamicToolsError(msg);
} finally {
setSavingDynamicTools(false);
}
}, [dynamicTools, dynamicToolsLocked, savingDynamicTools]);

const customModelDirty = customModel.trim() !== savedCustomModel.trim();

const modelName = status?.default_model_name ?? DEFAULT_MODEL_NAME;
Expand Down Expand Up @@ -433,6 +465,46 @@ export function SettingsPage() {
</p>
</section>

{/* Dynamic Tools (Beta) — #1798 */}
<section className="settings-section">
<h4>Dynamic Tools <span className="beta-badge">BETA</span></h4>
<p className="model-override-desc">
Trim each turn&rsquo;s tool list to a semantically-matched subset to
speed up the first response. Currently affects the Doc Agent only.
</p>
{/* <label> wraps the row so a click on the text or the track
forwards to the visually-hidden checkbox (matches the
working connector/grant toggles). */}
<label className="setting-row">
<span>Enable dynamic tool loading</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={dynamicTools}
onChange={() => void toggleDynamicTools()}
disabled={!settingsLoaded || savingDynamicTools || dynamicToolsLocked}
aria-label={dynamicTools ? 'Disable dynamic tools' : 'Enable dynamic tools'}
/>
<span className="toggle-track" />
</span>
</label>
{dynamicToolsLocked && (
<p className="model-status-hint">
Controlled by <code>GAIA_DYNAMIC_TOOLS</code> &mdash; unset that
environment variable to change this here.
</p>
)}
{dynamicToolsError && (
<div className="model-warning" role="alert">
<AlertCircle size={14} />
<div className="model-warning-content">
<strong>Could not save</strong>
<p>{dynamicToolsError}</p>
</div>
</div>
)}
</section>

{/* Memory Warnings */}
{status && status.memory_available_gb != null && (() => {
const available = status.memory_available_gb;
Expand Down
140 changes: 140 additions & 0 deletions src/gaia/apps/webui/src/components/__tests__/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
// SPDX-License-Identifier: MIT

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsPage } from '../SettingsPage';
import { useChatStore } from '../../stores/chatStore';
import type { Settings } from '../../types';
import * as api from '../../services/api';

vi.mock('../../services/api');

// Heavy child sections make their own API calls; stub them so this test
// stays focused on the Dynamic Tools toggle.
vi.mock('../CustomAgentsSection', () => ({ CustomAgentsSection: () => null }));
vi.mock('../ConnectorsSection', () => ({ ConnectorsSection: () => null }));

const mockedApi = vi.mocked(api);

function makeSettings(overrides: Partial<Settings> = {}): Settings {
return {
custom_model: null,
model_status: null,
context_size: null,
dynamic_tools: false,
dynamic_tools_locked: false,
...overrides,
};
}

beforeEach(() => {
vi.clearAllMocks();
mockedApi.getSystemStatus.mockResolvedValue(null as never);
mockedApi.getMCPRuntimeStatus.mockResolvedValue({ servers: [] } as never);
mockedApi.getSettings.mockResolvedValue(makeSettings());
mockedApi.updateSettings.mockImplementation(async (patch) =>
makeSettings(patch as Partial<Settings>),
);
useChatStore.setState({ sessions: [], agents: [] });
});

function getDynamicToolsToggle(): HTMLInputElement {
// Both enabled/disabled labels resolve to the same control.
return (screen.queryByLabelText('Enable dynamic tools')
?? screen.getByLabelText('Disable dynamic tools')) as HTMLInputElement;
}

describe('SettingsPage — Dynamic Tools toggle (#1798)', () => {
it('renders the loaded value (off by default)', async () => {
render(<SettingsPage />);
const toggle = await waitFor(getDynamicToolsToggle);
expect(toggle).not.toBeChecked();
expect(toggle).not.toBeDisabled();
});

it('reflects a persisted-on value from the server', async () => {
mockedApi.getSettings.mockResolvedValue(makeSettings({ dynamic_tools: true }));
render(<SettingsPage />);
await waitFor(() => expect(getDynamicToolsToggle()).toBeChecked());
});

it('persists dynamic_tools: true when toggled on', async () => {
render(<SettingsPage />);
const toggle = await waitFor(getDynamicToolsToggle);

await userEvent.click(toggle);

await waitFor(() =>
expect(mockedApi.updateSettings).toHaveBeenCalledWith({ dynamic_tools: true }),
);
await waitFor(() => expect(getDynamicToolsToggle()).toBeChecked());
});

it('toggles when the visible row/label is clicked, not just the hidden input', async () => {
// Regression: the checkbox is visually hidden (width:0/height:0) and the
// track is a sibling <span>. Clicking the visible control only works if a
// <label> forwards the click to the input. Clicking the input element
// directly (as the other tests do) would pass even if that wiring is
// missing — so click the visible label text here, the way a user does.
render(<SettingsPage />);
await waitFor(getDynamicToolsToggle);

await userEvent.click(screen.getByText('Enable dynamic tool loading'));

await waitFor(() =>
expect(mockedApi.updateSettings).toHaveBeenCalledWith({ dynamic_tools: true }),
);
await waitFor(() => expect(getDynamicToolsToggle()).toBeChecked());
});

it('is disabled and reflects the env value when locked', async () => {
mockedApi.getSettings.mockResolvedValue(
makeSettings({ dynamic_tools: true, dynamic_tools_locked: true }),
);
render(<SettingsPage />);
const toggle = await waitFor(getDynamicToolsToggle);

expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
expect(screen.getByText(/GAIA_DYNAMIC_TOOLS/)).toBeInTheDocument();
});

it('guards against a double-click while a save is in flight (single PUT)', async () => {
// Hold the first save open so the toggle stays mid-save between clicks.
let resolveSave!: (value: Settings) => void;
mockedApi.updateSettings.mockImplementationOnce(
() => new Promise<Settings>((resolve) => { resolveSave = resolve; }),
);

render(<SettingsPage />);
const toggle = await waitFor(getDynamicToolsToggle);

// First click starts the save; the toggle disables (savingDynamicTools).
await userEvent.click(toggle);
await waitFor(() => expect(getDynamicToolsToggle()).toBeDisabled());

// Second click while the save is pending must be a no-op, not a second PUT.
await userEvent.click(getDynamicToolsToggle());

// Let the in-flight save settle.
resolveSave(makeSettings({ dynamic_tools: true }));

await waitFor(() => expect(getDynamicToolsToggle()).toBeChecked());
expect(mockedApi.updateSettings).toHaveBeenCalledTimes(1);
});

it('reverts and surfaces an error when the save fails (no silent fallback)', async () => {
mockedApi.updateSettings.mockRejectedValue(new Error('network down'));
render(<SettingsPage />);
const toggle = await waitFor(getDynamicToolsToggle);

await userEvent.click(toggle);

await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.getByText('network down')).toBeInTheDocument();
// Optimistic flip reverted back to off.
await waitFor(() => expect(getDynamicToolsToggle()).not.toBeChecked());
});
});
4 changes: 4 additions & 0 deletions src/gaia/apps/webui/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ export interface Settings {
model_status: ModelStatus | null;
/** Persisted context window size override (tokens). null = use default 32768. */
context_size: number | null;
/** Beta dynamic tool loader (#1798). Effective value: GAIA_DYNAMIC_TOOLS wins when set, else the persisted setting. */
dynamic_tools: boolean;
/** True when GAIA_DYNAMIC_TOOLS locks the value — the toggle reflects the effective value and disables. */
dynamic_tools_locked: boolean;
}

/** Status of the GAIA Agent UI MCP server (exposes UI tools to Claude Code etc.). */
Expand Down
Loading
Loading