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
2 changes: 1 addition & 1 deletion apps/buddy/electron/preload/local-chat/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi'

export function createSkillsApi(): Pick<LocalChatApi, 'skills'> {
return { skills: Object.freeze({
list: spaceId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsList, { spaceId: spaceId ?? null }),
list: (spaceId, metadataOnly) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsList, { spaceId: spaceId ?? null, metadataOnly }),
get: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsGet, { ...input }),
listFiles: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsListFiles, { ...input }),
readFile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsReadFile, { ...input }),
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/shared/localChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ export interface LocalChatApi {
update: (input: LocalSpaceUpdateInput) => Promise<LocalSpace>
}
skills: {
list: (spaceId?: string | null) => Promise<LocalSkillCatalog>
list: (spaceId?: string | null, metadataOnly?: boolean) => Promise<LocalSkillCatalog>
get: (input: { spaceId: string | null, id: string }) => Promise<SkillDetail>
listFiles: (input: SkillDirectoryRequest) => Promise<DirectoryPage>
readFile: (input: SkillFileTarget) => Promise<FilePreview>
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ export async function startBuddyService(
runInputs,
runs,
sessions: sessionBlueprints,
skills: skillService,
})
const turnLauncher = new BuddyTurnLauncher({
lifecycle: runLifecycleService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('buddy runtime cross-subsystem contract', () => {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ async function createFixture(options: {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: DIRECTORY_CONTEXT,
revision: 'offline-s0',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AttachmentService } from '../../attachments/AttachmentService'
import type { ProviderExecutionModelResolver } from '../../providers/ProviderExecutionModelResolver'
import type { SkillService } from '../../skills/SkillService'
import type { CommandRequestRepository } from '../../storage/commandRequestRepository'
import type { ConversationRepository } from '../../storage/conversationRepository'
import type { RunInputRepository } from '../../storage/runInputRepository'
Expand All @@ -25,6 +26,7 @@ export interface BuddyRunExecutionPlannerOptions {
runInputs: Pick<RunInputRepository, 'findByRunId'>
runs: Pick<RunRepository, 'findById'>
sessions: Pick<BuddySessionBlueprintService, 'createForConversation'>
skills: Pick<SkillService, 'materializeForSpace'>
}

export class BuddyRunExecutionPlanner {
Expand Down Expand Up @@ -88,10 +90,13 @@ export class BuddyRunExecutionPlanner {
const input = this.#options.runInputs.findByRunId(run.id)
if (!input?.prompt.trim())
throw new BuddyAgentRunError('RUN_INPUT_NOT_FOUND')
for (const item of input.contextItems) {
if (item.kind !== 'skill' || !item.skill)
continue
const selected = item.skill
const selections = input.contextItems.flatMap(item => item.kind === 'skill' ? [item.skill ?? item.value] : [])
for (const selection of selections) {
if (typeof selection !== 'string' && !session.resources.skillReferences.some(skill => skill.id === selection.id && skill.name === selection.name))
throw new SkillError('SKILL_CHANGED')
}
const selectedSkills = await this.#options.skills.materializeForSpace(conversation.spaceId, selections)
for (const { reference: selected } of selectedSkills) {
if (!session.resources.skillReferences.some(skill => skill.id === selected.id && skill.name === selected.name && skill.revision === selected.revision))
throw new SkillError('SKILL_CHANGED')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1483,7 +1483,7 @@ function emptyResources() {
return {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ import type {
BuddyTurnHandle,
StartBuddyTurnInput,
} from '../turnTypes'
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRunEventLog } from '../../../events/createRunEventLog'
import { RunLifecycleService } from '../../../runs/RunLifecycleService'
import { SkillService } from '../../../skills/SkillService'
import { prepareTestTurnRequest } from '../../../storage/__tests__/composerDraftTestFixture'
import { BuddyDataPaths } from '../../../storage/BuddyDataPaths'
import { createCommandRequestRepository } from '../../../storage/commandRequestRepository'
import { createConversationRepository } from '../../../storage/conversationRepository'
import { openBuddyDatabase } from '../../../storage/database'
import { createRunInputRepository } from '../../../storage/runInputRepository'
import { createRunRepository } from '../../../storage/runRepository'
import { createSkillRepository } from '../../../storage/skillRepository'
import { createSpaceRepository } from '../../../storage/spaceRepository'
import { BuddySessionBlueprintService } from '../../sessions/BuddySessionBlueprintService'
import { BuddyRunExecutionPlanner } from '../BuddyRunExecutionPlanner'
Expand All @@ -40,6 +42,30 @@ describe('buddyTurnLauncher', () => {
expect(fixture.runs.findById('run-1')?.status).toBe('queued')
})

it.each(['legacy', 'current'] as const)('launches an unchanged %s Skill reference and rejects later package changes', async (format) => {
const fixture = await createFixture()
const directory = join(fixture.root, 'agent', 'skills', 'writer')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), '---\nname: writer\ndescription: Write text\n---\nWrite carefully.')
const resource = join(directory, 'reference.md')
await writeFile(resource, 'first version')
const [materialized] = await fixture.skills.materializeForSpace(null, ['writer'])
const selectedSkill = format === 'legacy'
? { id: materialized!.reference.id, name: 'writer', revision: materialized!.reference.packageRevision! }
: materialized!.reference
fixture.prepareTurn({ spaceId: null, selectedSkill })

const plan = await fixture.planner.resolve('run-1')
expect(plan.kind).toBe('turn')
expect(plan.input.session.resources.skillReferences).toContainEqual(expect.objectContaining({
id: selectedSkill.id,
revision: materialized!.reference.revision,
}))
await writeFile(resource, 'second version')
await expect(fixture.planner.resolve('run-1')).rejects.toMatchObject({ code: 'SKILL_CHANGED' })
expect(fixture.runs.findById('run-1')?.status).toBe('queued')
})

it('rebuilds the executable turn from persisted run facts', async () => {
const fixture = await createFixture()
fixture.prepareTurn({ spaceId: null })
Expand Down Expand Up @@ -177,19 +203,16 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image')
database,
})
const lifecycle = new RunLifecycleService({ eventLog, repository: runs })
const skills = new SkillService({
agentDirectory: join(root, 'agent'),
paths,
repository: createSkillRepository(database),
spaces,
})
const blueprints = new BuddySessionBlueprintService({
conversationGrants: { listActive: () => [] },
paths,
skills: {
loadForSpace: async () => ({
diagnostics: [],
readRoots: [],
references: [],
paths: [],
revision: 'skills-revision-1',
skills: [],
}),
},
skills,
spaces,
})
const planner = new BuddyRunExecutionPlanner({
Expand All @@ -200,6 +223,7 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image')
runInputs: createRunInputRepository(database),
runs,
sessions: blueprints,
skills,
})
return {
createLauncher(overrides: {
Expand All @@ -222,7 +246,8 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image')
planner,
resolveInputReferences,
paths,
prepareTurn({ spaceId }: { spaceId: string | null }) {
skills,
prepareTurn({ spaceId, selectedSkill = options.selectedSkill }: { spaceId: string | null, selectedSkill?: SkillReference }) {
prepareTestTurnRequest(database, {
attachmentBindings: [],
branchId: 'branch-1',
Expand All @@ -239,7 +264,7 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image')
runId: 'run-1',
runInput: {
attachmentIds: [],
contextItems: options.selectedSkill ? [{ kind: 'skill', value: options.selectedSkill.name, skill: options.selectedSkill }] : [],
contextItems: selectedSkill ? [{ kind: 'skill', value: selectedSkill.name, skill: selectedSkill }] : [],
prompt: 'Persisted prompt',
reasoning: 'high',
serviceTier: 'priority',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('buddy in-process Pi extensions', () => {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down Expand Up @@ -124,7 +124,7 @@ describe('buddy in-process Pi extensions', () => {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down Expand Up @@ -216,7 +216,7 @@ describe('buddy in-process Pi extensions', () => {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down Expand Up @@ -370,7 +370,7 @@ describe('buddy in-process Pi extensions', () => {
resources: {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'resources-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async function fixture() {
model,
modelRuntime: runtime,
inProcessExtensions: [extension, discovery.extension],
resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' },
resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' },
}
return { root, options, runtime, model }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { SkillReference } from '../../../../shared/skills/skillApi'
import type { LocalSkill, SkillReference } from '../../../../shared/skills/skillApi'
import type { SkillService } from '../../skills/SkillService'
import type { BoundedContextFilesResult } from './loadBoundedContextFiles'
import { createHash } from 'node:crypto'

import { loadBoundedContextFiles } from './loadBoundedContextFiles'

export interface BuddySessionResources {
approvedSkillPaths: readonly string[]
approvedSkills: readonly LocalSkill[]
skillReadRoots: readonly string[]
skillReferences: readonly SkillReference[]
context: BoundedContextFilesResult
Expand Down Expand Up @@ -55,7 +55,7 @@ export async function resolveBuddySessionResources(
hash.update('\0')
}
return {
approvedSkillPaths: skills.paths,
approvedSkills: skills.skills,
skillReadRoots: skills.readRoots,
skillReferences: skills.references,
context,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { SettingsManager } from '@earendil-works/pi-coding-agent'
import type { BuddyApprovalPolicy } from '../../../../shared/permissions/approvalPolicy'
import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile'
import type { LocalSkill } from '../../../../shared/skills/skillApi'
import type { BuddyInputReferenceV1 } from '../context/BuddyInputReference'
import type { BuddyInProcessExtension } from '../extensions/BuddyInProcessExtension'
import type { BoundedContextFile } from './loadBoundedContextFiles'
import { dirname } from 'node:path'
import process from 'node:process'

import {
Expand All @@ -18,7 +20,7 @@ import { createBuddySystemPrompt } from './createBuddySystemPrompt'

export interface CreateBuddyResourceLoaderOptions {
getPendingInput?: () => BuddyInputReferenceV1 | null
approvedSkillPaths: readonly string[]
approvedSkills: readonly LocalSkill[]
agentDir: string
approvalPolicy: BuddyApprovalPolicy
boundedContextFiles: readonly BoundedContextFile[]
Expand Down Expand Up @@ -56,7 +58,7 @@ export async function createBuddyResourceLoader(
const loader = new DefaultResourceLoader({
additionalExtensionPaths: [],
additionalPromptTemplatePaths: [],
additionalSkillPaths: [...options.approvedSkillPaths],
additionalSkillPaths: [],
additionalThemePaths: [],
agentDir: options.agentDir,
agentsFilesOverride: () => ({ agentsFiles: [...options.boundedContextFiles] }),
Expand All @@ -67,6 +69,22 @@ export async function createBuddyResourceLoader(
noExtensions: true,
noPromptTemplates: true,
noSkills: true,
skillsOverride: () => ({
skills: options.approvedSkills.map(skill => ({
name: skill.name,
description: skill.description,
filePath: skill.filePath,
baseDir: dirname(skill.filePath),
disableModelInvocation: skill.status === 'manual_only',
sourceInfo: {
path: skill.filePath,
source: 'lexora',
scope: skill.source === 'directory' ? 'project' : 'user',
origin: 'top-level',
},
})),
diagnostics: [],
}),
noThemes: true,
settingsManager: options.settingsManager ?? createBuddySettingsManager(),
systemPrompt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('buddySessionBlueprintService', () => {
{ canonicalRoot: spaceRoot, grantId: 'directory-1', kind: 'workspace' as const, root: spaceRoot },
{ canonicalRoot: scratchRoot, grantId: 'space-1', kind: 'workspace' as const, root: scratchRoot },
],
resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: ['/skills/space/SKILL.md'] },
resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [] },
scratchRoot,
space: {
additionalDirectoryBindings: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('buddy cache warming', () => {
model,
modelRuntime: runtime,
thinkingLevel: 'off',
resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' },
resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' },
inProcessExtensions: [{
name: 'lexora-warm-fixture',
factory(pi) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { InMemoryCredentialStore } from '@earendil-works/pi-ai'
import { ModelRuntime, SessionManager } from '@earendil-works/pi-coding-agent'
import { afterEach, describe, expect, it } from 'vitest'
import { createImageGenerationCapability } from '../../../images/imageGenerationExtension'
import { SkillService } from '../../../skills/SkillService'
import { BuddyDataPaths } from '../../../storage/BuddyDataPaths'
import { openBuddyDatabase } from '../../../storage/database'
import { createSkillRepository } from '../../../storage/skillRepository'
import { createSpaceRepository } from '../../../storage/spaceRepository'
import { createToolDiscoveryCapability } from '../../extensions/discovery/toolDiscoveryExtension'

import { createBuddySession as createPreparedBuddySession } from '../createBuddySession'
Expand Down Expand Up @@ -76,6 +81,21 @@ describe('createBuddySession', () => {
const skillPath = join(directory, 'SKILL.md')
await writeFile(skillPath, '---\nname: sample-workflow\ndescription: Inspect sample workflow inputs\n---\nBODY_ONLY_ON_DEMAND\nSee reference.md when needed.\n')
await writeFile(join(directory, 'reference.md'), 'REFERENCE_ONLY_ON_DEMAND')
const database = openBuddyDatabase({ databasePath: ':memory:' })
const skills = new SkillService({
agentDirectory: join(root, 'agent'),
builtinSkillsDirectories: [join(root, 'skills')],
paths: new BuddyDataPaths(root),
repository: createSkillRepository(database),
spaces: createSpaceRepository(database),
})
const approved = await skills.loadForSpace(null)
await skills.dispose()
database.close()
const outside = join(root, 'outside.md')
await writeFile(outside, '---\nname: sample-workflow\ndescription: OUTSIDE_METADATA\n---\nOUTSIDE_BODY')
await rm(skillPath)
await symlink(outside, skillPath)
const modelRuntime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false })
const model = modelRuntime.getModels()[0]!
const result = await createBuddySession({
Expand All @@ -88,7 +108,7 @@ describe('createBuddySession', () => {
...createRuntimeOptions(),
model,
modelRuntime,
resources: { ...emptyResources(), skillReadRoots: [], skillReferences: [], approvedSkillPaths: [skillPath] },
resources: { ...emptyResources(), skillReadRoots: [], skillReferences: [], approvedSkills: approved.skills },
})
try {
expect(result.session.systemPrompt).toContain('<available_skills>')
Expand All @@ -98,6 +118,7 @@ describe('createBuddySession', () => {
expect(result.session.systemPrompt).toContain('Use the read tool to load a skill')
expect(result.session.systemPrompt).not.toContain('BODY_ONLY_ON_DEMAND')
expect(result.session.systemPrompt).not.toContain('REFERENCE_ONLY_ON_DEMAND')
expect(result.session.systemPrompt).not.toContain('OUTSIDE_METADATA')
}
finally {
await result.shutdown('quit')
Expand Down Expand Up @@ -238,7 +259,7 @@ function emptyResources() {
return {
skillReadRoots: [],
skillReferences: [],
approvedSkillPaths: [],
approvedSkills: [],
context: { agentsFiles: [], diagnostics: [] },
directoryContext: '',
revision: 'empty',
Expand Down
Loading