Skip to content

Commit 625f9db

Browse files
committed
fix(sandbox): advertise only image skills and unstick installs
The host skills/preloaded tree is not what execute_skill_script finds inside the sandbox, so stop merging it into the agent's advertised set. Seed install files as a tar, publish transcript locators before the copy, and wait in the UI instead of 404-polling.
1 parent 6b916e7 commit 625f9db

12 files changed

Lines changed: 446 additions & 139 deletions

frontend/src/components/SandboxSkillsPanel.vue

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,13 @@
125125
</p>
126126

127127
<ul class="skill-list">
128-
<li v-for="skill in skills" :key="skill.id" class="skill-item">
128+
<li
129+
v-for="skill in skills"
130+
:key="skill.id"
131+
:ref="(el) => bindSkillItem(skill.id, el)"
132+
class="skill-item"
133+
:class="{ 'skill-item--focused': focusedSkillId === skill.id }"
134+
>
129135
<div class="skill-status-ring" :title="statusLabel(skill)">
130136
<t-progress
131137
v-if="isBusy(skill)"
@@ -154,6 +160,7 @@
154160
<span v-if="skill.version">{{ skill.version }} · </span>
155161
<span>{{ statusLabel(skill) }}</span>
156162
<span v-if="isBusy(skill)"> · {{ progressOf(skill) }}%</span>
163+
<span v-if="isBusy(skill) && progressLog(skill)"> · {{ progressLog(skill) }}</span>
157164
</p>
158165
</div>
159166
<div class="skill-item__actions">
@@ -275,7 +282,7 @@
275282
</template>
276283

