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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Router } from '@angular/router';
import { CdkMenuTrigger, CdkMenu, CdkMenuItem } from '@angular/cdk/menu';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { heroCheck } from '@ng-icons/heroicons/outline';
import { heroCheck, heroLockClosed } from '@ng-icons/heroicons/outline';
import { ModelService } from '../../session/services/model/model.service';
import { SessionService } from '../../session/services/session/session.service';
import { ManagedModel } from '../../admin/manage-models/models/managed-model.model';
Expand All @@ -12,9 +12,20 @@ import { ManagedModel } from '../../admin/manage-models/models/managed-model.mod
selector: 'app-model-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CdkMenuTrigger, CdkMenu, CdkMenuItem, NgIcon],
providers: [provideIcons({ heroCheck })],
providers: [provideIcons({ heroCheck, heroLockClosed })],
template: `
<div class="relative">
@if (modelService.agentModelLocked()) {
<!-- Agent-dictated: the active agent pins this model; the picker is locked. -->
<div
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400"
title="This agent runs on a fixed model"
>
<ng-icon name="heroLockClosed" class="size-3.5 shrink-0" aria-hidden="true" />
<span>{{ modelService.selectedModel().modelName || 'Loading...' }}</span>
<span class="sr-only">(set by this agent)</span>
</div>
} @else {
<button
type="button"
[cdkMenuTriggerFor]="modelMenu"
Expand All @@ -37,6 +48,7 @@ import { ManagedModel } from '../../admin/manage-models/models/managed-model.mod
/>
</svg>
</button>
}

<ng-template #modelMenu>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ <h2 id="model-settings-title" class="text-base/7 font-semibold text-gray-900 dar
<div class="mt-2 text-sm/6 text-red-600 dark:text-red-400" role="alert">
{{ modelService.modelsError() }}
</div>
} @else if (modelService.agentModelLocked()) {
<!-- Agent-dictated: the active agent pins this model; the picker is locked. -->
<div class="mt-1.5 flex items-center gap-2 rounded-2xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-white/5">
<ng-icon name="heroLockClosed" class="size-3.5 shrink-0 text-gray-400 dark:text-gray-500" aria-hidden="true" />
<span class="truncate text-sm/6 font-medium text-gray-900 dark:text-white">
{{ modelService.selectedModel().modelName }}
</span>
<span class="ml-auto shrink-0 text-xs/5 text-gray-500 dark:text-gray-400">Set by agent</span>
</div>
} @else {
<!-- Custom dropdown -->
<div class="relative mt-1.5">
Expand Down Expand Up @@ -353,6 +362,12 @@ <h3 id="skills-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whi

@if (isSkillsOpen()) {
<div id="skills-body" class="px-4 pb-4">
@if (skillService.agentLocked()) {
<p class="mb-3 flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-2 text-xs/5 text-gray-500 dark:bg-white/5 dark:text-gray-400">
<ng-icon name="heroLockClosed" class="size-3.5 shrink-0" aria-hidden="true" />
This agent uses a fixed set of skills.
</p>
}
@if (skillService.loading()) {
<div class="text-sm/6 text-gray-500 dark:text-gray-400">Loading skills...</div>
} @else if (skillService.error()) {
Expand Down Expand Up @@ -392,19 +407,23 @@ <h3 id="skills-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whi
type="button"
role="switch"
[id]="'skill-toggle-' + skill.skillId"
[attr.aria-checked]="skill.isEnabled"
[attr.aria-checked]="skillService.isSkillShownEnabled(skill)"
[attr.aria-labelledby]="'skill-label-' + skill.skillId"
[attr.aria-describedby]="'skill-desc-' + skill.skillId"
[disabled]="skillService.agentLocked()"
(click)="toggleSkill(skill.skillId)"
(keydown.space)="$event.preventDefault(); toggleSkill(skill.skillId)"
(keydown.enter)="toggleSkill(skill.skillId)"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class]="skill.isEnabled ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
class="relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class.cursor-pointer]="!skillService.agentLocked()"
[class.cursor-not-allowed]="skillService.agentLocked()"
[class.opacity-60]="skillService.agentLocked()"
[class]="skillService.isSkillShownEnabled(skill) ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
<span
aria-hidden="true"
class="pointer-events-none inline-block size-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
[class.translate-x-5]="skill.isEnabled"
[class.translate-x-0]="!skill.isEnabled">
[class.translate-x-5]="skillService.isSkillShownEnabled(skill)"
[class.translate-x-0]="!skillService.isSkillShownEnabled(skill)">
</span>
</button>
</div>
Expand Down Expand Up @@ -436,6 +455,12 @@ <h3 id="tools-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whit

@if (isToolsOpen()) {
<div id="tools-body" class="px-4 pb-4">
@if (toolService.agentLocked()) {
<p class="mb-3 flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-2 text-xs/5 text-gray-500 dark:bg-white/5 dark:text-gray-400">
<ng-icon name="heroLockClosed" class="size-3.5 shrink-0" aria-hidden="true" />
This agent uses a fixed set of tools.
</p>
}
@if (toolService.loading()) {
<div class="text-sm/6 text-gray-500 dark:text-gray-400">Loading tools...</div>
} @else if (toolService.error()) {
Expand Down Expand Up @@ -482,19 +507,23 @@ <h3 id="tools-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whit
type="button"
role="switch"
[id]="'tool-toggle-' + tool.toolId"
[attr.aria-checked]="tool.isEnabled"
[attr.aria-checked]="toolService.isToolShownEnabled(tool)"
[attr.aria-labelledby]="'tool-label-' + tool.toolId"
[attr.aria-describedby]="'tool-desc-' + tool.toolId"
[disabled]="toolService.agentLocked()"
(click)="toggleTool(tool.toolId)"
(keydown.space)="$event.preventDefault(); toggleTool(tool.toolId)"
(keydown.enter)="toggleTool(tool.toolId)"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class]="tool.isEnabled ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
class="relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class.cursor-pointer]="!toolService.agentLocked()"
[class.cursor-not-allowed]="toolService.agentLocked()"
[class.opacity-60]="toolService.agentLocked()"
[class]="toolService.isToolShownEnabled(tool) ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
<span
aria-hidden="true"
class="pointer-events-none inline-block size-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
[class.translate-x-5]="tool.isEnabled"
[class.translate-x-0]="!tool.isEnabled">
[class.translate-x-5]="toolService.isToolShownEnabled(tool)"
[class.translate-x-0]="!toolService.isToolShownEnabled(tool)">
</span>
</button>
</div>
Expand All @@ -514,18 +543,22 @@ <h3 id="tools-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whit
<button
type="button"
role="switch"
[attr.aria-checked]="sub.enabled"
[attr.aria-checked]="toolService.isSubToolShownEnabled(tool, sub)"
[attr.aria-labelledby]="'subtool-label-' + tool.toolId + '-' + sub.name"
[disabled]="toolService.agentLocked()"
(click)="toggleServerTool(tool.toolId, sub.name)"
(keydown.space)="$event.preventDefault(); toggleServerTool(tool.toolId, sub.name)"
(keydown.enter)="toggleServerTool(tool.toolId, sub.name)"
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class]="sub.enabled ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
[class.cursor-pointer]="!toolService.agentLocked()"
[class.cursor-not-allowed]="toolService.agentLocked()"
[class.opacity-60]="toolService.agentLocked()"
[class]="toolService.isSubToolShownEnabled(tool, sub) ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">
<span
aria-hidden="true"
class="pointer-events-none inline-block size-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
[class.translate-x-4]="sub.enabled"
[class.translate-x-0]="!sub.enabled">
[class.translate-x-4]="toolService.isSubToolShownEnabled(tool, sub)"
[class.translate-x-0]="!toolService.isSubToolShownEnabled(tool, sub)">
</span>
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed, effect, ElementRef } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath } from '@ng-icons/heroicons/outline';
import { heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath, heroLockClosed } from '@ng-icons/heroicons/outline';
import { ModelService } from '../../session/services/model/model.service';
import { ToolService, Tool } from '../../services/tool/tool.service';
import { SkillService } from '../../services/skill/skill.service';
Expand Down Expand Up @@ -42,7 +42,7 @@ interface AdvancedParamRow {
selector: 'app-model-settings',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIcon],
providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath })],
providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath, heroLockClosed })],
host: {
'(document:click)': 'onDocumentClick($event)',
},
Expand Down
39 changes: 39 additions & 0 deletions frontend/ai.client/src/app/services/skill/skill.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,45 @@ describe('SkillService', () => {
});
});

describe('agent binding lock', () => {
beforeEach(setup);

it('is unlocked by default', () => {
expect(service.agentLocked()).toBe(false);
});

it('locks enabledSkillIds to the bound set (replace semantics)', () => {
// web_research is user-disabled; the lock forces exactly the bound set.
service.lockToAgentSkills(['web_research']);
expect(service.agentLocked()).toBe(true);
expect(service.enabledSkillIds()).toEqual(['web_research']);
expect(service.enabledCount()).toBe(1);
});

it('reflects the bound set in per-skill shown state', () => {
service.lockToAgentSkills(['web_research']);
const pdf = service.skills().find(s => s.skillId === 'pdf_workflows')!;
const web = service.skills().find(s => s.skillId === 'web_research')!;
expect(service.isSkillShownEnabled(pdf)).toBe(false);
expect(service.isSkillShownEnabled(web)).toBe(true);
});

it('ignores toggles while locked (no HTTP)', async () => {
service.lockToAgentSkills(['web_research']);
await service.toggleSkill('pdf_workflows');
httpMock.expectNone('http://localhost:8000/skills/preferences');
expect(service.enabledSkillIds()).toEqual(['web_research']);
});

it('restores the user set when cleared', () => {
service.lockToAgentSkills(['web_research']);
service.clearAgentLock();
expect(service.agentLocked()).toBe(false);
// Back to per-skill state: only pdf_workflows is user-enabled.
expect(service.enabledSkillIds()).toEqual(['pdf_workflows']);
});
});

describe('loadSkills', () => {
beforeEach(setup);

Expand Down
53 changes: 49 additions & 4 deletions frontend/ai.client/src/app/services/skill/skill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,21 @@ export class SkillService {
private _error = signal<string | null>(null);
private _initialized = signal(false);

// Agent Designer: when the active conversation is bound to an Agent that binds
// skills, the picker is locked to exactly that set — the backend governs skills
// (and forces skill-mode) at invocation regardless of the client. Holds the
// bound skill ids, or null when not agent-bound.
private readonly _agentLockedSkillIds = signal<string[] | null>(null);

// Public readonly signals
readonly skills = this._skills.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly initialized = this._initialized.asReadonly();

/** True when the skill set is dictated by the active Agent and toggles are locked. */
readonly agentLocked = computed(() => this._agentLockedSkillIds() !== null);

constructor() {
// Load the user's skills once the feature is known to be enabled. While
// skills are deferred (disabled) the /skills API is unmounted, so gating
Expand All @@ -78,14 +87,48 @@ export class SkillService {
);

/** Skill ids to send as `enabled_skills` on a skills-mode chat request. */
readonly enabledSkillIds = computed(() =>
this.enabledSkills().map(s => s.skillId)
);
readonly enabledSkillIds = computed(() => {
// Agent-bound: the Agent's skills are the effective set (the backend enforces
// the same, replace semantics, and forces skill-mode). Toggling is disabled.
const locked = this._agentLockedSkillIds();
if (locked !== null) {
return [...locked];
}
return this.enabledSkills().map(s => s.skillId);
});

readonly enabledCount = computed(() => this.enabledSkills().length);
readonly enabledCount = computed(() => {
const locked = this._agentLockedSkillIds();
if (locked !== null) {
return locked.length;
}
return this.enabledSkills().length;
});

readonly hasSkills = computed(() => this._skills().length > 0);

/**
* Whether a skill row should render as ON. Agent-locked → membership in the
* bound set; otherwise the user's own enabled state.
*/
isSkillShownEnabled(skill: UserSkill): boolean {
const locked = this._agentLockedSkillIds();
if (locked !== null) {
return locked.includes(skill.skillId);
}
return skill.isEnabled;
}

/** Lock the picker to an Agent's bound skills (Agent Designer). */
lockToAgentSkills(skillIds: string[]): void {
this._agentLockedSkillIds.set([...skillIds]);
}

/** Release an Agent skill lock. */
clearAgentLock(): void {
this._agentLockedSkillIds.set(null);
}

/**
* Fetch the user's accessible skills. Called on service construction;
* call again after login or role changes.
Expand Down Expand Up @@ -114,6 +157,8 @@ export class SkillService {

/** Toggle a skill's enabled state (optimistic, reverts on save failure). */
async toggleSkill(skillId: string): Promise<void> {
// Agent-locked: the skill set is dictated by the Agent; ignore toggles.
if (this._agentLockedSkillIds() !== null) return;
const skill = this._skills().find(s => s.skillId === skillId);
if (!skill) return;

Expand Down
45 changes: 45 additions & 0 deletions frontend/ai.client/src/app/services/tool/tool.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,51 @@ describe('ToolService', () => {
});
});

describe('agent binding lock', () => {
beforeEach(setup);

it('is unlocked by default', () => {
expect(service.agentLocked()).toBe(false);
});

it('locks enabledToolIds to the bound set (replace semantics)', () => {
service.lockToAgentTools(['code-interp']);
expect(service.agentLocked()).toBe(true);
// Replaces the user's set entirely, even though search-web is user-enabled.
expect(service.enabledToolIds()).toEqual(['code-interp']);
expect(service.enabledCount()).toBe(1);
});

it('reflects the bound set in per-tool shown state', () => {
service.lockToAgentTools(['code-interp']);
const search = service.getTool('search-web')!;
const code = service.getTool('code-interp')!;
expect(service.isToolShownEnabled(search)).toBe(false);
expect(service.isToolShownEnabled(code)).toBe(true);
});

it('ignores toggles while locked (no HTTP)', async () => {
service.lockToAgentTools(['code-interp']);
await service.toggleTool('search-web');
httpMock.expectNone('http://localhost:8000/tools/preferences');
expect(service.enabledToolIds()).toEqual(['code-interp']);
});

it('restores the user set when cleared', () => {
service.lockToAgentTools(['code-interp']);
service.clearAgentLock();
expect(service.agentLocked()).toBe(false);
// Back to the per-tool computation (both mock tools are enabled).
expect(service.enabledToolIds().sort()).toEqual(['code-interp', 'search-web']);
});

it('locks to an empty set (agent with no tools ≠ free-select)', () => {
service.lockToAgentTools([]);
expect(service.agentLocked()).toBe(true);
expect(service.enabledToolIds()).toEqual([]);
});
});

describe('toggleTool', () => {
beforeEach(setup);

Expand Down
Loading