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
36 changes: 30 additions & 6 deletions .playwright/scripts/__tests__/extensionAgent.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ test('agent plugin uses the originating model, persists settings and protects ma
else finish()
return
}
const tool = body.tools.find(tool => tool.function.description.includes('Generate and save a concise title'))
if (tool && body.messages.at(-1).role !== 'tool') {
const tool = body.tools.find(tool => tool.function.name.startsWith('lexora_plugin_') && tool.function.description.includes('Generate and save a concise title'))
const search = body.tools.find(tool => tool.function.name === 'lexora_tool_search')
const last = body.messages.at(-1)
const discovered = last.role === 'tool' && String(last.content).includes('alreadyDisclosed')
if (!tool && search?.function.description.includes('Generate and save a concise title') && last.role !== 'tool') {
send({ tool_calls: [{ index: 0, id: `search-${requests.length}`, type: 'function', function: { name: search.function.name, arguments: JSON.stringify({ query: '生成标题', limit: 1 }) } }] }, 'tool_calls')
}
else if (tool && (last.role !== 'tool' || discovered)) {
send({ tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: { name: tool.function.name, arguments: JSON.stringify({ summary: '整理本周项目计划' }) } }] }, 'tool_calls')
}
else {
Expand Down Expand Up @@ -152,11 +158,19 @@ test('agent plugin uses the originating model, persists settings and protects ma
await settled()
expect(latestTask()).toMatchObject({ title: '生成标题 1', title_source: 'generated' })
expect(requests.filter(body => !body.tools?.length).map(body => body.model)).toEqual(['primary'])
expect(JSON.stringify(requests[0].messages)).toContain('proactively')
const pluginToolName = requests[0].tools.find(tool => tool.function.description.includes('Generate and save a concise title')).function.name
expect(JSON.stringify(requests[0].messages)).not.toContain('proactively')
expect(requests[0].tools.some(tool => tool.function.name.startsWith('lexora_plugin_'))).toBe(false)
expect(requests[0].tools.some(tool => tool.function.name === 'lexora_host_shell')).toBe(false)
expect(requests[0].tools.map(tool => tool.function.name)).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'grep', 'find', 'ls', 'lexora_web_search', 'lexora_web_fetch', 'lexora_output_present', 'lexora_authorize_directory', 'lexora_tool_search']))
expect(requests[0].tools.find(tool => tool.function.name === 'lexora_tool_search').function.description).toContain('生成标题')
const disclosed = requests.find(body => body.tools?.some(tool => tool.function.name.startsWith('lexora_plugin_')))
expect(JSON.stringify(disclosed.messages)).toContain('proactively')
const pluginToolName = disclosed.tools.find(tool => tool.function.description.includes('Generate and save a concise title')).function.name
await expect(page.getByText('生成标题 1', { exact: true }).first()).toBeVisible()
await expect(page.locator('.buddy-chat-tool__title').first()).toHaveText('工具调用')
await expect(page.locator('.buddy-chat-tool__summary').first()).toHaveText('标题自动生成 · 生成标题')
await page.getByRole('button', { name: '搜索 1 次 · 工具调用 1 次', exact: true }).click()
const pluginRow = page.locator('.buddy-chat-tool').filter({ has: page.locator('.buddy-chat-tool__summary', { hasText: '标题自动生成 · 生成标题' }) })
await expect(pluginRow.locator('.buddy-chat-tool__title')).toHaveText('工具调用')
await expect(pluginRow.locator('.buddy-chat-tool__summary')).toHaveText('标题自动生成 · 生成标题')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'plugin-tool-row.png'), animations: 'disabled' })

