diff --git a/core/http/endpoints/studio/skills.go b/core/http/endpoints/studio/skills.go
index 448b1f5a12..def01204e0 100644
--- a/core/http/endpoints/studio/skills.go
+++ b/core/http/endpoints/studio/skills.go
@@ -42,9 +42,10 @@ func editable(c echo.Context, l skills.Layer) bool {
// ListSkills lists the skills.
// @Summary List your skills.
-// @Description Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.
+// @Description Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. A chat with a saved agent also lists the skills of that agent (agent). The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.
// @Tags chats
// @Param chat query string false "a chat id, to apply its switches"
+// @Param agents query bool false "true: add the skills of your saved agents (layer agent), for the Skills page; ignored with chat"
// @Success 200 {object} schema.ChatsSkillList "The skills"
// @Router /api/chats/skills [get]
func (h *Handlers) ListSkills(c echo.Context) error {
@@ -55,6 +56,15 @@ func (h *Handlers) ListSkills(c echo.Context) error {
if err != nil {
return failFor(c, err)
}
+ // The Skills page asks for the skills of the saved agents too. A chat's
+ // own list already has the layer of its agent.
+ if c.QueryParam("agents") == "true" && c.QueryParam("chat") == "" {
+ more, err := h.svc.AgentSkills(c.Request().Context(), userID(c))
+ if err != nil {
+ return failFor(c, err)
+ }
+ list = append(list, more...)
+ }
out := schema.ChatsSkillList{Skills: make([]schema.ChatsSkill, 0, len(list)), Notes: notes}
for _, sk := range list {
out.Skills = append(out.Skills, schema.ChatsSkill{
diff --git a/core/http/react-ui/public/locales/en/skills.json b/core/http/react-ui/public/locales/en/skills.json
index df6824543f..ef6b86d1ac 100644
--- a/core/http/react-ui/public/locales/en/skills.json
+++ b/core/http/react-ui/public/locales/en/skills.json
@@ -24,6 +24,7 @@
"shadowed": "Hidden by a {{layer}} skill",
"shadowedLong": "Hidden by a {{layer}} skill of the same name",
"providedBy": "Provided by {{name}}",
+ "proposedBy": "Proposed by {{name}} and confirmed by you. Only that agent uses it.",
"replacedBy": "Replaced by {{name}}",
"notActive": "Not active: the built-in skill serves",
"notActiveBy": "Not active: {{name}} provides it",
@@ -71,6 +72,7 @@
"layers": {
"label": "Layer",
"personal": "Personal",
+ "agent": "Agent",
"shared": "Shared",
"builtin": "Built-in",
"folder": "Workspace",
diff --git a/core/http/react-ui/src/pages/Skills.jsx b/core/http/react-ui/src/pages/Skills.jsx
index ecdbf600a2..35dba8e1ba 100644
--- a/core/http/react-ui/src/pages/Skills.jsx
+++ b/core/http/react-ui/src/pages/Skills.jsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { chatSkillsApi } from '../utils/api'
+import { skillRef } from '../utils/chatSkills'
import { useLibraryFacts } from '../hooks/useLibraryFacts'
import { useWideLayout } from '../hooks/useWideLayout'
// eslint-disable-next-line no-unused-vars
@@ -19,13 +20,14 @@ import './library.css'
const PREVIEW_LINES = 8
// The layers, nearest first: a skill of a nearer layer hides one of the same name.
-const LAYERS = ['personal', 'folder', 'shared', 'addon', 'builtin']
+const LAYERS = ['personal', 'agent', 'folder', 'shared', 'addon', 'builtin']
// Two add-ons may ship a skill of one name, so an add-on skill keys by its add-on too.
-const keyOf = (s) => (s.layer === 'addon' ? `addon/${s.addon}/${s.name}` : `${s.layer}/${s.name}`)
+// An agent skill keys by its agent: two agents may keep one name.
+const keyOf = (s) => (s.layer === 'addon' ? `addon/${s.addon}/${s.name}` : s.layer === 'agent' ? `agent/${s.agent}/${s.name}` : `${s.layer}/${s.name}`)
// The badge of a skill: the add-on's title for an add-on skill, else the layer.
-const badgeOf = (t, s) => (s.layer === 'addon' ? (s.addon_title || s.addon) : t(`layers.${s.layer}`))
+const badgeOf = (t, s) => (s.layer === 'addon' ? (s.addon_title || s.addon) : s.layer === 'agent' ? (s.agent_name || s.agent) : t(`layers.${s.layer}`))
// Why a skill does not serve, or where a serving one comes from, in one line.
// titles maps an add-on ID to its title, from the add-on skills of the list.
@@ -64,7 +66,7 @@ export default function Skills() {
const fetchSkills = useCallback(async () => {
try {
- const data = await chatSkillsApi.list()
+ const data = await chatSkillsApi.list('', { agents: true })
setSkills(data.skills || [])
setNotes(data.notes || [])
setOff('')
@@ -110,7 +112,7 @@ export default function Skills() {
const deleteSkill = async (skill) => {
setToDelete(null)
try {
- await chatSkillsApi.remove(skill.layer, skill.name)
+ await chatSkillsApi.remove(skill.layer, skill.name, skillRef(skill))
addToast(t('toasts.deleted', { name: skill.name }), 'success')
select('')
fetchSkills()
@@ -315,16 +317,17 @@ function SkillPane({ t, skill, titles, users, status, agents, busy, onBack, onEd
const [tab, setTab] = useState('overview')
const [detail, setDetail] = useState({ phase: 'loading', data: null })
const unused = status === 'ready' && users.length === 0
- const grantable = !skill.invalid
+ // An agent skill belongs to its agent: no other agent is given it from here.
+ const grantable = !skill.invalid && skill.layer !== 'agent'
useEffect(() => {
if (skill.invalid) { setDetail({ phase: 'failed', data: null }); return undefined }
let live = true
- chatSkillsApi.get(skill.layer, skill.name, skill.layer === 'addon' ? skill.addon : '')
+ chatSkillsApi.get(skill.layer, skill.name, skillRef(skill))
.then(data => { if (live) setDetail({ phase: 'ready', data }) })
.catch(() => { if (live) setDetail({ phase: 'failed', data: null }) })
return () => { live = false }
- }, [skill.layer, skill.name, skill.addon, skill.invalid])
+ }, [skill.layer, skill.name, skill.addon, skill.agent, skill.invalid])
const body = detail.data?.body || ''
const tokens = estimateTokens(body)
@@ -343,14 +346,14 @@ function SkillPane({ t, skill, titles, users, status, agents, busy, onBack, onEd
{skill.editable && (
)}
- {skill.editable && (
+ {(skill.editable || skill.removable) && (
)}
}
diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js
index 57aea3581b..1dfd8c90a9 100644
--- a/core/http/react-ui/src/utils/api.js
+++ b/core/http/react-ui/src/utils/api.js
@@ -379,13 +379,17 @@ function draftQuery(model, agentId) {
return s ? `?${s}` : ''
}
+const refQuery = (ref) => (ref.addon ? `?addon=${enc2(ref.addon)}` : ref.agent ? `?agent=${enc2(ref.agent)}` : '')
export const chatSkillsApi = {
- list: (chat) => fetchJSON(`/api/chats/skills${chat ? `?chat=${enc2(chat)}` : ''}`),
- // addon names the add-on of a skill of the addon layer.
- get: (layer, name, addon) => fetchJSON(`/api/chats/skills/${enc2(layer)}/${enc2(name)}${addon ? `?addon=${enc2(addon)}` : ''}`),
+ // agents: also list the skills of your saved agents (the Skills page); a
+ // chat's own list has the layer of its agent already.
+ list: (chat, { agents = false } = {}) => fetchJSON(`/api/chats/skills${chat ? `?chat=${enc2(chat)}` : agents ? '?agents=true' : ''}`),
+ // ref names the owner of a skill of the addon layer ({ addon }) or of the
+ // agent layer ({ agent }): see skillRef in utils/chatSkills.js.
+ get: (layer, name, ref = {}) => fetchJSON(`/api/chats/skills/${enc2(layer)}/${enc2(name)}${refQuery(ref)}`),
create: (body) => postJSON('/api/chats/skills', body),
update: (layer, name, body) => fetchJSON(`/api/chats/skills/${enc2(layer)}/${enc2(name)}`, { method: 'PUT', body: JSON.stringify(body) }),
- remove: (layer, name) => fetchJSON(`/api/chats/skills/${enc2(layer)}/${enc2(name)}`, { method: 'DELETE' }),
+ remove: (layer, name, ref = {}) => fetchJSON(`/api/chats/skills/${enc2(layer)}/${enc2(name)}${refQuery(ref)}`, { method: 'DELETE' }),
setOff: (chat, off) => fetchJSON(`/api/chats/${enc2(chat)}/skills`, { method: 'PUT', body: JSON.stringify({ off }) }),
}
@@ -464,6 +468,8 @@ export const chatAgentsApi = {
// agent and says in `warnings` what it changed.
exportFile: (id) => fetchJSON(`/api/chats/agents/${enc2(id)}/export`),
importFile: (data) => postJSON('/api/chats/agents/import', data),
+ // The builtin grants an agent can hold: { grants: [{ name, title, description, sensitive }] }.
+ builtinGrants: () => fetchJSON('/api/chats/agents/builtin-grants'),
templates: () => fetchJSON('/api/chats/agents/templates'),
fromTemplate: (id, name) => postJSON(`/api/chats/agents/templates/${enc2(id)}/create`, name ? { name } : {}),
runs: (id, limit) => fetchJSON(`/api/chats/agents/${enc2(id)}/runs${q({ limit })}`),
diff --git a/core/http/react-ui/src/utils/chatSkills.js b/core/http/react-ui/src/utils/chatSkills.js
index 5fa16d7e32..3c6a515622 100644
--- a/core/http/react-ui/src/utils/chatSkills.js
+++ b/core/http/react-ui/src/utils/chatSkills.js
@@ -52,10 +52,20 @@ export function pickerSkills(list) {
}
}
+// What names the owner of a skill besides its layer and name: the add-on of an
+// add-on skill, the saved agent of an agent skill. It is the last argument of
+// chatSkillsApi.get and remove.
+export function skillRef(s) {
+ if (s.layer === 'addon' && s.addon) return { addon: s.addon }
+ if (s.layer === 'agent' && s.agent) return { agent: s.agent }
+ return {}
+}
+
// Where a skill comes from, for the label of its row: the add-on's title for
// an add-on skill, else its layer.
export function skillSource(s) {
if (s.layer === 'addon') return { kind: 'addon', name: s.addon_title || s.addon || '' }
+ if (s.layer === 'agent') return { kind: 'agent', name: s.agent_name || s.agent || '' }
return { kind: s.layer || '' }
}
diff --git a/core/http/react-ui/tests/unit/chatskills.test.mjs b/core/http/react-ui/tests/unit/chatskills.test.mjs
index d7934fb0e9..1ecbb83980 100644
--- a/core/http/react-ui/tests/unit/chatskills.test.mjs
+++ b/core/http/react-ui/tests/unit/chatskills.test.mjs
@@ -119,3 +119,11 @@ test('install document notices use flow provider keys and retain built-in replac
assert.equal(skillInstallNoticeKey({ skill: 'writing' }), 'flow.skillShadow')
assert.equal(skillInstallNoticeKey({ skill: 'writing', name: 'Other' }), 'flow.skillShadowWarning')
})
+
+test('skillRef names the owner of an add-on skill or an agent skill, and nothing for the other layers', async () => {
+ const { skillRef, skillSource } = await import('../../src/utils/chatSkills.js')
+ assert.deepEqual(skillRef({ layer: 'addon', addon: 'office' }), { addon: 'office' })
+ assert.deepEqual(skillRef({ layer: 'agent', agent: 'a1', agent_name: 'Sales' }), { agent: 'a1' })
+ assert.deepEqual(skillRef({ layer: 'personal' }), {})
+ assert.deepEqual(skillSource({ layer: 'agent', agent: 'a1', agent_name: 'Sales' }), { kind: 'agent', name: 'Sales' })
+})
diff --git a/core/services/studio/actorscan_test.go b/core/services/studio/actorscan_test.go
index 710e440c31..5d514f18f0 100644
--- a/core/services/studio/actorscan_test.go
+++ b/core/services/studio/actorscan_test.go
@@ -40,7 +40,7 @@ var actorAllowlist = map[string]string{
"agentfolder.go:folderResolver.pathsOf": "finds where an agent's folder is now, for the person and for chatAccess",
"chatcommands.go:Service.chatSkills": "lists the skills a person may run as commands",
"skillsvc.go:Service.Skills": "lists the user's skills for the skills page",
- "skillsvc.go:Service.agentSkillsOf": "lists the agent-layer skills of the user's own agents for the skills page; a person's view, never the model's",
+ "skillsvc.go:Service.AgentSkills": "lists the agent-layer skills of the user's own agents for the skills page; a person's view, never the model's",
"skillsvc.go:Service.chatSkillSet": "reads the index of the skills the chat may use",
"loadskill.go:Service.loadSkill": "reads only skills the agent grants, by name",
"chatfiles.go:Service.ResolveAttachments": "the attachments a person sees in the transcript are a user surface",
diff --git a/core/services/studio/agentskills_internal_test.go b/core/services/studio/agentskills_internal_test.go
index 3623b12e27..502345c014 100644
--- a/core/services/studio/agentskills_internal_test.go
+++ b/core/services/studio/agentskills_internal_test.go
@@ -121,8 +121,13 @@ var _ = Describe("the agent skills layer", func() {
Expect(out).To(ContainSubstring("Writing body."))
})
- It("lists the skills of every agent outside a chat, each with its own agent", func() {
- list, _, err := svc.Skills(ctx, "alice", "")
+ It("lists the skills of every agent for the Skills page, each with its own agent, and keeps them out of the plain list", func() {
+ plain, _, err := svc.Skills(ctx, "alice", "")
+ Expect(err).NotTo(HaveOccurred())
+ for _, sk := range plain {
+ Expect(sk.Layer).NotTo(Equal(skills.LayerAgent))
+ }
+ list, err := svc.AgentSkills(ctx, "alice")
Expect(err).NotTo(HaveOccurred())
by := map[string]string{}
for _, sk := range list {
diff --git a/core/services/studio/skillsvc.go b/core/services/studio/skillsvc.go
index 02c155fa7f..a6bf7bcd67 100644
--- a/core/services/studio/skillsvc.go
+++ b/core/services/studio/skillsvc.go
@@ -238,20 +238,18 @@ func (s *Service) Skills(ctx context.Context, user, conv string) ([]skills.Skill
return nil, nil, err
}
ad.markFallbacks(list)
- if conv == "" {
- // Outside a chat the skills of every saved agent are listed, each
- // resolved on its own: two agents may keep a skill of one name.
- more, err := s.agentSkillsOf(ctx, p)
- if err != nil {
- return nil, nil, err
- }
- list = append(list, more...)
- }
return list, ad.withNotes(notes), nil
}
-// agentSkillsOf lists the agent-layer skills of every saved agent of p.
-func (s *Service) agentSkillsOf(ctx context.Context, p string) ([]skills.Skill, error) {
+// AgentSkills lists the agent-layer skills of every saved agent of the user,
+// for the Skills page. Each agent is resolved on its own, because two agents
+// may keep a skill of one name. A chat never sees this list: it resolves the
+// layer of its own agent only (chatSkillSet).
+func (s *Service) AgentSkills(ctx context.Context, user string) ([]skills.Skill, error) {
+ p := principal(user)
+ if !s.d.Files.Available() {
+ return nil, fmt.Errorf("%w: %s", files.ErrUnavailable, s.d.Files.Unavailable())
+ }
if s.d.Agents == nil {
return nil, nil
}
diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md
index 5742a7d05c..c6b77dca3b 100644
--- a/docs/content/features/chats.md
+++ b/docs/content/features/chats.md
@@ -1396,7 +1396,7 @@ Skills come from six places, nearest first, and the nearest wins when two have t
| Add-on | ships with an installed add-on | the add-on owner |
| Built-in | ships with the server | the server |
-The agent layer holds the skills of one saved agent. Only a chat that runs that agent sees them, and it needs no grant in the agent's skill list. They win over your personal skill of the same name, for that agent only. An agent writes one by a change proposal of kind `skill` with scope `agent` (see [Agents]({{%relref "features/chats-agents" %}})); nothing is written until you confirm, and the write can be undone. An agent skill cannot take the name of a built-in skill or of a document skill such as `xlsx`, and a skill that you locked in your personal layer stays locked against it. The grant `skills.write` shows the built-in `writing-skills` to the agent, led by the line "Do not write files. Call propose_change with kind skill." The **Skills** page lists agent skills with the agent's name and a **Remove** button; a removed skill goes to the Trash. The API lists them with `layer: "agent"`, `agent` and `agent_name`, and `GET` or `DELETE /api/chats/skills/agent/?agent=` reads or removes one.
+The agent layer holds the skills of one saved agent. Only a chat that runs that agent sees them, and it needs no grant in the agent's skill list. They win over your personal skill of the same name, for that agent only. An agent writes one by a change proposal of kind `skill` with scope `agent` (see [Agents]({{%relref "features/chats-agents" %}})); nothing is written until you confirm, and the write can be undone. An agent skill cannot take the name of a built-in skill or of a document skill such as `xlsx`, and a skill that you locked in your personal layer stays locked against it. The grant `skills.write` shows the built-in `writing-skills` to the agent, led by the line "Do not write files. Call propose_change with kind skill." The **Skills** page lists agent skills with the agent's name and a **Remove** button; a removed skill goes to the Trash. `GET /api/chats/skills?agents=true` adds them to the list with `layer: "agent"`, `agent` and `agent_name` (a chat's own list has the layer of its agent already), and `GET` or `DELETE /api/chats/skills/agent/?agent=` reads or removes one.
The add-on layer holds the skills of the add-ons that the chat can use now: installed, running, usable by you and on for the chat (see [Add-on tools](#add-on-tools)). A chat with a saved agent gets them only for the add-ons the agent grants. The model loads one by its name with `load_skill`; its files are in `/workspace/shared-addon-skills///`, where the add-on ID says where it comes from. Switching the add-on off for a chat takes its skills out of that chat's index, and the skill list shows each one with `addon` and `addon_title`. Its switch id is `addon:`.
diff --git a/swagger/docs.go b/swagger/docs.go
index 276b502537..8c2c44bc75 100644
--- a/swagger/docs.go
+++ b/swagger/docs.go
@@ -7183,7 +7183,7 @@ const docTemplate = `{
},
"/api/chats/skills": {
"get": {
- "description": "Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.",
+ "description": "Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. A chat with a saved agent also lists the skills of that agent (agent). The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.",
"tags": [
"chats"
],
@@ -7194,6 +7194,12 @@ const docTemplate = `{
"description": "a chat id, to apply its switches",
"name": "chat",
"in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "true: add the skills of your saved agents (layer agent), for the Skills page; ignored with chat",
+ "name": "agents",
+ "in": "query"
}
],
"responses": {
diff --git a/swagger/swagger.json b/swagger/swagger.json
index ba4698794b..ff1bfa31a0 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -7180,7 +7180,7 @@
},
"/api/chats/skills": {
"get": {
- "description": "Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.",
+ "description": "Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. A chat with a saved agent also lists the skills of that agent (agent). The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied.",
"tags": [
"chats"
],
@@ -7191,6 +7191,12 @@
"description": "a chat id, to apply its switches",
"name": "chat",
"in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "true: add the skills of your saved agents (layer agent), for the Skills page; ignored with chat",
+ "name": "agents",
+ "in": "query"
}
],
"responses": {
diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml
index ec242a82b2..27e3bdbd1e 100644
--- a/swagger/swagger.yaml
+++ b/swagger/swagger.yaml
@@ -16939,7 +16939,8 @@ paths:
get:
description: 'Skills from every layer: .skills folders of your files, your personal
skills, the shared ones, the ones of the add-ons you may use now (addon, with
- the add-on in addon and addon_title) and the built-in ones. The nearest wins;
+ the add-on in addon and addon_title) and the built-in ones. A chat with a
+ saved agent also lists the skills of that agent (agent). The nearest wins;
a skill that another overrides says so in shadowed. With chat set, the chat''s
switches are applied.'
parameters:
@@ -16947,6 +16948,11 @@ paths:
in: query
name: chat
type: string
+ - description: 'true: add the skills of your saved agents (layer agent), for
+ the Skills page; ignored with chat'
+ in: query
+ name: agents
+ type: boolean
responses:
"200":
description: The skills