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
4 changes: 2 additions & 2 deletions apps/buddy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.77",
"@earendil-works/pi-ai": "^0.87.1",
"@earendil-works/pi-coding-agent": "^0.87.1",
"@earendil-works/pi-ai": "^0.99.1",
"@earendil-works/pi-coding-agent": "^0.99.1",
"@js-temporal/polyfill": "^0.5.1",
"@modelcontextprotocol/client": "2.0.0",
"@mozilla/readability": "^0.6.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildBuddyRequestContext } from '../buildBuddyRequestContext'

describe('buildBuddyRequestContext', () => {
it('includes only active first-party guidelines once and does not promote MCP metadata into instructions', () => {
const tools = ['lexora_visible', 'lexora_hidden', 'mcp__service__action'].map(name => ({ name, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level' as const, scope: 'temporary' as const }, promptGuidelines: [`GUIDELINE_${name}`, `GUIDELINE_${name}`] }))
const tools = ['lexora_visible', 'lexora_hidden', 'mcp__service__action'].map(name => ({ name, exposure: 'direct' as const, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level' as const, scope: 'temporary' as const }, promptGuidelines: [`GUIDELINE_${name}`, `GUIDELINE_${name}`] }))
const context = buildBuddyRequestContext({ systemPrompt: 'Buddy base prompt', messages: [], tools: [tools[0]!, tools[2]!] }, tools)
expect(getCurrentSystemPrompt(context.messages).match(/GUIDELINE_lexora_visible/g)).toHaveLength(1)
expect(getCurrentSystemPrompt(context.messages)).not.toContain('GUIDELINE_lexora_hidden')
Expand All @@ -14,7 +14,7 @@ describe('buildBuddyRequestContext', () => {
})

it('preserves transcript history and follows the current tool declarations after removal', () => {
const tools = ['lexora_old', 'lexora_new'].map(name => ({ name, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level' as const, scope: 'temporary' as const }, promptGuidelines: [`GUIDELINE_${name}`] }))
const tools = ['lexora_old', 'lexora_new'].map(name => ({ name, exposure: 'direct' as const, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level' as const, scope: 'temporary' as const }, promptGuidelines: [`GUIDELINE_${name}`] }))
const input = normalizeContext({ messages: [
{ role: 'system', content: 'Buddy base prompt', toolsAdded: [tools[0]!], timestamp: 0 },
{ role: 'user', content: 'Continue', timestamp: 1 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,18 @@ describe('createBuddyToolPresentation', () => {
},
},
})
const error = await tool.execute('tool-1', { command: 'fixture' }).then(
() => { throw new Error('Expected the command to fail') },
error => error as Error,
)
const result = await tool.execute('tool-1', { command: 'fixture' })
expect(result.isError).toBe(true)
expect(result.structuredContent).toMatchObject({ exit_code: 2 })
expect(createBuddyToolPresentation({
arguments: { command: 'fixture' },
isError: true,
result: { content: [{ type: 'text', text: error.message }] },
result,
toolName: 'bash',
})).toMatchObject({ card: 'terminal', exitCode: 2, signal: null })
})

it('projects an expired internal action binding as recoverable without exposing internals', () => {
it('projects an expired action binding as recoverable', () => {
const failure = {
error: {
code: 'SYSTEM_ACTION_EXPIRED',
Expand Down Expand Up @@ -87,7 +86,5 @@ describe('createBuddyToolPresentation', () => {
target: null,
verified: null,
})
expect(JSON.stringify(presentation)).not.toContain('startTicks')
expect(JSON.stringify(presentation)).not.toContain('executable')
})
})
3 changes: 3 additions & 0 deletions apps/buddy/service/src/agent/events/toolPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ function readDiff(value: unknown): string | null {
function readExitCode(value: unknown, isError: boolean | undefined): number | null {
if (value === undefined)
return null
const structured = readRecord(readRecord(value)?.structuredContent)?.exit_code
if (typeof structured === 'number' && Number.isSafeInteger(structured))
return structured
if (isError === false)
return 0
if (isError === undefined)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolResultMessage } from '@earendil-works/pi-ai'
import type { ExtensionContext, ReadToolInput, ReadToolOptions } from '@earendil-works/pi-coding-agent'
import type { ExtensionToolContext, ReadToolInput, ReadToolOptions } from '@earendil-works/pi-coding-agent'
import { Buffer } from 'node:buffer'
import { mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand All @@ -12,7 +12,7 @@ import { createBuddyReadTool } from '../readFileExtension'
function createReadTool(cwd: string, options?: Pick<ReadToolOptions, 'autoResizeImages'>) {
const tool = createBuddyReadTool(cwd, options)
return {
execute: (id: string, parameters: ReadToolInput, signal?: AbortSignal) => tool.execute(id, parameters, signal, undefined, { cwd } as ExtensionContext),
execute: (id: string, parameters: ReadToolInput, signal?: AbortSignal) => tool.execute(id, parameters, signal, undefined, { cwd } as ExtensionToolContext),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Type } from 'typebox'
import { describe, expect, it } from 'vitest'
import { SessionToolCapabilities } from '../SessionToolCapabilities'

const tools: ToolInfo[] = ['read', 'create_image'].map(name => ({ name, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level', scope: 'temporary' } }))
const tools: ToolInfo[] = ['read', 'create_image'].map(name => ({ name, exposure: 'direct' as const, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level', scope: 'temporary' } }))
const model = { provider: 'fixture', id: 'images' } as Model<Api>
function setup() {
const state = new SessionToolCapabilities([{ source: 'connector', id: 'fixture', revision: 'catalog-1' }])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ToolDisclosure } from '../ToolDisclosure'
import { TOOL_SEARCH_NAME } from '../toolDiscoveryContract'

const names = ['read', TOOL_SEARCH_NAME, 'lexora_browser_open', 'lexora_browser_act', 'lexora_buddy_automation', 'lexora_image_generate', 'mcp__calendar__events']
const tools: ToolInfo[] = names.map(name => ({ name, description: name, parameters: Type.Object({ query: Type.String() }), sourceInfo: { source: 'extension', path: '', origin: 'top-level', scope: 'temporary' } }))
const tools: ToolInfo[] = names.map(name => ({ name, exposure: 'direct' as const, description: name, parameters: Type.Object({ query: Type.String() }), sourceInfo: { source: 'extension', path: '', origin: 'top-level', scope: 'temporary' } }))
const model = { id: 'image-model' } as Model<Api>
function create() {
return new ToolDisclosure(tools, names, [
Expand Down Expand Up @@ -64,12 +64,16 @@ describe('toolDisclosure', () => {
expect(disclosure.active({ model })).not.toContain(name)
})

it('does not expose undeclared extensions just because Pi initially activates them', () => {
const disclosure = new ToolDisclosure(tools, names, [])
expect(disclosure.active({ model })).toEqual([TOOL_SEARCH_NAME, 'read'])
expect(disclosure.search({ toolNames: ['lexora_browser_act'] }, { model }).tools).toEqual([])
const deferred = new ToolDisclosure(tools, names, [], () => 'on_demand')
expect(deferred.active({ model })).toEqual([TOOL_SEARCH_NAME])
it('keeps undeclared extensions unavailable regardless of Pi initial activation', () => {
for (const initial of [names, ['read', TOOL_SEARCH_NAME]]) {
const disclosure = new ToolDisclosure(tools, initial, [])
expect(disclosure.active({ model })).toEqual([TOOL_SEARCH_NAME, 'read'])
expect(disclosure.search({ toolNames: ['lexora_browser_act'] }, { model })).toMatchObject({ tools: [], notFound: ['lexora_browser_act'] })
expect(disclosure.search({ query: 'lexora_browser_act' }, { model }).tools.map(tool => tool.name)).not.toContain('lexora_browser_act')
expect(disclosure.active({ model })).toEqual([TOOL_SEARCH_NAME, 'read'])
const deferred = new ToolDisclosure(tools, initial, [], () => 'on_demand')
expect(deferred.active({ model })).toEqual([TOOL_SEARCH_NAME])
}
})

it('keeps catalog ordering independent of registration and search order and rejects ambiguous identities', () => {
Expand All @@ -83,13 +87,6 @@ describe('toolDisclosure', () => {
expect(() => new ToolDisclosure(tools, names, [policy, policy])).toThrow('TOOL_CATALOG_DUPLICATE')
})

it('does not activate registered tools outside the platform baseline or a disclosure policy', () => {
const disclosure = new ToolDisclosure(tools, ['read', TOOL_SEARCH_NAME], [])
expect(disclosure.search({ toolNames: ['lexora_browser_act'] }, { model }).notFound).toEqual(['lexora_browser_act'].sort())
expect(disclosure.search({ query: 'lexora_browser_act' }, { model }).tools.map(tool => tool.name)).not.toContain('lexora_browser_act')
expect(disclosure.active({ model })).toEqual(['read', TOOL_SEARCH_NAME].sort())
})

it('keeps large and external schemas out of new sessions and discovers Chinese capabilities', () => {
const disclosure = create()
expect(disclosure.active({ model })).toEqual(['read', TOOL_SEARCH_NAME].sort())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ExtensionToolContext } from '@earendil-works/pi-coding-agent'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand Down Expand Up @@ -63,7 +64,7 @@ describe('createBuddySession', () => {
{ toolNames: ['lexora_image_generate'] },
undefined,
undefined,
result.session.extensionRunner.createContext(),
result.session.extensionRunner.createContext() as ExtensionToolContext,
)
expect(result.session.getActiveToolNames()).toContain('lexora_image_generate')
await result.session.setModel(model)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('extension agent capability and invocation lifetimes', () => {
const definitions: ToolDefinition[] = []
for (const capability of capabilities)
await capability.extension.factory({ registerTool: (tool: ToolDefinition) => definitions.push(tool), on() {} } as never)
const tools = definitions.map(tool => ({ ...tool, sourceInfo: { source: 'extension' as const, path: '', origin: 'top-level' as const, scope: 'temporary' as const } }))
const tools = definitions.map(tool => ({ ...tool, exposure: tool.exposure ?? 'direct', sourceInfo: { source: 'extension' as const, path: '', origin: 'top-level' as const, scope: 'temporary' as const } }))
const names = definitions.map(tool => tool.name)
const policies = capabilities.flatMap(capability => capability.disclosure!)
const disclosure = new ToolDisclosure(tools, names, policies)
Expand Down
10 changes: 7 additions & 3 deletions apps/buddy/service/src/providers/ProviderService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
AuthType,
LoginOptions,
Provider,
} from '@earendil-works/pi-ai'
import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
Expand Down Expand Up @@ -63,6 +64,7 @@ export interface ProviderModelRuntime extends ProviderModelCatalogRuntime {
type ModelSnapshot = Pick<ProviderModelSnapshotService, 'initialize' | 'getModels' | 'getProviders' | 'getStatus' | 'refresh'> & Partial<Pick<ProviderModelSnapshotService, 'onDidChange' | 'dispose'>>

export interface ProviderServiceOptions {
loginOptions?: LoginOptions
requestHeaders?: ProviderRequestHeaders
createBuiltinSource?: (providerId: string) => Provider | undefined
authInteractions: AuthInteractionService
Expand All @@ -78,6 +80,7 @@ export interface ProviderServiceOptions {
}

export class ProviderService {
readonly #loginOptions: LoginOptions | undefined
readonly #requestHeaders: ProviderRequestHeaders
readonly #builtins: BuiltinProviderConfigRepository
readonly #builtinTemplates: ReadonlyMap<string, Provider>
Expand Down Expand Up @@ -111,6 +114,7 @@ export class ProviderService {
get snapshot() { return this.#state.snapshot }

constructor(options: ProviderServiceOptions) {
this.#loginOptions = options.loginOptions
this.#requestHeaders = options.requestHeaders ?? new ProviderRequestHeaders(options.providers.states)
this.#builtins = options.providers.builtins
this.#builtinTemplates = new Map(options.modelRuntime.getProviders().map(provider => [provider.id, provider]))
Expand Down Expand Up @@ -194,7 +198,7 @@ export class ProviderService {
}

listBuiltinPresets() {
return [...this.#builtinTemplates.values()].map(provider => ({
return [...this.#builtinTemplates.values()].filter(provider => provider.getModels().length > 0 || !provider.getAllModels?.().length).map(provider => ({
id: provider.id,
displayName: provider.name,
baseUrl: provider.baseUrl ?? null,
Expand Down Expand Up @@ -521,7 +525,7 @@ export class ProviderService {
const handle = this.#authInteractions.beginLogin(providerId)
let outcome: 'completed' | 'failed' | 'cancelled' = 'failed'
try {
await this.#modelRuntime.login(providerId, type, handle.interaction)
await this.#modelRuntime.login(providerId, type, handle.interaction, this.#loginOptions)
const instance = this.#builtins.findById(providerId)
if (instance) {
if (provider.refreshModels)
Expand Down Expand Up @@ -664,7 +668,7 @@ export class ProviderService {
id: instance.id,
name: instance.displayName ?? template.name,
source,
getCatalogModels: () => template.getModels(),
getCatalogModels: () => template.getAllModels?.() ?? template.getModels(),
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ describe('built-in model discovery through the production runtime', () => {
let contextWindow = remote.contextWindow
let status = 200
vi.stubGlobal('fetch', async (input: URL | string, init: RequestInit) => {
expect(String(input)).toBe(`https://pi.dev/api/models/providers/${providerId}`)
const url = new URL(input)
expect(`${url.origin}${url.pathname}`).toBe(`https://pi.dev/api/models/providers/${providerId}`)
expect(url.searchParams.get('types')).toBe('chat,image,classifier')
expect(new Headers(init.headers).has('authorization')).toBe(false)
if (status !== 200)
return new Response(null, { status })
Expand Down
Loading