await settings()
Expand Down Expand Up @@ -207,6 +221,16 @@ test('agent plugin uses the originating model, persists settings and protects ma
await settled()
expect(requests.filter(body => !body.tools?.length)).toHaveLength(beforeProtected)

await instance.stop()
;({ app, page, diagnostics } = await instance.launch())
await syntheticCredentials(app, page)
await taskPage()
const resumedRequests = requests.length
await send('重启后继续同一任务')
await settled()
expect(requests[resumedRequests].tools.some(tool => tool.function.name === pluginToolName)).toBe(true)
expect(requests.filter(body => !body.tools?.length)).toHaveLength(beforeProtected)

await send('取消任务时不写入迟到标题', true)
await expect.poll(() => pending.length).toBe(1)
const beforeCancel = latestTask()
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class DesktopIntegrations {
conditionRuntime: async (input, signal) => extensionConditionSnapshotRpc.response.parse(await service.request(extensionConditionSnapshotRpc.method, input, { signal, timeoutMs: 5000 })),
taskActions: async () => extensionActionRpc.list.response.parse(await service.request(extensionActionRpc.list.method, {})),
invokeTaskAction: async input => extensionActionRpc.invoke.response.parse(await service.request(extensionActionRpc.invoke.method, input, { timeoutMs: null })),
agentChanged: () => service.notify(extensionAgentRpc.changed, {}),
agentChanged: catalog => service.notify(extensionAgentRpc.changed, catalog),
agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: 120000 })),
readText: async (target, signal) => spaceTextDocumentSchema.parse(await service.request('spaceFiles.readDocument', target, { signal })).text,
})
Expand Down
6 changes: 3 additions & 3 deletions apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BrowserWindow, IpcMainEvent } from 'electron'
import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from '../../../shared/extensions/extensionActionApi'
import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent'
import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent'
import type { ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext'
Expand Down Expand Up @@ -29,7 +29,7 @@ export function registerExtensionIpc(options: {
notificationsEnabled?: () => boolean
taskActions: () => Promise<ExtensionTaskAction[]>
invokeTaskAction: (input: ExtensionTaskActionInput) => Promise<ExtensionTaskActionResult>
agentChanged?: () => void
agentChanged?: (catalog: ExtensionAgentCatalog) => void
record?: ApplicationDiagnosticReporter
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise<ExtensionConditionRuntime>
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise<JsonValue>
Expand Down Expand Up @@ -135,7 +135,7 @@ export function registerExtensionIpc(options: {
}),
service.onDidChange((change) => {
if (change.kind === 'contributions' && !change.initial)
options.agentChanged?.()
options.agentChanged?.(change.descriptors.map(({ id, revision, configurationRevision }) => ({ id, revision, configurationRevision })))
}),
observeExtensionDiagnostics(service, event => options.record?.(event)),
]
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/service/src/agent/extensions/BuddyCapability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { BuddyInProcessExtension } from './BuddyInProcessExtension'
import type { BuddyToolDisclosurePolicy } from './discovery/toolDiscoveryContract'

export interface BuddyCapabilityResourceRevision {
readonly source: 'connector'
readonly source: 'connector' | 'plugin'
readonly id: string
readonly revision: string
}
Expand All @@ -19,7 +19,7 @@ export interface BuddyCapability {
classify: (event: ToolCallEvent, signal: AbortSignal) =>
BuddyToolClassificationResult | null | Promise<BuddyToolClassificationResult | null>
workspaceMutationTools?: readonly string[]
disclosure?: BuddyToolDisclosurePolicy
disclosure?: readonly BuddyToolDisclosurePolicy[]
}

export interface BuddyCapabilityContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ function createCompositionServices(
browserHost,
connectorService: {
getTools() {
return { classifications: new Map(), diagnostics: [], tools: [], available: () => false }
return { classifications: new Map(), diagnostics: [], tools: [], disclosure: [], available: () => false }
},
},
} as unknown as BuddyCapabilityServices, new PetActionService({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { BuddyInputReferenceStore } from '../context/BuddyInputReference'
import type { BuddyCapability, BuddyCapabilityFactory, BuddyCapabilityResourceRevision } from './BuddyCapability'
import type { BuddyExtensionRunContextStore } from './BuddyExtensionRunContext'
import type { BuddyInProcessExtension } from './BuddyInProcessExtension'
import type { BuddyToolExposureResolver } from './discovery/toolDiscoveryContract'
import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot'
import { SessionDirectoryGrants } from '../../directories/SessionDirectoryGrants'
import { observeSessionPermissions } from '../../permissions/observeSessionPermissions'
Expand All @@ -38,6 +39,7 @@ export interface BuddySessionExtensionServices {
createCapabilities: BuddyCapabilityFactory
directoryGrants: Pick<DirectoryGrantService, 'grant' | 'assertCurrent'>
recordPermissions?: ApplicationDiagnosticReporter
resolveToolExposure?: BuddyToolExposureResolver
shellSandbox?: Pick<ShellSandboxClient, 'exec'>
}

Expand Down Expand Up @@ -147,9 +149,9 @@ export async function createBuddySessionExtensions(
}
creationSignal.throwIfAborted()
const resourceRevisions = copyEventSnapshot(capabilities.flatMap(capability => capability.resourceRevisions ?? []))
toolCapabilities = new SessionToolCapabilities(resourceRevisions)
toolCapabilities = new SessionToolCapabilities(resourceRevisions, services.resolveToolExposure)
stopToolDiagnostics = observeSessionTools(toolCapabilities, options.conversationId, () => runContext.current?.runId, services.recordPermissions)
const discovery = createToolDiscoveryCapability(capabilities.flatMap(capability => capability.disclosure ? [capability.disclosure] : []), toolCapabilities)
const discovery = createToolDiscoveryCapability(capabilities.flatMap(capability => capability.disclosure ?? []), toolCapabilities)
const sessionCapabilities = [...capabilities, discovery]
const inProcessExtensions: BuddyInProcessExtension[] = [
createInputReferenceExtension(inputReferences),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Api, Message, Model } from '@earendil-works/pi-ai'
import type { Message } from '@earendil-works/pi-ai'
import type { ToolInfo } from '@earendil-works/pi-coding-agent'
import type { BuddyCapabilityResourceRevision } from '../BuddyCapability'
import type { BuddyToolDisclosurePolicy, ToolSearchInput } from './toolDiscoveryContract'
import type { ToolDisclosureResolution } from './ToolDisclosure'
import type { BuddyToolDisclosurePolicy, BuddyToolExposureContext, BuddyToolExposureResolver, ToolSearchInput } from './toolDiscoveryContract'
import type { ToolDiscoveryState } from './toolDiscoveryState'
import { randomUUID } from 'node:crypto'
import { Emitter } from '../../../../../shared/events/Emitter'
import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot'
Expand All @@ -14,6 +16,7 @@ export interface SessionToolSnapshot {
readonly disclosureRevision: number
readonly resources: readonly BuddyCapabilityResourceRevision[]
readonly discovered: readonly string[]
readonly direct: readonly string[]
readonly active: readonly string[]
readonly model: { readonly provider: string, readonly id: string } | null
readonly status: 'initializing' | 'ready' | 'degraded' | 'disposed'
Expand All @@ -28,57 +31,65 @@ export class SessionToolCapabilities {
readonly onDidChange = this.#changes.event
readonly #instanceId = randomUUID()
readonly #resources: readonly BuddyCapabilityResourceRevision[]
readonly #resolveExposure: BuddyToolExposureResolver | undefined
#disclosure?: ToolDisclosure
#revision = 0
#active: readonly string[] = Object.freeze([])
#direct: readonly string[] = Object.freeze([])
#model: SessionToolSnapshot['model'] = null
#status: SessionToolSnapshot['status'] = 'initializing'

constructor(resources: readonly BuddyCapabilityResourceRevision[] = []) {
constructor(resources: readonly BuddyCapabilityResourceRevision[] = [], resolveExposure?: BuddyToolExposureResolver) {
this.#resources = copyEventSnapshot(resources)
this.#resolveExposure = resolveExposure
}

get snapshot(): SessionToolSnapshot {
return Object.freeze({ instanceId: this.#instanceId, revision: this.#revision, resources: this.#resources, disclosureRevision: this.#disclosure?.snapshot.revision ?? 0, discovered: this.#disclosure?.snapshot.discovered ?? Object.freeze([]), active: this.#active, model: this.#model, status: this.#status })
return Object.freeze({ instanceId: this.#instanceId, revision: this.#revision, resources: this.#resources, disclosureRevision: this.#disclosure?.snapshot.revision ?? 0, discovered: this.#disclosure?.snapshot.discovered ?? Object.freeze([]), direct: this.#direct, active: this.#active, model: this.#model, status: this.#status })
}

initialize(tools: readonly ToolInfo[], resident: readonly string[], policies: readonly BuddyToolDisclosurePolicy[]): void {
get persistedState(): ToolDiscoveryState { return this.#disclosure?.persistedState ?? { version: 1, discovered: [] } }

initialize(tools: readonly ToolInfo[], baseline: readonly string[], policies: readonly BuddyToolDisclosurePolicy[]): void {
this.#assertCurrent()
this.#disclosure?.dispose()
this.#disclosure = new ToolDisclosure(tools, resident, policies)
this.#disclosure = new ToolDisclosure(tools, baseline, policies, this.#resolveExposure)
this.#disclosure.onDidChange(event => this.#publish('disclosure-changed', event.reason))
this.#status = 'initializing'
this.#publish('catalog-accepted', 'initial')
}

search(input: ToolSearchInput, model: Model<Api> | undefined) {
search(input: ToolSearchInput, context: BuddyToolExposureContext) {
this.#assertCurrent()
if (!this.#disclosure)
throw new Error('SESSION_TOOLS_NOT_INITIALIZED')
return this.#disclosure.search(input, model)
return this.#disclosure.search(input, context)
}

restore(messages: readonly Message[]): void {
restore(messages: readonly Message[], state?: ToolDiscoveryState): void {
this.#assertCurrent()
this.#disclosure?.restore(messages)
this.#disclosure?.restore(messages, state)
}

connectedTools(model: Model<Api> | undefined) {
return this.#disclosure?.connectedTools(model) ?? []
resolve(context: BuddyToolExposureContext): ToolDisclosureResolution {
this.#assertCurrent()
if (!this.#disclosure)
throw new Error('SESSION_TOOLS_NOT_INITIALIZED')
return this.#disclosure.resolve(context)
}

apply(model: Model<Api> | undefined, reason: ActiveToolReason, adapter: { getActiveTools: () => string[], setActiveTools: (tools: string[]) => void }): void {
apply(resolution: ToolDisclosureResolution, reason: ActiveToolReason, adapter: { getActiveTools: () => string[], setActiveTools: (tools: string[]) => void }): void {
this.#assertCurrent()
if (!this.#disclosure)
return
const desired = this.#disclosure.active(model)
const nextModel = model ? Object.freeze({ provider: model.provider, id: model.id }) : null
const { active: desired, direct, model: nextModel } = resolution
try {
if (JSON.stringify(adapter.getActiveTools()) !== JSON.stringify(desired))
adapter.setActiveTools(desired)
const actual = Object.freeze([...adapter.getActiveTools()])
const changed = this.#status !== 'ready' || JSON.stringify(actual) !== JSON.stringify(this.#active) || JSON.stringify(nextModel) !== JSON.stringify(this.#model)
if (JSON.stringify(actual) !== JSON.stringify(desired))
throw new Error('SESSION_TOOLS_APPLICATION_MISMATCH')
const changed = this.#status !== 'ready' || JSON.stringify(actual) !== JSON.stringify(this.#active) || JSON.stringify(direct) !== JSON.stringify(this.#direct) || JSON.stringify(nextModel) !== JSON.stringify(this.#model)
this.#active = actual
this.#direct = Object.freeze(direct)
this.#model = nextModel
this.#status = 'ready'
if (changed)
Expand Down
Loading