277284
<script setup lang="ts">
278-
import { computed, onUnmounted, ref, watch } from 'vue'
285+
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
279286
import { MessagePlugin } from 'tdesign-vue-next'
280287
import { useI18n } from 'vue-i18n'
281288
import { fetchEventSource } from '@microsoft/fetch-event-source'
@@ -330,6 +337,9 @@ const deletingId = ref('')
330337
const expandedSkillId = ref('')
331338
const expandedCopyIds = ref<Set<string>>(new Set())
332339
const transcriptEpoch = ref(0)
340+
const focusedSkillId = ref('')
341+
const skillItemEls = new Map<string, HTMLElement>()
342+
let focusTimer: number | null = null
333343
const fileInputRef = ref<HTMLInputElement | null>(null)
334344
const progressById = ref<Record<string, ConfigSkillInstallEvent>>({})
335345
@@ -408,6 +418,26 @@ function hasTranscript(skill: ConfigSkill): boolean {
408418
return Boolean(skill.install_session_id && skill.install_message_id)
409419
}
410420
421+
function bindSkillItem(id: string, el: unknown) {
422+
if (el instanceof HTMLElement) {
423+
skillItemEls.set(id, el)
424+
return
425+
}
426+
skillItemEls.delete(id)
427+
}
428+
429+
function revealSkill(skillId: string) {
430+
focusedSkillId.value = skillId
431+
void nextTick(() => {
432+
skillItemEls.get(skillId)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
433+
})
434+
if (focusTimer != null) window.clearTimeout(focusTimer)
435+
focusTimer = window.setTimeout(() => {
436+
if (focusedSkillId.value === skillId) focusedSkillId.value = ''
437+
focusTimer = null
438+
}, 2400)
439+
}
440+
411441
function onTranscriptVisible(skill: ConfigSkill, visible: boolean) {
412442
if (visible) {
413443
if (expandedSkillId.value !== skill.id) {
@@ -671,7 +701,10 @@ async function uploadFile(file: File) {
671701
const skillId = res?.data?.skill_id
672702
await loadSkills()
673703
await refreshImage()
674-
if (skillId) followProgress(skillId)
704+
if (skillId) {
705+
followProgress(skillId)
706+
revealSkill(skillId)
707+
}
675708
} catch (e: any) {
676709
MessagePlugin.error(e?.message || t('settings.sandbox.skillUploadFailed'))
677710
} finally {
@@ -698,7 +731,10 @@ async function installFromSource() {
698731
const skillId = res?.data?.skill_id
699732
await loadSkills()
700733
await refreshImage()
701-
if (skillId) followProgress(skillId)
734+
if (skillId) {
735+
followProgress(skillId)
736+
revealSkill(skillId)
737+
}
702738
} catch (e: any) {
703739
MessagePlugin.error(e?.message || t('settings.sandbox.skillSourceFailed'))
704740
} finally {
@@ -773,6 +809,7 @@ watch(
773809
onUnmounted(() => {
774810
stopAllFollows()
775811
stopPoll()
812+
if (focusTimer != null) window.clearTimeout(focusTimer)
776813
})
777814
</script>
778815

@@ -976,6 +1013,12 @@ onUnmounted(() => {
9761013
border: 1px solid var(--td-component-stroke);
9771014
border-radius: 8px;
9781015
background: var(--td-bg-color-container);
1016+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
1017+
}
1018+
1019+
.skill-item--focused {
1020+
border-color: var(--td-brand-color);
1021+
box-shadow: 0 0 0 2px var(--td-brand-color-focus, rgba(0, 168, 112, 0.18));
9791022
}
9801023
9811024
.skill-transcript-toggle--on {

frontend/src/components/SkillInstallTimeline.vue

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ const props = defineProps<{
3838
// The durable rows behind the run, used when the event log has aged out.
3939
sessionId: string
4040
messageId: string
41-
// True while this skill is still installing. The locators are written only
42-
// after the installer sandbox is up, so a 404 here means "not yet" rather
43-
// than "gone" and the stream is retried.
41+
// True while this skill is still installing. Locators are written after the
42+
// sandbox is up; until then this component shows the waiting copy and does
43+
// not hit /transcript.
4444
live?: boolean
4545
compact?: boolean
4646
}>()
@@ -168,22 +168,30 @@ async function open() {
168168
const run = ++openRun
169169
closed = false
170170
messages.splice(0, messages.length)
171-
loading.value = true
172171
const stale = () => run !== openRun || closed
173172
try {
174173
// A finished install already has durable rows. Replaying the event log
175174
// through processStreamChunk would animate every tool call again, which
176175
// is what "view the run" must not do.
177176
if (!props.live) {
177+
loading.value = true
178178
if (props.sessionId) {
179179
await loadPersisted(run)
180180
}
181181
return
182182
}
183183
184-
// Locators land after the installer sandbox is up. Keep asking until the
185-
// stream answers; falling through to the empty state on the first 404
186-
// would flash "no record" during setup.
184+
// Locators land after the installer sandbox is up. Hitting /transcript
185+
// before that 404s every second (WARNING in the access log) and leaves
186+
// the spinner up for the entire file seed, which can take minutes.
187+
// The parent already polls the skill list; this watch re-opens when
188+
// sessionId arrives.
189+
if (!props.sessionId || !props.messageId) {
190+
loading.value = false
191+
return
192+
}
193+
194+
loading.value = true
187195
for (;;) {
188196
if (stale() || !props.live) return
189197
const served = await follow(run).catch(() => false)
@@ -204,7 +212,7 @@ async function open() {
204212
}
205213
206214
watch(
207-
() => [props.configId, props.skillId, props.sessionId, props.live] as const,
215+
() => [props.configId, props.skillId, props.sessionId, props.messageId, props.live] as const,
208216
() => {
209217
stop()
210218
if (props.configId && props.skillId) void open()

internal/agent/skills/manager.go

Lines changed: 15 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ type Manager struct {
7777
sandboxMgr sandbox.Manager
7878

7979
// tenantSource holds the skills installed into this run's sandbox image.
80-
// It is nil for every run whose workspace has none, which is why the
81-
// preloaded path below is untouched by its existence.
80+
// When set it is the only source the model is told about: the host
81+
// skills/preloaded directory is not what execute_skill_script would find
82+
// inside the sandbox.
8283
tenantSource SkillSource
8384

8485
// Configuration
@@ -129,70 +130,25 @@ func (m *Manager) WithTenantSource(source SkillSource) *Manager {
129130
return m
130131
}
131132

132-
// resolveSource decides which source owns one skill name. An installed skill
133-
// shadows a preloaded one of the same name, because the sandbox boots the
134-
// image the install produced and that is the copy a script would run.
133+
// resolveSource decides which source owns one skill name. An installed image
134+
// is the only copy the sandbox can run: falling back to a host preloaded
135+
// skill would advertise files that are not in the image.
135136
func (m *Manager) resolveSource(skillName string) SkillSource {
136137
if m.tenantSource != nil {
137-
if _, err := m.tenantSource.GetSkillBasePath(skillName); err == nil {
138-
return m.tenantSource
139-
}
138+
return m.tenantSource
140139
}
141140
return m.loader
142141
}
143142

144-
// discoverAllSkills merges the two sources into the set the model is told
145-
// about.
143+
// discoverAllSkills returns the set the model is told about. When skills are
144+
// installed into the sandbox image, that image is the source of truth; the
145+
// deployment's skills/preloaded directory is not what execute_skill_script
146+
// would find inside the sandbox.
146147
func (m *Manager) discoverAllSkills() ([]*SkillMetadata, error) {
147-
preloaded, err := m.loader.DiscoverSkills()
148-
if err != nil {
149-
return nil, err
150-
}
151-
return m.mergeWithTenantSkills(preloaded)
152-
}
153-
154-
// mergeWithTenantSkills overlays the installed skills on a freshly discovered
155-
// preloaded set.
156-
func (m *Manager) mergeWithTenantSkills(preloaded []*SkillMetadata) ([]*SkillMetadata, error) {
157-
if m.tenantSource == nil {
158-
return preloaded, nil
159-
}
160-
tenant, err := m.tenantSource.DiscoverSkills()
161-
if err != nil {
162-
return nil, err
163-
}
164-
return mergeSkillMetadata(preloaded, tenant), nil
165-
}
166-
167-
// mergeSkillMetadata overlays the installed skills on the preloaded ones,
168-
// keeping the preloaded ordering for the names both sources carry so the
169-
// system prompt does not reshuffle when a skill is installed.
170-
func mergeSkillMetadata(preloaded, tenant []*SkillMetadata) []*SkillMetadata {
171-
byName := make(map[string]*SkillMetadata, len(tenant))
172-
for _, meta := range tenant {
173-
if meta != nil {
174-
byName[meta.Name] = meta
175-
}
176-
}
177-
merged := make([]*SkillMetadata, 0, len(preloaded)+len(tenant))
178-
overridden := make(map[string]bool, len(tenant))
179-
for _, meta := range preloaded {
180-
if meta == nil {
181-
continue
182-
}
183-
if installed, ok := byName[meta.Name]; ok {
184-
merged = append(merged, installed)
185-
overridden[meta.Name] = true
186-
continue
187-
}
188-
merged = append(merged, meta)
189-
}
190-
for _, meta := range tenant {
191-
if meta != nil && !overridden[meta.Name] {
192-
merged = append(merged, meta)
193-
}
148+
if m.tenantSource != nil {
149+
return m.tenantSource.DiscoverSkills()
194150
}
195-
return merged
151+
return m.loader.Reload()
196152
}
197153

198154
// Initialize discovers all skills and caches their metadata
@@ -491,11 +447,7 @@ func (m *Manager) Reload(ctx context.Context) error {
491447
return nil
492448
}
493449

494-
preloaded, err := m.loader.Reload()
495-
if err != nil {
496-
return err
497-
}
498-
metadata, err := m.mergeWithTenantSkills(preloaded)
450+
metadata, err := m.discoverAllSkills()
499451
if err != nil {
500452
return err
501453
}

internal/agent/skills/tenant_source_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ func TestTenantSkillSourceReportsAMissingBundleWithoutBlockingExecution(t *testi
188188
require.Equal(t, "/opt/weknora/tenant/skills/pdf/scripts/extract.py", remote)
189189
}
190190

191-
func TestManagerPrefersTheTenantSkillOverASameNamedPreloadedOne(t *testing.T) {
192-
dir := preloadedSkillDir(t, "pdf", "preloaded description")
191+
func TestManagerIgnoresPreloadedSkillsWhenTenantSourceIsAttached(t *testing.T) {
192+
dir := preloadedSkillDir(t, "document-analyzer", "preloaded description")
193193
mgr := NewManager(&ManagerConfig{SkillDirs: []string{dir}, Enabled: true}, nil)
194194
mgr.WithTenantSource(NewTenantSkillSource([]*types.TenantSkillEntity{
195195
{
@@ -209,13 +209,17 @@ func TestManagerPrefersTheTenantSkillOverASameNamedPreloadedOne(t *testing.T) {
209209
byName[meta.Name] = meta
210210
}
211211
require.Len(t, byName, 2)
212-
require.Equal(t, "tenant description", byName["pdf"].Description,
213-
"the tenant's own install is what the sandbox image actually carries")
212+
require.NotContains(t, byName, "document-analyzer",
213+
"host preloaded skills are not in the sandbox image")
214+
require.Equal(t, "tenant description", byName["pdf"].Description)
214215
require.Equal(t, "tenant only", byName["csv"].Description)
215216

216217
skill, err := mgr.LoadSkill(context.Background(), "pdf")
217218
require.NoError(t, err)
218219
require.Equal(t, "tenant body", skill.Instructions)
220+
221+
_, err = mgr.LoadSkill(context.Background(), "document-analyzer")
222+
require.Error(t, err, "a host-only skill must not be readable once the image is the source")
219223
}
220224

221225
func TestManagerRunsATenantSkillFromTheImageWithoutUploading(t *testing.T) {
@@ -246,13 +250,10 @@ func TestManagerRunsATenantSkillFromTheImageWithoutUploading(t *testing.T) {
246250

247251
// Preloaded skills keep uploading from the host and keep running in their own
248252
// directory; the tenant source must not change that path at all.
249-
func TestManagerKeepsPreloadedSkillExecutionUnchanged(t *testing.T) {
253+
func TestManagerKeepsPreloadedSkillExecutionWhenNoTenantSource(t *testing.T) {
250254
dir := preloadedSkillDir(t, "pdf", "preloaded description")
251255
sandboxMgr := &recordingSandboxManager{}
252256
mgr := NewManager(&ManagerConfig{SkillDirs: []string{dir}, Enabled: true}, sandboxMgr)
253-
mgr.WithTenantSource(NewTenantSkillSource([]*types.TenantSkillEntity{{
254-
ID: "sk-1", Name: "csv", Status: types.SkillStatusReady, Enabled: true,
255-
}}, nil))
256257
require.NoError(t, mgr.Initialize(context.Background()))
257258

258259
_, err := mgr.ExecuteScript(context.Background(), "pdf", "scripts/run.py", nil, "")

internal/application/service/agent_service.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -237,20 +237,18 @@ func (s *agentService) CreateAgentEngine(
237237
}
238238
}
239239

240-
skillsEnabledWithDirs := config.SkillsEnabled && len(config.SkillDirs) > 0
241-
// Initialize the skills/sandbox manager when skills are configured, or for
242-
// the skill installer, which keeps skills off yet needs the sandbox shell.
243-
// The second disjunct is deliberately NOT "the config lists a sandbox
244-
// tool": shell_exec is a user-selectable entry in the tool picker, so that
245-
// rule would hand a live sandbox shell to every stored agent record that
246-
// already lists it. Install mode is settable only through
247-
// EnableSkillInstallMode, so only the built-in installer passes here and
248-
// every other agent keeps exactly its previous behaviour.
249-
if skillsEnabledWithDirs || config.SkillInstallMode() {
240+
// TenantSkills is the sandbox image. SkillDirs is the host
241+
// skills/preloaded tree and is no longer filled on the QA path; it
242+
// remains so tests (and any caller that still points at a host
243+
// directory) can construct a manager. Install mode initializes for
244+
// the shell without hanging a skills manager on the engine.
245+
offerSkills := config.SkillsEnabled &&
246+
(len(config.SkillDirs) > 0 || len(config.TenantSkills) > 0)
247+
if offerSkills || config.SkillInstallMode() {
250248
skillsManager, err := s.initializeSkillsManager(ctx, sessionID, config, toolRegistry)
251249
if err != nil {
252250
logger.Warnf(ctx, "Failed to initialize skills manager: %v", err)
253-
} else if skillsEnabledWithDirs && skillsManager != nil {
251+
} else if offerSkills && skillsManager != nil {
254252
engine.SetSkillsManager(skillsManager)
255253
logger.Infof(ctx, "Skills manager initialized with %d skills",
256254
len(skillsManager.GetAllMetadata()))
@@ -838,6 +836,14 @@ func (s *agentService) registerTools(
838836
case tools.ToolWikiDeletePage:
839837
toolToRegister = tools.NewWikiDeletePageTool(s.wikiPageService, wikiKBIDs, wikiRoutes)
840838

839+
case tools.ToolShellExec, tools.ToolReadSkill, tools.ToolExecuteSkillScript,
840+
tools.ToolListSandboxFiles, tools.ToolReadSandboxFile:
841+
// Bound to the resolved sandbox manager in initializeSkillsManager
842+
// / registerSandboxShellTool. Listing them here would warn
843+
// "Unknown tool: shell_exec" on every skill install, then register
844+
// the real tool a few lines later.
845+
continue
846+
841847
default:
842848
logger.Warnf(ctx, "Unknown tool: %s", toolName)
843849
}
@@ -1178,6 +1184,11 @@ func (s *agentService) resolvePinnedSkillInfos(config *types.AgentConfig) []*age
11781184
}
11791185
}
11801186
}
1187+
for _, row := range config.TenantSkills {
1188+
if row != nil && row.Name != "" {
1189+
descByName[row.Name] = row.Description
1190+
}
1191+
}
11811192

11821193
result := make([]*agent.PinnedSkillInfo, 0, len(config.PinnedSkillNames))
11831194
for _, name := range config.PinnedSkillNames {

0 commit comments

Comments
 (0)