feat: add external knowledge retrieval and cross-project activity
Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
This commit is contained in:
@@ -2872,6 +2872,82 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
).not.toHaveProperty('modelBridge')
|
||||
})
|
||||
|
||||
it('restores live questions after transcript ACK, scopes replies and ignores answered history', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const reply = vi.fn<NonNullable<RemoteRuntimeChannel['respondToQuestion']>>()
|
||||
.mockRejectedValueOnce(new Error('temporary response failure'))
|
||||
.mockResolvedValue(undefined)
|
||||
fixture.channel.respondToQuestion = reply
|
||||
let answered = false
|
||||
let pages = 0
|
||||
const question = { sessionId: 'owned-session', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'native-question', _meta: {
|
||||
goodbuddyQuestion: { question: { id: 'native-question', sessionID: 'owned-session',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [] }] } }
|
||||
}
|
||||
} }
|
||||
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
|
||||
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
|
||||
latestSequence: answered ? '2' : '1', acknowledgedSequence: '1', hasMore: false,
|
||||
pendingQuestions: answered ? [] : [question],
|
||||
events: answered ? [
|
||||
{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 },
|
||||
{ sequence: '2', kind: 'prompt-terminal', payload: { status: 'completed', response: { stopReason: 'end_turn' } }, createdAt: 2 }
|
||||
] : (++pages === 1 ? [] : [{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 }])
|
||||
})
|
||||
let id: string | undefined
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
if (event.type !== 'question') continue
|
||||
expect(id).toBeUndefined()
|
||||
id = event.questionId
|
||||
expect(id).not.toBe('native-question')
|
||||
expect(event.questions[0]).toMatchObject({ custom: true, multiple: false })
|
||||
await expect(fixture.instance.respondToQuestion(id, [['A']])).rejects.toThrow('temporary response failure')
|
||||
await fixture.instance.respondToQuestion(id, [['A']])
|
||||
answered = true
|
||||
}
|
||||
expect(id).toBeDefined()
|
||||
expect(reply).toHaveBeenLastCalledWith({ bindingId: expect.any(String), operationId: request.requestId,
|
||||
questionId: 'native-question', answers: [['A']] })
|
||||
await expect(fixture.instance.respondToQuestion(id!, [['late']])).rejects.toThrow('no longer pending')
|
||||
expect(reply).toHaveBeenCalledTimes(2)
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('drops reply mappings when the Agent resolves a question while the prompt remains active', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const reply = vi.fn(async () => undefined)
|
||||
fixture.channel.respondToQuestion = reply
|
||||
let pages = 0
|
||||
let answered = false
|
||||
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
|
||||
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
|
||||
latestSequence: answered ? '1' : '0', acknowledgedSequence: '0', hasMore: false,
|
||||
pendingQuestions: answered ? [] : [{ sessionId: 'owned-session', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'question', _meta: { goodbuddyQuestion: {
|
||||
question: { id: ++pages === 1 ? 'first' : 'second', sessionID: 'owned-session',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [] }] }
|
||||
} }
|
||||
} }],
|
||||
events: answered ? [{ sequence: '1', kind: 'prompt-terminal', createdAt: 1,
|
||||
payload: { status: 'completed', response: { stopReason: 'end_turn' } } }] : []
|
||||
})
|
||||
let previous: string | undefined
|
||||
const resolved: string[] = []
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
if (event.type === 'question-resolved') resolved.push(event.questionId)
|
||||
if (event.type !== 'question') continue
|
||||
if (!previous) { previous = event.questionId; continue }
|
||||
await expect(fixture.instance.respondToQuestion(previous, [['stale']])).rejects.toThrow('no longer pending')
|
||||
expect(reply).not.toHaveBeenCalled()
|
||||
await fixture.instance.respondToQuestion(event.questionId)
|
||||
answered = true
|
||||
}
|
||||
expect(reply).toHaveBeenCalledOnce()
|
||||
expect(resolved).toEqual([previous])
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('freshly attaches an accepted prompt without resending it', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const first = fixture.instance.run(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
|
||||
import type { AgentQuestionAnswer } from '../../shared/contracts'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -137,6 +139,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
reportedQuestions?: Set<string>
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
requestId: string
|
||||
operationId: string
|
||||
@@ -352,6 +355,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
Promise<SessionRecord>
|
||||
>()
|
||||
private readonly activePrompts = new Map<string, ActivePrompt>()
|
||||
private readonly pendingQuestions = new Map<string, { prompt: ActivePrompt; nativeId: string }>()
|
||||
private readonly sessionReservations = new Set<string>()
|
||||
private readonly drainWaiters = new Set<() => void>()
|
||||
private readonly contexts = new Map<string, ChannelContext>()
|
||||
@@ -2070,6 +2074,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt: ActivePrompt,
|
||||
update: SessionUpdate
|
||||
): RuntimePublicEvent | undefined {
|
||||
const extension = update._meta?.goodbuddyQuestion
|
||||
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
|
||||
prompt.open && prompt.context.channel.respondToQuestion) {
|
||||
const value = extension as { question?: unknown; childCallId?: unknown }
|
||||
const question = remoteQuestionSchema.parse(value.question)
|
||||
const questionId = JSON.stringify([prompt.bindingId, prompt.operationId, question.id])
|
||||
prompt.reportedQuestions ??= new Set()
|
||||
if (prompt.reportedQuestions.has(questionId)) return undefined
|
||||
prompt.reportedQuestions.add(questionId)
|
||||
this.pendingQuestions.set(questionId, { prompt, nativeId: question.id })
|
||||
const childTaskId = typeof value.childCallId === 'string'
|
||||
? prompt.toolCalls.get(value.childCallId)?.retainedSubagent?.childTaskId : undefined
|
||||
return { requestId: prompt.requestId, type: 'question', questionId,
|
||||
questions: question.questions, ...(childTaskId ? { childTaskId } : {}) }
|
||||
}
|
||||
if (this.options.runtimeId === 'opencode') {
|
||||
if (!prompt.subagentProgress) {
|
||||
prompt.subagentProgress = new OpenCodeSubagentProgress(
|
||||
@@ -2295,6 +2314,25 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async respondToQuestion(questionId: string, answers: AgentQuestionAnswer[] = []): Promise<void> {
|
||||
const pending = this.pendingQuestions.get(questionId)
|
||||
if (!pending || !pending.prompt.open || !pending.prompt.context.channel.respondToQuestion) {
|
||||
throw new Error('Remote question is no longer pending')
|
||||
}
|
||||
const { prompt, nativeId } = pending
|
||||
this.assertUsable(prompt.context, true)
|
||||
await this.awaitOperation(prompt.context, '回答问题', prompt.context.channel.respondToQuestion!({
|
||||
bindingId: prompt.bindingId, operationId: prompt.operationId, questionId: nativeId, answers
|
||||
}), undefined, false)
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
|
||||
private clearPromptQuestions(prompt: ActivePrompt): void {
|
||||
for (const [id, pending] of this.pendingQuestions) {
|
||||
if (pending.prompt === prompt) this.pendingQuestions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private withRemoteProvenance(
|
||||
event: RuntimePublicEvent,
|
||||
bindingId: string,
|
||||
@@ -2553,7 +2591,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
transcriptEvent.payload,
|
||||
session.sessionId
|
||||
)
|
||||
const mapped = this.mapUpdate(prompt, notification.update)
|
||||
const mapped = page.pendingQuestions !== undefined && notification.update._meta?.goodbuddyQuestion
|
||||
? undefined : this.mapUpdate(prompt, notification.update)
|
||||
if (mapped !== undefined) {
|
||||
publicEvents.push(this.limitEvent(mapped))
|
||||
}
|
||||
@@ -2672,6 +2711,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
transcriptPollDelayMs = 100
|
||||
continue
|
||||
}
|
||||
const pendingNotifications = (page.pendingQuestions ?? []).map(question =>
|
||||
transcriptSessionNotification(question, session.sessionId))
|
||||
if (page.pendingQuestions !== undefined) {
|
||||
const liveIds = new Set(pendingNotifications.map(notification => remoteQuestionSchema.parse(
|
||||
(notification.update._meta?.goodbuddyQuestion as { question?: unknown } | undefined)?.question
|
||||
).id))
|
||||
for (const [id, pending] of this.pendingQuestions) {
|
||||
if (pending.prompt === prompt && !liveIds.has(pending.nativeId)) {
|
||||
this.pendingQuestions.delete(id)
|
||||
yield { requestId: prompt.requestId, type: 'question-resolved', questionId: id }
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const notification of pendingNotifications) {
|
||||
const event = this.mapUpdate(prompt, notification.update)
|
||||
if (event?.type === 'question') yield event
|
||||
}
|
||||
if (
|
||||
page.state === 'completed' ||
|
||||
page.state === 'failed' ||
|
||||
@@ -2699,6 +2755,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
}
|
||||
signal.removeEventListener('abort', cancel)
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
this.activePrompts.delete(binding.bindingId)
|
||||
}
|
||||
}
|
||||
@@ -3043,6 +3100,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
session.binding = binding
|
||||
}
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
prompt.interrupt = undefined
|
||||
prompt.wake?.()
|
||||
prompt.wake = undefined
|
||||
@@ -3251,6 +3309,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
new Error('远端 Runtime 已关闭,活动请求结果需要核对')
|
||||
)
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
prompt.wake?.()
|
||||
this.discardUpdates(prompt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { execFile, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { rgPath } from '@vscode/ripgrep'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep'
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>()
|
||||
const mocked = { ...actual, execFile: vi.fn(actual.execFile) }
|
||||
return { ...mocked, default: mocked }
|
||||
})
|
||||
|
||||
const input: WorkspaceRipgrepInput = {
|
||||
path: '.', pattern: 'target', glob: [], fixedStrings: false,
|
||||
ignoreCase: false, filesOnly: false, maxResults: 100
|
||||
}
|
||||
const match = JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: './valid.txt' }, lines: { text: 'target\n' },
|
||||
line_number: 1, submatches: [{ start: 0 }]
|
||||
}
|
||||
}) + '\n'
|
||||
const accessError = 'blocked: Permission denied (os error 13)'
|
||||
const directories: string[] = []
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
|
||||
|
||||
function mockResult(code: number | string, stdout: string, stderr: string) {
|
||||
const error = Object.assign(new Error(`process failed: ${code}`), { code })
|
||||
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
|
||||
if (file !== rgPath) return actual.execFile(file, args, options, callback)
|
||||
const complete = callback as (error: Error, stdout: string, stderr: string) => void
|
||||
complete(error, stdout, stderr)
|
||||
return {} as ChildProcess
|
||||
})
|
||||
return error
|
||||
}
|
||||
|
||||
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
|
||||
return searchWorkspaceWithRipgrep(
|
||||
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(execFile).mockReset()
|
||||
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('searchWorkspaceWithRipgrep', () => {
|
||||
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => {
|
||||
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000))
|
||||
const result = await search()
|
||||
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath)
|
||||
expect(result).toContain('valid.txt:1:1:target\\n')
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('missing results do not establish absence')
|
||||
expect(result).toContain(accessError)
|
||||
expect(result.length).toBeLessThan(2_500)
|
||||
})
|
||||
|
||||
it('preserves file listings and existing result truncation', async () => {
|
||||
mockResult(2, './valid.txt\0./other.txt\0', accessError)
|
||||
const result = await search({ filesOnly: true, maxResults: 1 })
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]')
|
||||
expect(result).not.toContain('other.txt')
|
||||
})
|
||||
|
||||
it('qualifies a no-match summary when traversal was incomplete', async () => {
|
||||
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError)
|
||||
const result = await search()
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('[no matches found in searched files]')
|
||||
expect(result).not.toContain('[no matches found]')
|
||||
})
|
||||
|
||||
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => {
|
||||
mockResult(2, '', accessError)
|
||||
await expect(search({ filesOnly })).rejects.toThrow(accessError)
|
||||
})
|
||||
|
||||
it('does not turn invalid regex diagnostics into successful partial output', async () => {
|
||||
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class')
|
||||
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class')
|
||||
})
|
||||
|
||||
it('keeps launch failures as errors even with stdout', async () => {
|
||||
const error = mockResult('ENOENT', match, '')
|
||||
await expect(search()).rejects.toMatchObject({ cause: error })
|
||||
})
|
||||
|
||||
it('preserves the cancellation reason even when partial stdout is available', async () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel search')
|
||||
controller.abort(reason)
|
||||
mockResult(2, match, accessError)
|
||||
await expect(search({}, controller.signal)).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('keeps exit 1 as a normal no-match result', async () => {
|
||||
mockResult(1, '', '')
|
||||
await expect(search()).resolves.toBe('[no matches found]')
|
||||
})
|
||||
|
||||
it('preserves capture-limit truncation', async () => {
|
||||
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
|
||||
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
|
||||
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
|
||||
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
|
||||
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
|
||||
directories.push(directory)
|
||||
await writeFile(join(directory, 'valid.txt'), 'target\n')
|
||||
// Add a second target at the process boundary: the public API stats its single target first.
|
||||
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
|
||||
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
|
||||
)
|
||||
const result = await searchWorkspaceWithRipgrep(
|
||||
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
|
||||
new LocalWorkspaceAccess(directory), new AbortController().signal
|
||||
)
|
||||
expect(result).toContain(expected)
|
||||
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
|
||||
expect(result).toContain('missing.txt')
|
||||
})
|
||||
|
||||
it('rejects an invalid regex using the real bundled rg', async () => {
|
||||
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,7 @@ function runRipgrep(
|
||||
if (
|
||||
processError &&
|
||||
processError.code !== 1 &&
|
||||
processError.code !== 2 &&
|
||||
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
) {
|
||||
reject(
|
||||
@@ -225,7 +226,17 @@ export async function searchWorkspaceWithRipgrep(
|
||||
identity.canonicalDisplayPath,
|
||||
signal
|
||||
)
|
||||
return input.filesOnly
|
||||
const incomplete = result.code === 2
|
||||
if (
|
||||
incomplete &&
|
||||
(!result.stdout.trim() ||
|
||||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
|
||||
) {
|
||||
throw new Error(
|
||||
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
|
||||
)
|
||||
}
|
||||
const formatted = input.filesOnly
|
||||
? formatFileResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
@@ -236,4 +247,13 @@ export async function searchWorkspaceWithRipgrep(
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
if (incomplete) {
|
||||
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
|
||||
const details = result.stderr.trim().slice(0, 2_000)
|
||||
const partial = formatted
|
||||
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
|
||||
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
|
||||
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -29,32 +29,22 @@ const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function createService() {
|
||||
const searchHybridMany = vi.fn(
|
||||
const retrieveMany = vi.fn(
|
||||
async (libraryIds: readonly string[]) =>
|
||||
libraryIds.map((knowledgeBaseId, index) => ({
|
||||
knowledgeBaseId,
|
||||
result: {
|
||||
document: {
|
||||
id: `33333333-3333-4333-8333-33333333333${index}`,
|
||||
title: `文档 ${index}`
|
||||
},
|
||||
source: {
|
||||
displayName: `来源 ${index}`,
|
||||
location: `/private/${index}`
|
||||
},
|
||||
chunk: {
|
||||
id: `44444444-4444-4444-8444-44444444444${index}`,
|
||||
location: `第 ${index + 1} 段`
|
||||
},
|
||||
response: { diagnostics: {}, results: [{
|
||||
knowledgeBaseId,
|
||||
documentId: `33333333-3333-4333-8333-33333333333${index}`,
|
||||
documentTitle: `文档 ${index}`,
|
||||
sourceDisplayName: `来源 ${index}`,
|
||||
chunkId: `44444444-4444-4444-8444-444444444440`,
|
||||
location: `第 ${index + 1} 段`,
|
||||
snippet: `<mark>匹配</mark> ${index}`,
|
||||
rank: index + 1,
|
||||
retrieval: {
|
||||
score: 0.5,
|
||||
channels: ['fts'] as const,
|
||||
lexicalRank: 1,
|
||||
evidenceIds: []
|
||||
}
|
||||
}
|
||||
channels: ['fts'] as const,
|
||||
scores: { fusedScore: 0.5, ftsRank: 1 }
|
||||
}] }
|
||||
}))
|
||||
)
|
||||
const service = {
|
||||
@@ -72,9 +62,9 @@ function createService() {
|
||||
}
|
||||
]
|
||||
},
|
||||
searchHybridMany
|
||||
retrieveMany
|
||||
} as unknown as KnowledgeService
|
||||
return { service, searchHybridMany }
|
||||
return { service, retrieveMany }
|
||||
}
|
||||
|
||||
function customMcpServer(
|
||||
@@ -189,24 +179,19 @@ function testTool(name: string): Tool {
|
||||
const gateways: KnowledgeMcpGateway[] = []
|
||||
|
||||
it('honors a requested knowledge result count above eight', async () => {
|
||||
const { service, searchHybridMany } = createService()
|
||||
const [sample] = await searchHybridMany([firstLibraryId])
|
||||
searchHybridMany.mockClear()
|
||||
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({
|
||||
...sample!,
|
||||
result: {
|
||||
...sample!.result,
|
||||
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
|
||||
rank: index + 1
|
||||
}
|
||||
})))
|
||||
const { service, retrieveMany } = createService()
|
||||
const [sample] = await retrieveMany([firstLibraryId])
|
||||
retrieveMany.mockClear()
|
||||
retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({
|
||||
...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1
|
||||
})) } }])
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
const controller = new AbortController()
|
||||
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
|
||||
const references = await gateway.search(token, { query: 'evidence', limit: 12 })
|
||||
expect(references).toHaveLength(12)
|
||||
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal)
|
||||
expect(retrieveMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', expect.any(AbortSignal))
|
||||
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
|
||||
})
|
||||
const databases: AssistantDatabase[] = []
|
||||
@@ -621,7 +606,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
|
||||
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
|
||||
const { service, searchHybridMany } = createService()
|
||||
const { service, retrieveMany } = createService()
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
const token = gateway.grant(
|
||||
@@ -652,10 +637,9 @@ describe('KnowledgeMcpGateway', () => {
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(searchHybridMany).toHaveBeenCalledWith(
|
||||
expect(retrieveMany).toHaveBeenCalledWith(
|
||||
[secondLibraryId],
|
||||
'要找什么',
|
||||
1,
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(references).toEqual([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
@@ -24,7 +25,6 @@ import type {
|
||||
BrowserTabSummary,
|
||||
KnowledgeSearchReference
|
||||
} from '../../shared/contracts'
|
||||
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||
import {
|
||||
knowledgeToolNames,
|
||||
knowledgeScopedDataToolCatalog,
|
||||
@@ -273,13 +273,7 @@ function textContent(value: string): MagicNoteRichContent {
|
||||
}
|
||||
|
||||
function referenceKey(reference: KnowledgeSearchReference): string {
|
||||
return [
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? '',
|
||||
reference.snippet
|
||||
].join('\0')
|
||||
return knowledgeReferenceKey(reference)
|
||||
}
|
||||
|
||||
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
|
||||
@@ -681,20 +675,22 @@ export class KnowledgeMcpGateway {
|
||||
const { query, limit } = knowledgeSearchTool.inputSchema.parse(
|
||||
input
|
||||
)
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, capability.signal])
|
||||
: capability.signal
|
||||
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
|
||||
effectiveSignal.throwIfAborted()
|
||||
const libraries = this.knowledgeService.database.listKnowledgeBases(500)
|
||||
const libraryNames = new Map(
|
||||
libraries.map((library) => [library.id, library.name])
|
||||
)
|
||||
const results = await this.knowledgeService.searchHybridMany(
|
||||
const outcomes = await this.knowledgeService.retrieveMany(
|
||||
capability.libraryIds,
|
||||
query,
|
||||
limit,
|
||||
effectiveSignal
|
||||
)
|
||||
effectiveSignal.throwIfAborted()
|
||||
const warnings = outcomes.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
||||
if (outcomes.length && warnings.length === outcomes.length) throw new Error(warnings.join('; '))
|
||||
const results = outcomes.flatMap(item => item.response.results.map(result => ({ knowledgeBaseId: item.knowledgeBaseId, result })))
|
||||
if (!results.length && warnings.length) throw new Error(warnings.join('; '))
|
||||
const references: KnowledgeSearchReference[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const { knowledgeBaseId, result } of results.sort(
|
||||
@@ -704,22 +700,8 @@ export class KnowledgeMcpGateway {
|
||||
break
|
||||
}
|
||||
const reference: KnowledgeSearchReference = {
|
||||
libraryId: knowledgeBaseId,
|
||||
libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库',
|
||||
documentId: result.document.id,
|
||||
chunkId: result.chunk.id,
|
||||
documentName: result.document.title.slice(0, 500),
|
||||
sourceName: result.source.displayName.slice(0, 500),
|
||||
locator: result.chunk.location?.slice(0, 1_000),
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 12_000),
|
||||
rank: result.rank,
|
||||
score: result.retrieval.score,
|
||||
lexicalRank: result.retrieval.lexicalRank,
|
||||
vectorRank: result.retrieval.vectorRank,
|
||||
graphRank: result.retrieval.graphRank,
|
||||
similarity: result.retrieval.similarity,
|
||||
retrievalChannels: result.retrieval.channels,
|
||||
evidenceIds: result.retrieval.evidenceIds?.slice(0, 100)
|
||||
...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'),
|
||||
...(warnings.length ? { warnings } : {})
|
||||
}
|
||||
const key = referenceKey(reference)
|
||||
if (seen.has(key)) {
|
||||
@@ -1473,13 +1455,14 @@ export class KnowledgeMcpGateway {
|
||||
private async callScopedTool(
|
||||
token: string,
|
||||
name: ScopedDataToolName,
|
||||
input: unknown
|
||||
input: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (name) {
|
||||
case 'knowledge_list':
|
||||
return { libraries: this.listLibraries(token, input) }
|
||||
case 'knowledge_search':
|
||||
return { references: await this.search(token, input) }
|
||||
return { references: await this.search(token, input, signal) }
|
||||
case 'note_list':
|
||||
return { notes: this.listMagicNotes(token, input) }
|
||||
case 'note_get':
|
||||
@@ -1641,7 +1624,8 @@ export class KnowledgeMcpGateway {
|
||||
await this.callScopedTool(
|
||||
token,
|
||||
name as ScopedDataToolName,
|
||||
parsedInput
|
||||
parsedInput,
|
||||
extra.signal
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4084,6 +4084,104 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('recovers an absolute workspace read by retrying a relative path against the real filesystem', async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-read-recovery-'))
|
||||
const workspaceAccess = new LocalWorkspaceAccess(workspace)
|
||||
const toolProvider = new ModelToolProvider(workspaceAccess, [])
|
||||
const absolutePath = join(workspace, 'README.md')
|
||||
const marker = `read-recovery-${crypto.randomUUID()}`
|
||||
const responses = [absolutePath, 'README.md'].map((path, index) => ({
|
||||
choices: [{ message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [{
|
||||
id: `read-${index}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: JSON.stringify({ path })
|
||||
}
|
||||
}]
|
||||
} }]
|
||||
}))
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
const response = responses.shift()
|
||||
return Response.json(response ?? {
|
||||
choices: [{ message: { role: 'assistant', content: 'Read completed.' } }]
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1',
|
||||
model: 'test-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider,
|
||||
workspaceAccess
|
||||
})
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
await writeFile(absolutePath, `${marker}\n`)
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation-workspace-read-recovery',
|
||||
prompt: 'Read README.md.',
|
||||
workMode: 'ask'
|
||||
}, new AbortController().signal, async () => 'deny')) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||||
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as {
|
||||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||||
}
|
||||
const correction = secondBody.messages.filter((message) => message.role === 'tool')
|
||||
expect(correction).toHaveLength(1)
|
||||
expect(correction[0]).toMatchObject({ tool_call_id: 'read-0' })
|
||||
expect(JSON.parse(correction[0]!.content)).toEqual({
|
||||
ok: false,
|
||||
recoverable: true,
|
||||
error: expect.stringMatching(/^workspace_read_text: [\s\S]*路径必须相对于工作区/u),
|
||||
nextAction: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed. Do not retry identical arguments.'
|
||||
})
|
||||
expect(correction[0]!.content).not.toContain(marker)
|
||||
|
||||
const thirdBody = JSON.parse(String(fetcher.mock.calls[2]![1]!.body)) as {
|
||||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||||
}
|
||||
expect(thirdBody.messages.filter((message) => message.role === 'tool')).toEqual([
|
||||
correction[0],
|
||||
expect.objectContaining({
|
||||
tool_call_id: 'read-1',
|
||||
content: expect.stringContaining(marker)
|
||||
})
|
||||
])
|
||||
const toolEvents = events.filter((event) => event.type === 'tool')
|
||||
expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([
|
||||
['read-0', 'pending'],
|
||||
['read-0', 'running'],
|
||||
['read-0', 'recoverable'],
|
||||
['read-1', 'pending'],
|
||||
['read-1', 'running'],
|
||||
['read-1', 'completed']
|
||||
])
|
||||
expect(JSON.parse(toolEvents[0]!.input!)).toEqual({ path: absolutePath })
|
||||
expect(toolEvents[2]).toMatchObject({
|
||||
error: expect.stringContaining('路径必须相对于工作区')
|
||||
})
|
||||
expect(JSON.parse(toolEvents[3]!.input!)).toEqual({ path: 'README.md' })
|
||||
expect(toolEvents[5]).toMatchObject({ output: expect.stringContaining(marker) })
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'text', delta: 'Read completed.'
|
||||
}))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
|
||||
const streams = [
|
||||
createSseEventStream([
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('../capabilities/mcp-client-transport', () => ({
|
||||
|
||||
import {
|
||||
ModelToolProvider,
|
||||
RecoverableModelToolError,
|
||||
type ModelToolCallContext,
|
||||
type ModelSubagentRequestContext,
|
||||
type ModelToolResult
|
||||
@@ -461,6 +462,158 @@ describe('ModelToolProvider', () => {
|
||||
).resolves.toBe('saved')
|
||||
})
|
||||
|
||||
it('recovers an absolute local path and reads the corrected relative path', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
await writeFile(join(workspace, 'note.txt'), 'corrected read')
|
||||
const provider = new ModelToolProvider(workspace)
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
await expect(provider.callTool('workspace_read_text', {
|
||||
path: join(workspace, 'note.txt')
|
||||
}, signal, toolContext)).rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError',
|
||||
nextAction: expect.stringContaining('workspace-relative path')
|
||||
})
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
|
||||
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('corrected read') }] })
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'missing.txt' }, signal, toolContext))
|
||||
.rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError', cause: { code: 'ENOENT' },
|
||||
nextAction: expect.stringContaining('Do not retry identical arguments')
|
||||
})
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers invalid search inputs and actual rg regex failures', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
await writeFile(join(workspace, 'note.txt'), '[literal')
|
||||
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
for (const args of [
|
||||
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
|
||||
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
|
||||
]) {
|
||||
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError',
|
||||
nextAction: expect.stringContaining('adjust the search path/globs')
|
||||
})
|
||||
}
|
||||
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
|
||||
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds expected remote read errors without recovering disconnects or programmer errors', async () => {
|
||||
const access = new LocalWorkspaceAccess(await createWorkspace())
|
||||
const readText = vi.spyOn(access, 'readText')
|
||||
const provider = new ModelToolProvider(access)
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
for (const code of ['ENOENT', 'EACCES', 'invalid-path', 'invalid-utf8', 'special-file']) {
|
||||
const failure = Object.assign(new Error('detail'.repeat(1_000)), { data: { code } })
|
||||
readText.mockRejectedValueOnce(failure)
|
||||
const result = await provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)
|
||||
.catch((error: unknown) => error)
|
||||
expect(result).toBeInstanceOf(RecoverableModelToolError)
|
||||
const error = result as RecoverableModelToolError
|
||||
expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(2_021)
|
||||
expect(error.cause).toBe(failure)
|
||||
expect(error.nextAction).not.toContain('process_execute')
|
||||
}
|
||||
for (const failure of [
|
||||
new TypeError('programmer error'), new Error('remote disconnected'),
|
||||
Object.assign(new Error('closed'), { code: 'closed' }),
|
||||
Object.assign(new Error('stale workspace'), { data: { code: 'stale-workspace' } })
|
||||
]) {
|
||||
readText.mockRejectedValueOnce(failure)
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
|
||||
.rejects.toBe(failure)
|
||||
}
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const access = new LocalWorkspaceAccess(workspace)
|
||||
const failure = new Error('regex parse error', {
|
||||
cause: Object.assign(new Error('process failed'), { code: 2 })
|
||||
})
|
||||
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
|
||||
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
|
||||
})
|
||||
try {
|
||||
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
|
||||
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
|
||||
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
|
||||
.catch((error: unknown) => error)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).not.toBeInstanceOf(RecoverableModelToolError)
|
||||
expect(error).toMatchObject({ cause: { code: 'ENOENT' } })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['workspace_read_text', 'workspace_rg'])('keeps %s cancellation fatal', async (name) => {
|
||||
const access = new LocalWorkspaceAccess(await createWorkspace())
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
|
||||
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
|
||||
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
|
||||
try {
|
||||
const abortError = new DOMException('cancelled', 'AbortError')
|
||||
operation.mockRejectedValueOnce(abortError)
|
||||
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during access')
|
||||
operation.mockImplementationOnce(async () => {
|
||||
controller.abort(reason)
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
})
|
||||
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason)
|
||||
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('suggests process_execute only when available in local model Execute', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const access = new LocalWorkspaceAccess(workspace)
|
||||
const identity = await access.getIdentity()
|
||||
const processService = {
|
||||
getCapability: vi.fn(async () => ({ available: true, shell: { kind: 'powershell', label: 'pwsh' } })),
|
||||
dispose: vi.fn(async () => {})
|
||||
} as unknown as DirectModelProcessService
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { processService })
|
||||
try {
|
||||
for (const [context, available] of [
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const }, true],
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const, workMode: 'ask' as const }, false],
|
||||
[toolContext, false],
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const, executionSpaceIdentity: `${identity.id}-other` }, false]
|
||||
] as const) {
|
||||
const error = await provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, context)
|
||||
.catch((error: unknown) => error) as RecoverableModelToolError
|
||||
expect(error).toBeInstanceOf(RecoverableModelToolError)
|
||||
expect(error.nextAction.includes('process_execute')).toBe(available)
|
||||
}
|
||||
vi.mocked(processService.getCapability).mockResolvedValueOnce({ available: false, reason: 'unavailable' })
|
||||
await expect(provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, {
|
||||
...toolContext, runtimeTarget: 'model'
|
||||
})).rejects.toMatchObject({ nextAction: expect.not.stringContaining('process_execute') })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => {
|
||||
const readText = vi.fn(async () => ({
|
||||
path: 'remote.txt',
|
||||
|
||||
@@ -1731,25 +1731,74 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (name === 'workspace_rg') {
|
||||
if (!this.programming.ripgrepExecutablePath) {
|
||||
throw new Error('GoodBuddy 内置 ripgrep 不可用')
|
||||
}
|
||||
const input = ripgrepInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await searchWorkspaceWithRipgrep(
|
||||
this.programming.ripgrepExecutablePath,
|
||||
input,
|
||||
this.workspaceAccess,
|
||||
signal
|
||||
if (name === 'workspace_rg' || name === 'workspace_read_text') {
|
||||
try {
|
||||
if (name === 'workspace_rg') {
|
||||
if (!this.programming.ripgrepExecutablePath) {
|
||||
throw new Error('GoodBuddy 内置 ripgrep 不可用')
|
||||
}
|
||||
const input = ripgrepInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await searchWorkspaceWithRipgrep(
|
||||
this.programming.ripgrepExecutablePath,
|
||||
input,
|
||||
this.workspaceAccess,
|
||||
signal
|
||||
)
|
||||
)
|
||||
}
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await readWorkspaceLines(this.workspaceAccess, input, signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_read_text') {
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await readWorkspaceLines(this.workspaceAccess, input, signal)
|
||||
)
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
if (!(error instanceof Error) || error.name === 'AbortError' ||
|
||||
(error.cause instanceof Error && error.cause.name === 'AbortError')) {
|
||||
throw error
|
||||
}
|
||||
const code = 'code' in error ? error.code : undefined
|
||||
const data = 'data' in error ? error.data : undefined
|
||||
const remoteCode = data && typeof data === 'object' && 'code' in data
|
||||
? data.code : undefined
|
||||
const expectedOsCodes = ['ENOENT', 'ENOTDIR', 'EISDIR', 'EACCES', 'EPERM', 'ELOOP', 'ENAMETOOLONG']
|
||||
const expectedWorkspaceError = [
|
||||
'路径必须是工作区内的相对路径',
|
||||
'路径必须是工作区内的相对路径,不能超出工作区',
|
||||
'文件路径不能超出项目工作区',
|
||||
'文件路径不能通过符号链接超出项目工作区',
|
||||
'目标不是普通文件',
|
||||
'目标不是目录',
|
||||
'工作区读取目标不是有效 UTF-8 文本',
|
||||
'工作区读取范围无效'
|
||||
].includes(error.message)
|
||||
const rgExecutionError = name === 'workspace_rg' && (
|
||||
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
|
||||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
|
||||
)
|
||||
if (!(error instanceof z.ZodError) &&
|
||||
!(typeof code === 'string' && expectedOsCodes.includes(code)) &&
|
||||
!(typeof remoteCode === 'string' && [
|
||||
...expectedOsCodes, 'invalid-path', 'invalid-utf8',
|
||||
'not-directory', 'special-file', 'symlink-rejected'
|
||||
].includes(remoteCode)) &&
|
||||
!expectedWorkspaceError && !rgExecutionError) {
|
||||
throw error
|
||||
}
|
||||
let nextAction = name === 'workspace_rg'
|
||||
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).'
|
||||
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
|
||||
nextAction += ' Do not retry identical arguments.'
|
||||
if ((await this.getProcessTool(context, signal)).length > 0) {
|
||||
nextAction += ' For files outside the workspace, use process_execute in local Execute mode.'
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
throw new RecoverableModelToolError(
|
||||
`${name}: ${utf8Prefix(error.message, 2_000)}`,
|
||||
nextAction,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (name === 'workspace_apply_patch') {
|
||||
const input = applyPatchInputSchema.parse(argumentsValue)
|
||||
|
||||
@@ -14,7 +14,7 @@ const cachedBinary = join(
|
||||
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'completes native Task external reads and structured yes/no and text questions while Ask disables tools',
|
||||
'completes native Task external reads and concurrent structured questions while Ask disables tools',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -50,7 +50,8 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
|
||||
}
|
||||
} else if (results.some(result => result.tool_call_id === 'native-question')) {
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer')
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer') &&
|
||||
results.some(result => result.tool_call_id === 'parallel-question' && JSON.stringify(result.content).includes('Parallel answer'))
|
||||
content = 'PARENT_OK'
|
||||
} else if (results.some(result => result.tool_call_id === 'native-task')) {
|
||||
tool = { id: 'native-question', name: 'question', arguments: { questions: [
|
||||
@@ -74,7 +75,11 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
|
||||
name: tool.name, arguments: JSON.stringify(tool.arguments)
|
||||
} }]
|
||||
} }, ...(tool.id === 'native-question' ? [{ index: 1, id: 'parallel-question', type: 'function', function: {
|
||||
name: 'question', arguments: JSON.stringify({ questions: [
|
||||
{ header: 'Parallel', question: 'Another decision?', options: [], custom: true }
|
||||
] })
|
||||
} }] : [])]
|
||||
} : { role: 'assistant', content }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
@@ -102,17 +107,24 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
let text = ''
|
||||
const pending: Array<{ id: string; answers: string[][] }> = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
expect(event.questions).toHaveLength(2)
|
||||
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']])
|
||||
pending.push({ id: event.questionId, answers: event.questions.length === 2
|
||||
? [['Yes'], ['Custom answer']] : [['Parallel answer']] })
|
||||
// Both native calls must reach the desktop before either is answered.
|
||||
if (pending.length === 2) {
|
||||
expect(pending[0]!.id).not.toBe(pending[1]!.id)
|
||||
for (const question of pending) await runtime.respondToQuestion(question.id, question.answers)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
|
||||
expect(pending).toHaveLength(mode === 'execute' ? 2 : 0)
|
||||
}
|
||||
expect(readVerified).toBe(true)
|
||||
expect(answerVerified).toBe(true)
|
||||
|
||||
@@ -2281,6 +2281,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => {
|
||||
const question = (id: string, sessionID: string) => ({
|
||||
type: "question.asked", properties: { id, sessionID,
|
||||
questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] },
|
||||
});
|
||||
const setup = runClient([
|
||||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||||
state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" },
|
||||
metadata: { sessionId: "child-session" }, time: { start: 1 } },
|
||||
} } },
|
||||
question("first", "child-session"),
|
||||
question("second", "other-child"),
|
||||
question("first", "child-session"),
|
||||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||||
state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" },
|
||||
output: "Finished", time: { start: 1, end: 2 } },
|
||||
} } },
|
||||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never);
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel",
|
||||
prompt: "test", workMode: "execute" }, new AbortController().signal);
|
||||
await stream.next();
|
||||
const task = (await stream.next()).value;
|
||||
expect(task?.type).toBe("subagent");
|
||||
const first = (await stream.next()).value;
|
||||
const second = (await stream.next()).value;
|
||||
if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions");
|
||||
expect(first.childTaskId).toBe(task.childTaskId);
|
||||
expect(second.childTaskId).toBeUndefined();
|
||||
expect(first.questionId).not.toBe(second.questionId);
|
||||
setup.questionReply.mockRejectedValueOnce(new Error("Reply failed"));
|
||||
await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed");
|
||||
await runtime.respondToQuestion(first.questionId, [["Answer"]]);
|
||||
await runtime.respondToQuestion(second.questionId);
|
||||
expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything());
|
||||
expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything());
|
||||
const remaining = [];
|
||||
for await (const event of stream) remaining.push(event);
|
||||
expect(remaining.some((event) => event.type === "question")).toBe(false);
|
||||
await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow();
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("namespaces identical upstream question IDs across concurrent external conversations", async () => {
|
||||
const setup = runClient([]);
|
||||
(
|
||||
|
||||
@@ -2464,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return false;
|
||||
};
|
||||
const reportedMessageIds = new Set<string>();
|
||||
const childTaskIdsBySession = new Map<string, string>();
|
||||
let waitingForRetry = false;
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event);
|
||||
@@ -2621,6 +2622,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
...(subagent ? { subagent } : {}),
|
||||
});
|
||||
if (subagent) {
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined;
|
||||
if (isRecord(metadata) && typeof metadata.sessionId === "string") {
|
||||
childTaskIdsBySession.set(metadata.sessionId, subagent.childTaskId);
|
||||
}
|
||||
const retained = subagentProgress.retain(subagent);
|
||||
toolStates.get(callId)!.subagent = retained;
|
||||
yield retained;
|
||||
@@ -2693,6 +2698,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
requestId: request.requestId,
|
||||
type: "question",
|
||||
questionId: publicQuestionId,
|
||||
childTaskId: childTaskIdsBySession.get(event.properties.sessionID),
|
||||
questions: questionRequest.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
remoteSemanticTranscriptPageResultSchema,
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type { z } from 'zod'
|
||||
import type { RemoteQuestionResponse } from '../../shared/remote-question-contracts'
|
||||
import type {
|
||||
ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
@@ -91,6 +92,7 @@ export interface RemoteModelBridgeSession {
|
||||
* The runtime intentionally receives no SSH client, socket or command API.
|
||||
*/
|
||||
export interface RemoteRuntimeChannel {
|
||||
respondToQuestion?(request: RemoteQuestionResponse): Promise<void>
|
||||
readonly input: ReadableStream<Uint8Array>
|
||||
readonly output: WritableStream<Uint8Array>
|
||||
readonly generation: number
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -132,6 +133,148 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
|
||||
const database = await createDatabase()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
try {
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const otherProject = database.createProject({
|
||||
name: 'Other project', description: '', rootPath: 'C:\\Other',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
const active = ['running', 'waiting_approval'].map((status, index) =>
|
||||
database.createTask({
|
||||
id: randomUUID(), projectId: index ? otherProject.id : projectId,
|
||||
title: status, instructions: '', workMode: 'execute',
|
||||
status: status as 'running' | 'waiting_approval'
|
||||
})
|
||||
)
|
||||
database.createTask({
|
||||
id: randomUUID(), title: 'Hidden live task', instructions: '',
|
||||
workMode: 'execute', visible: false
|
||||
})
|
||||
const history = Array.from({ length: 501 }, (_, index) => {
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 1, 1) + index * 1000))
|
||||
return database.createTask({
|
||||
id: randomUUID(), projectId, title: `History ${index}`,
|
||||
instructions: '', workMode: 'ask',
|
||||
status: index === 500 ? 'running' : 'completed'
|
||||
})
|
||||
}).reverse()
|
||||
const expected = [
|
||||
...history.slice(0, Math.max(1, Math.min(500, limit))),
|
||||
...active
|
||||
]
|
||||
const tasks = database.listTasks(limit)
|
||||
expect(tasks).toHaveLength(expected.length)
|
||||
expect(new Set(tasks.map((task) => task.id))).toEqual(
|
||||
new Set(expected.map((task) => task.id))
|
||||
)
|
||||
expect(tasks.slice(0, expected.length - active.length)).toEqual(
|
||||
history.slice(0, Math.max(1, Math.min(500, limit)))
|
||||
)
|
||||
expect(tasks.find((task) => task.id === active[1]!.id)?.projectId)
|
||||
.toBe(otherProject.id)
|
||||
if (limit === 100) {
|
||||
expect(database.listTasks()).toEqual(tasks)
|
||||
}
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
try {
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const schedules = ['running', 'approval', 'pending', 'idle', 'hidden', 'archived']
|
||||
.map((title) => database.createSchedule({
|
||||
projectId, title, prompt: title, recurrence: 'daily',
|
||||
nextRunAt: '2027-01-01T00:00:00Z'
|
||||
}))
|
||||
const [running, approval, pending, , hidden, archived] = schedules
|
||||
for (const schedule of [running!, approval!, hidden!, archived!]) {
|
||||
claimManualScheduleQueueItem(database, schedule.id)
|
||||
}
|
||||
database.updateTaskStatus(approval!.taskId, 'waiting_approval')
|
||||
database.queueScheduleNow(pending!.id)
|
||||
database.updateTaskStatus(pending!.taskId, 'running')
|
||||
raw.prepare('UPDATE tasks SET visible = 0 WHERE id = ?').run(hidden!.taskId)
|
||||
raw.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?")
|
||||
.run(archived!.conversationId)
|
||||
// Queued schedule tasks normally read as idle, unless a run overrides them.
|
||||
raw.prepare("UPDATE tasks SET status = 'queued' WHERE id = ?").run(running!.taskId)
|
||||
expect(database.listTasks().find((task) => task.id === pending!.taskId)?.status)
|
||||
.toBe('queued')
|
||||
const liveConversations = ['running', 'waiting_approval'].map((status) => {
|
||||
const id = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id, projectId, title: status, updatedAt: 1 }, messages: []
|
||||
}])
|
||||
database.createTask({
|
||||
id: randomUUID(), projectId, conversationId: id,
|
||||
title: status, instructions: '', workMode: 'execute',
|
||||
status: status as 'running' | 'waiting_approval'
|
||||
})
|
||||
return id
|
||||
})
|
||||
const streamingId = randomUUID()
|
||||
const streamingMessage = {
|
||||
id: randomUUID(), role: 'assistant' as const, content: 'Still working',
|
||||
state: 'streaming' as const, createdAt: 1
|
||||
}
|
||||
database.saveLocalConversations([{
|
||||
header: { id: streamingId, projectId, title: 'Streaming', updatedAt: 1 },
|
||||
messages: [streamingMessage]
|
||||
}])
|
||||
const recent = Array.from({ length: 101 }, (_, index) => ({
|
||||
header: {
|
||||
id: randomUUID(), projectId, title: `Recent ${index}`,
|
||||
updatedAt: Date.UTC(2026, 1, 1) + index * 1000
|
||||
},
|
||||
messages: []
|
||||
}))
|
||||
database.saveLocalConversations(recent)
|
||||
vi.setSystemTime(new Date('2026-02-01T00:00:00Z'))
|
||||
const historyTask = database.createTask({
|
||||
id: randomUUID(), projectId, title: 'Recent completed task',
|
||||
instructions: '', workMode: 'ask', status: 'completed'
|
||||
})
|
||||
const tasks = database.listTasks(1)
|
||||
expect(tasks).toHaveLength(6)
|
||||
expect(tasks).toEqual(expect.arrayContaining([
|
||||
historyTask,
|
||||
expect.objectContaining({ id: running!.taskId, status: 'running' }),
|
||||
expect.objectContaining({ id: approval!.taskId, status: 'waiting_approval' }),
|
||||
expect.objectContaining({ id: archived!.taskId, status: 'running' })
|
||||
]))
|
||||
const conversations = database.listConversations()
|
||||
expect(conversations).toHaveLength(105)
|
||||
expect(new Set(conversations.map((conversation) => conversation.id))).toEqual(
|
||||
new Set([
|
||||
...recent.slice(1).map((conversation) => conversation.header.id),
|
||||
running!.conversationId, approval!.conversationId, streamingId,
|
||||
...liveConversations
|
||||
])
|
||||
)
|
||||
expect(conversations.find((conversation) => conversation.id === streamingId))
|
||||
.toMatchObject({ projectId, messages: [streamingMessage] })
|
||||
expect(conversations.slice(0, 100).map((conversation) => conversation.id))
|
||||
.toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id))
|
||||
} finally {
|
||||
raw.close()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps timed messages independent of creation-time execution settings', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.createProject({
|
||||
@@ -3460,7 +3603,13 @@ describe('AssistantDatabase', () => {
|
||||
snippet: '可以继续恢复。',
|
||||
rank: 1,
|
||||
retrievalChannels: ['fts']
|
||||
}
|
||||
},
|
||||
...['first', 'second', 'first'].map(remoteChunkId => ({
|
||||
libraryId: '00000000-0000-4000-8000-000000000708',
|
||||
libraryName: 'External', documentName: 'Remote', sourceName: 'Remote',
|
||||
snippet: 'same excerpt', rank: 2,
|
||||
external: { kind: 'external' as const, provider: 'dify' as const, instanceId: 'instance', remoteKnowledgeBaseId: 'dataset', remoteChunkId }
|
||||
}))
|
||||
]
|
||||
})
|
||||
append('8', 0, {
|
||||
@@ -3489,7 +3638,9 @@ describe('AssistantDatabase', () => {
|
||||
resultCount: 1
|
||||
},
|
||||
sourceReferences: [
|
||||
expect.objectContaining({ documentName: '恢复说明.md' })
|
||||
expect.objectContaining({ documentName: '恢复说明.md' }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'first' }) }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second' }) })
|
||||
],
|
||||
subagents: [
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
@@ -588,6 +589,14 @@ const taskSelect = `SELECT tasks.*,
|
||||
LIMIT 1) AS active_run_status
|
||||
FROM tasks`
|
||||
|
||||
// Match toTask's live statuses: pending schedule runs normalize to queued.
|
||||
const activeVisibleTaskSelect = `${taskSelect}
|
||||
WHERE visible = 1 AND (
|
||||
active_run_status = 'running'
|
||||
OR (active_run_status IS NULL
|
||||
AND status IN ('running', 'waiting_approval'))
|
||||
)`
|
||||
|
||||
function toTask(row: TaskRow): AssistantTask {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -1470,16 +1479,7 @@ function reduceRecoveredAgentEvent(
|
||||
: undefined
|
||||
}
|
||||
} else if (event.type === 'source-references') {
|
||||
const key = (
|
||||
reference: (typeof event.references)[number]
|
||||
): string =>
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? '',
|
||||
reference.snippet
|
||||
].join('\0')
|
||||
const key = knowledgeReferenceKey
|
||||
const incoming = [
|
||||
...new Map(
|
||||
event.references.map((reference) => [key(reference), reference])
|
||||
@@ -2612,8 +2612,15 @@ export class AssistantDatabase {
|
||||
updated_at
|
||||
FROM conversations
|
||||
WHERE status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 100`
|
||||
AND (
|
||||
id IN (SELECT id FROM conversations WHERE status = 'active'
|
||||
ORDER BY updated_at DESC LIMIT 100)
|
||||
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
|
||||
OR EXISTS (SELECT 1 FROM messages
|
||||
WHERE conversation_id = conversations.id
|
||||
AND state = 'streaming')
|
||||
)
|
||||
ORDER BY updated_at DESC`
|
||||
)
|
||||
.all() as ConversationRow[]
|
||||
const messageStatement = database.prepare(
|
||||
@@ -4195,8 +4202,12 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`${taskSelect}
|
||||
WHERE visible = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
AND (
|
||||
id IN (SELECT id FROM tasks WHERE visible = 1
|
||||
ORDER BY created_at DESC LIMIT ?)
|
||||
OR id IN (SELECT id FROM (${activeVisibleTaskSelect}))
|
||||
)
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
.all(safeLimit) as TaskRow[]
|
||||
return rows.map(toTask)
|
||||
|
||||
@@ -854,6 +854,7 @@ if (hasSingleInstanceLock) {
|
||||
runtimeExtensionStore.markStartupFailed(extensionIds)
|
||||
})
|
||||
const startupKnowledgeService = new KnowledgeService({
|
||||
credentialCipher: secureCipher,
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||
extractStructured: createModelGraphExtractor(settingsStore),
|
||||
|
||||
+65
-12
@@ -25,6 +25,7 @@ import type {
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
@@ -3087,6 +3088,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||
it('exposes per-library ontology settings and rebuild state', async () => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const knowledgeService = {
|
||||
database: { externalStore: { listBindings: () => [] } },
|
||||
snapshot: vi.fn(() => ({
|
||||
libraries: [
|
||||
{
|
||||
@@ -8236,7 +8238,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => {
|
||||
it.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const documentId = '33333333-3333-4333-8333-333333333333'
|
||||
const chunkId = '44444444-4444-4444-8444-444444444444'
|
||||
@@ -8291,15 +8293,16 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
results: [
|
||||
{
|
||||
knowledgeBaseId: libraryId,
|
||||
documentId,
|
||||
sourceId: '55555555-5555-4555-8555-555555555555',
|
||||
chunkId,
|
||||
documentId: external ? undefined : documentId,
|
||||
sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555',
|
||||
chunkId: external ? undefined : chunkId,
|
||||
external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined,
|
||||
documentTitle: '离线部署.md',
|
||||
sourceDisplayName: '产品手册',
|
||||
sourceType: 'file' as const,
|
||||
location: '第 2 节',
|
||||
snippet: '离线部署需要先校验安装包',
|
||||
relevance: 0.9,
|
||||
relevance: external ? undefined : 0.9,
|
||||
rank: 1,
|
||||
channels: ['fts' as const],
|
||||
scores: {
|
||||
@@ -8323,6 +8326,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
]
|
||||
}
|
||||
}
|
||||
if (external) {
|
||||
retrievalResponse.context.groups = []
|
||||
retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } })
|
||||
}
|
||||
const retrieveMany = vi.fn(async () => [
|
||||
{ knowledgeBaseId: libraryId, response: retrievalResponse }
|
||||
])
|
||||
@@ -8337,6 +8344,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
supportsScopedDataTools: !external,
|
||||
run
|
||||
},
|
||||
undefined,
|
||||
@@ -8387,20 +8395,19 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect.objectContaining({
|
||||
type: 'knowledge-retrieval',
|
||||
state: 'succeeded',
|
||||
resultCount: 1
|
||||
resultCount: external ? 2 : 1
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'source-references',
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
chunkId,
|
||||
documentId
|
||||
})
|
||||
]
|
||||
references: external ? expect.arrayContaining([
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) })
|
||||
]) : [expect.objectContaining({ chunkId, documentId })]
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"')
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
@@ -8480,6 +8487,52 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('routes external UI payloads through IPC, the production service and citation search', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-'))
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => Response.json(init?.method === 'POST'
|
||||
? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] }
|
||||
: { data: [{ id: 'dataset', name: 'Policy' }], has_more: false }))
|
||||
const service = new KnowledgeService({
|
||||
databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'),
|
||||
externalFetcher: fetcher,
|
||||
credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() }
|
||||
})
|
||||
await service.initialize()
|
||||
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, service as unknown as Record<string, unknown>)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const invoke = (channel: string, input?: unknown) => electronMocks.handlers.get(channel)!(event, input)
|
||||
try {
|
||||
const instance = await invoke(ipcChannels.externalInstancesSave, {
|
||||
name: 'External instance', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
|
||||
credential: { action: 'replace', value: 'test-key' }
|
||||
}) as { id: string }
|
||||
expect(instance).not.toHaveProperty('credential')
|
||||
await expect(invoke(ipcChannels.externalCatalogList, { instanceId: instance.id })).resolves.toMatchObject({ items: [{ id: 'dataset' }] })
|
||||
await expect(invoke(ipcChannels.externalBindingsCreate, { instanceId: instance.id })).rejects.toThrow()
|
||||
const snapshot = await invoke(ipcChannels.externalBindingsCreate, {
|
||||
instanceId: instance.id, remoteKnowledgeBaseId: 'dataset', name: 'Policy', remoteName: 'Policy',
|
||||
testQuery: 'policy', commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 4000 },
|
||||
providerConfig: { provider: 'dify', useDatasetDefaults: true }
|
||||
}) as { libraries: Array<{ id: string; kind: string }> }
|
||||
expect(snapshot.libraries[0]?.kind).toBe('external')
|
||||
const libraryId = snapshot.libraries[0]!.id
|
||||
await expect(invoke(ipcChannels.knowledgeRetrieve, { knowledgeBaseId: libraryId, query: 'policy' })).resolves.toMatchObject({
|
||||
results: [{ external: { remoteChunkId: 'remote-chunk' }, snippet: 'Policy evidence' }]
|
||||
})
|
||||
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).resolves.toMatchObject([
|
||||
{ libraryId, documentId: undefined, snippet: 'Policy evidence', external: { remoteDocumentId: 'remote-document', remoteChunkId: 'remote-chunk', providerScore: 0.75 } }
|
||||
])
|
||||
await invoke(ipcChannels.externalInstancesSetEnabled, { instanceId: instance.id, enabled: false })
|
||||
fetcher.mockClear()
|
||||
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).rejects.toThrow('EXTERNAL_KB_DISABLED')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
await service.dispose()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes status and concurrent conversations to their selected runtimes', async () => {
|
||||
const firstProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const secondProfileId = '00000000-0000-4000-8000-000000000002'
|
||||
|
||||
+48
-51
@@ -1,3 +1,5 @@
|
||||
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -992,6 +994,8 @@ function getKnowledgeSnapshot(
|
||||
}
|
||||
return {
|
||||
libraries: snapshot.libraries.map((library) => ({
|
||||
kind: service.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) ? 'external' : 'local',
|
||||
external: service.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===library.id),
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
description: library.description ?? '',
|
||||
@@ -1092,6 +1096,7 @@ function buildForcedKnowledgeEvidence(
|
||||
promptContext?: string
|
||||
references: KnowledgeSearchReference[]
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
const ranked = entries
|
||||
.flatMap((entry) =>
|
||||
entry.response.results.map((result) => ({
|
||||
@@ -1104,35 +1109,26 @@ function buildForcedKnowledgeEvidence(
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.result.relevance - left.result.relevance ||
|
||||
right.result.scores.fusedScore -
|
||||
left.result.scores.fusedScore ||
|
||||
left.result.chunkId.localeCompare(right.result.chunkId)
|
||||
left.result.rank - right.result.rank ||
|
||||
left.entry.libraryId.localeCompare(right.entry.libraryId)
|
||||
)
|
||||
.filter(({ entry, result }) => {
|
||||
const key = knowledgeReferenceKey(toKnowledgeReference(result, entry.libraryName))
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.slice(0, 20)
|
||||
const references: KnowledgeSearchReference[] = ranked.map(
|
||||
({ entry, result }, index) => ({
|
||||
libraryId: entry.libraryId,
|
||||
libraryName: entry.libraryName,
|
||||
documentId: result.documentId,
|
||||
chunkId: result.chunkId,
|
||||
documentName: result.documentTitle,
|
||||
sourceName: result.sourceDisplayName,
|
||||
locator: result.location,
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet),
|
||||
rank: index + 1,
|
||||
score: result.scores.fusedScore,
|
||||
lexicalRank: result.scores.ftsRank,
|
||||
vectorRank: result.scores.vectorRank,
|
||||
graphRank: result.scores.graphRank,
|
||||
similarity: result.scores.vectorSimilarity,
|
||||
retrievalChannels: result.channels
|
||||
...toKnowledgeReference(result, entry.libraryName),
|
||||
rank: index + 1
|
||||
})
|
||||
)
|
||||
if (ranked.length === 0) {
|
||||
return { references }
|
||||
}
|
||||
let remainingCharacters = 24_000
|
||||
let remainingCharacters = 48_000
|
||||
const evidence: Array<{
|
||||
citation: number
|
||||
library: string
|
||||
@@ -1161,7 +1157,7 @@ function buildForcedKnowledgeEvidence(
|
||||
library: item.entry.libraryName,
|
||||
document: item.result.documentTitle,
|
||||
source: item.result.sourceDisplayName,
|
||||
locator: item.result.location,
|
||||
locator: item.result.external?.location ?? item.result.location,
|
||||
text
|
||||
})
|
||||
remainingCharacters -= text.length
|
||||
@@ -4317,12 +4313,7 @@ export function registerIpcHandlers(
|
||||
request.knowledgeCapabilityToken
|
||||
) ?? [])
|
||||
].map((reference) => [
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? ''
|
||||
].join('\0'),
|
||||
knowledgeReferenceKey(reference),
|
||||
reference
|
||||
])
|
||||
).values()
|
||||
@@ -4379,6 +4370,9 @@ export function registerIpcHandlers(
|
||||
libraryNames.get(knowledgeBaseId) ?? '知识库',
|
||||
response
|
||||
}))
|
||||
if (entries.length && entries.every(entry => entry.response.diagnostics.failure)) {
|
||||
throw new Error(entries.map(entry => `${entry.libraryName}: ${entry.response.diagnostics.failure}`).join('; '))
|
||||
}
|
||||
const evidence = buildForcedKnowledgeEvidence(entries)
|
||||
preflightReferences = evidence.references
|
||||
const usedChannels = [
|
||||
@@ -4390,13 +4384,13 @@ export function registerIpcHandlers(
|
||||
)
|
||||
]
|
||||
const warnings = entries.flatMap((entry) =>
|
||||
entry.response.diagnostics.degradedChannels.map(
|
||||
[...(entry.response.diagnostics.failure ? [`${entry.libraryName} · ${entry.response.diagnostics.failure}`] : []), ...entry.response.diagnostics.degradedChannels.map(
|
||||
(item) =>
|
||||
`${entry.libraryName} · ${item.reason}`.slice(
|
||||
0,
|
||||
500
|
||||
)
|
||||
)
|
||||
)]
|
||||
)
|
||||
if (evidence.promptContext) {
|
||||
executionRequest = {
|
||||
@@ -4641,6 +4635,10 @@ export function registerIpcHandlers(
|
||||
runtime: selectedRuntime
|
||||
})
|
||||
}
|
||||
if (publicEvent.type === 'question-resolved' &&
|
||||
pendingAgentQuestions.get(publicEvent.questionId)?.requestId === request.requestId) {
|
||||
pendingAgentQuestions.delete(publicEvent.questionId)
|
||||
}
|
||||
if (publicEvent.type === 'error') {
|
||||
runtimeErrorEvent = publicEvent
|
||||
if (provenance === undefined) {
|
||||
@@ -7901,6 +7899,16 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.externalInstancesList, (event) => {assertTrustedSender(event,window);return knowledgeService.external.listInstances()})
|
||||
registerHandler(ipcChannels.externalInstancesSave, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalInstancesTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalInstancesSetEnabled, (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
|
||||
registerHandler(ipcChannels.externalInstancesDelete, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalCatalogList, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.listCatalog(externalKnowledgeCatalogListInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalCatalogGet, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.getCatalog(externalKnowledgeCatalogGetInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalRetrievalTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testRetrieval(externalKnowledgeBindingTestInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const libraryId =
|
||||
@@ -8064,30 +8072,19 @@ export function registerIpcHandlers(
|
||||
availableLibraries.map((library) => [library.id, library.name])
|
||||
)
|
||||
const results = (
|
||||
await knowledgeService.searchHybridMany(
|
||||
await knowledgeService.retrieveMany(
|
||||
libraries,
|
||||
value.query,
|
||||
6
|
||||
value.query
|
||||
)
|
||||
).map(({ knowledgeBaseId, result }) => ({
|
||||
libraryId: knowledgeBaseId,
|
||||
libraryName: names.get(knowledgeBaseId) ?? '知识库',
|
||||
documentId: result.document.id,
|
||||
chunkId: result.chunk.id,
|
||||
documentName: result.document.title,
|
||||
sourceName: result.source.displayName,
|
||||
locator: result.chunk.location,
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet),
|
||||
rank: result.rank,
|
||||
score: result.retrieval.score,
|
||||
lexicalRank: result.retrieval.lexicalRank,
|
||||
vectorRank: result.retrieval.vectorRank,
|
||||
graphRank: result.retrieval.graphRank,
|
||||
similarity: result.retrieval.similarity,
|
||||
retrievalChannels: result.retrieval.channels,
|
||||
evidenceIds: result.retrieval.evidenceIds
|
||||
}))
|
||||
return results
|
||||
)
|
||||
const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
||||
if (failures.length === results.length) throw new Error(failures.join('; '))
|
||||
const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({
|
||||
...toKnowledgeReference(result, names.get(knowledgeBaseId) ?? '知识库'),
|
||||
...(failures.length ? { warnings: failures } : {})
|
||||
})))
|
||||
if (!references.length && failures.length) throw new Error(failures.join('; '))
|
||||
return [...new Map(references.map(reference => [knowledgeReferenceKey(reference), reference])).values()]
|
||||
.sort((left, right) => left.rank - right.rank)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
+220
-3
@@ -1,7 +1,9 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ExternalKnowledgeClient,
|
||||
ExternalKnowledgeError
|
||||
ExternalKnowledgeError,
|
||||
type ExternalKnowledgeRetrievalConfig
|
||||
} from './external-knowledge-client'
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
@@ -52,7 +54,7 @@ describe('ExternalKnowledgeClient', () => {
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ query: 'leave policy' })
|
||||
})
|
||||
|
||||
it('filters FastGPT folders and maps searchTest results', async () => {
|
||||
it('retains FastGPT folders for navigation and maps searchTest results', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 200,
|
||||
@@ -83,7 +85,7 @@ describe('ExternalKnowledgeClient', () => {
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).resolves.toMatchObject({
|
||||
items: [{ id: 'dataset-1', name: 'Products' }]
|
||||
items: [{ id: 'folder-1', kind: 'folder' }, { id: 'dataset-1', name: 'Products', kind: 'dataset' }]
|
||||
})
|
||||
await expect(client.retrieve('dataset-1', 'pricing', {
|
||||
provider: 'fastgpt',
|
||||
@@ -98,6 +100,221 @@ describe('ExternalKnowledgeClient', () => {
|
||||
})])
|
||||
|
||||
expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://fastgpt.example/api/core/dataset/searchTest')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: null })
|
||||
})
|
||||
|
||||
it.each(['current', 'legacy'] as const)('preserves typed FastGPT scores and result order in the %s envelope', async (envelope) => {
|
||||
const scores = [{ type: 'fullText', value: 12.4, index: 2 }, { type: 'embedding', value: 0.8, index: 0 }, { type: 'reRank', value: 0.3, index: 1 }]
|
||||
const list = [{ q: 'First', score: scores, id: 'first' }, { q: 'Second', score: 0.99 }]
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 200, data: envelope === 'legacy' ? list : { list } }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const results = await client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'mixedRecall', tokenLimit: 2000, similarity: 0.2, usingRerank: true })
|
||||
expect(results.map((item) => item.snippet)).toEqual(['First', 'Second'])
|
||||
expect(results[0]).toMatchObject({ providerScores: scores, remoteChunkId: 'first' })
|
||||
expect(results[0]?.providerScore).toBeUndefined()
|
||||
expect(results[0]?.remoteDocumentId).toBeUndefined()
|
||||
expect(results[1]?.providerScore).toBe(0.99)
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ datasetId: 'dataset', text: 'query', limit: 2000, similarity: 0.2, searchMode: 'mixedRecall', usingReRank: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ code: 200, data: { data: [] } },
|
||||
{ code: 500, data: [] },
|
||||
{ code: 200, data: [{ a: 'Answer without main text' }] },
|
||||
{ code: 200, data: [{ q: 'Text', score: [{ type: 'embedding', value: '0.8' }] }] }
|
||||
])('rejects unsupported FastGPT envelopes and malformed evidence', async (body) => {
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false })).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
})
|
||||
|
||||
it('paginates FastGPT directory results locally and traverses parent IDs', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse({ code: 200, data: [
|
||||
{ _id: 'folder', name: 'Folder', type: 'folder' },
|
||||
{ _id: 'one', name: 'One', type: 'dataset' },
|
||||
{ _id: 'two', name: 'Two', type: 'dataset' }
|
||||
] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(undefined, { parentId: 'parent', page: 2, pageSize: 2 })).resolves.toMatchObject({ items: [{ id: 'two' }], total: 3, hasMore: false })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: 'parent' })
|
||||
await expect(client.listKnowledgeBases(undefined, { search: 'one', pageSize: 1 })).resolves.toMatchObject({ items: [{ id: 'one' }], total: 1, hasMore: false })
|
||||
})
|
||||
|
||||
it.each(['dify', 'ragflow'] as const)('requests subsequent %s pages and keeps continuation when local search has no matches', async (provider) => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify'
|
||||
? { data: [{ id: 'one', name: 'One' }], total: 5, has_more: true }
|
||||
: { code: 0, data: [{ id: 'one', name: 'One' }], total_datasets: 5 }))
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(undefined, { page: 2, pageSize: 1, search: 'missing' })).resolves.toMatchObject({ items: [], hasMore: true })
|
||||
const url = new URL(String(fetcher.mock.calls[0]?.[0]))
|
||||
expect(url.searchParams.get('page')).toBe('2')
|
||||
expect(url.searchParams.get(provider === 'dify' ? 'limit' : 'page_size')).toBe('1')
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('reads %s details through its fixed read endpoint', async (provider) => {
|
||||
const record = { id: 'dataset', _id: 'dataset', name: 'Dataset', type: 'dataset', graphrag_task_finish_at: '2026-09-10T10:00:00Z', compilation_template_group_id: 'template' }
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify' ? record : { code: provider === 'fastgpt' ? 200 : 0, data: provider === 'fastgpt' ? record : [record] }))
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const detail = await client.getKnowledgeBase('dataset')
|
||||
expect(detail).toMatchObject({ id: 'dataset', name: 'Dataset', kind: 'dataset' })
|
||||
if (provider === 'ragflow') expect(detail).toMatchObject({ graphEnabled: true, knowledgeCompilationEnabled: true })
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`https://kb.example${provider === 'dify' ? '/v1/datasets/dataset' : provider === 'fastgpt' ? '/api/core/dataset/detail?id=dataset' : '/api/v1/datasets?id=dataset'}`)
|
||||
expect(fetcher.mock.calls[0]?.[1]?.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('distinguishes missing RAGFlow details and unsupported old Dify details', async () => {
|
||||
const ragflow = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: [] })) })
|
||||
await expect(ragflow.getKnowledgeBase('missing')).rejects.toMatchObject({ code: 'EXTERNAL_KB_NOT_FOUND' })
|
||||
const dify = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 405 })) })
|
||||
await expect(dify.getKnowledgeBase('old')).rejects.toMatchObject({ code: 'EXTERNAL_KB_INCOMPATIBLE' })
|
||||
})
|
||||
|
||||
it('sends complete explicit Dify configuration and rejects missing reranking identifiers before fetch', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: true, score_threshold: 0.2, reranking_mode: 'reranking_model', reranking_model: { reranking_provider_name: 'provider', reranking_model_name: 'model' } } as const
|
||||
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, reranking_model: undefined } } as ExternalKnowledgeRetrievalConfig)).rejects.toThrow()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fetch when already cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('private reason'))
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const operation = client.listKnowledgeBases(controller.signal)
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
await expect(operation).rejects.not.toHaveProperty('cause')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['headers', 'body'] as const)('distinguishes cancellation and timeout while reading %s', async (phase) => {
|
||||
for (const cancellation of [true, false]) {
|
||||
const controller = new AbortController()
|
||||
const cancelBody = vi.fn()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||
if (phase === 'headers') return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
|
||||
started()
|
||||
})
|
||||
return new Response(new ReadableStream({ pull() { started() }, cancel: cancelBody }))
|
||||
})
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher, timeoutMs: cancellation ? 1000 : 20 })
|
||||
const operation = client.listKnowledgeBases(controller.signal)
|
||||
const assertion = expect(operation).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
|
||||
await ready
|
||||
if (cancellation) controller.abort()
|
||||
await assertion
|
||||
if (phase === 'body') expect(cancelBody).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps body transport failures to a sanitized network error', async () => {
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(new ReadableStream({ pull(controller) { controller.error(new Error('private transport detail')) } }))) })
|
||||
const operation = client.listKnowledgeBases()
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_NETWORK' })
|
||||
await expect(operation).rejects.not.toHaveProperty('cause')
|
||||
})
|
||||
|
||||
it.each([200, 401])('cancels a response body when cancellation races with HTTP %s headers', async (status) => {
|
||||
const controller = new AbortController()
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(new ReadableStream({ cancel }), { status })
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return response
|
||||
})
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
expect(cancel).toHaveBeenCalledTimes(1)
|
||||
expect(response.body?.locked).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['headers', 'body'] as const)('aborts real HTTP requests stalled at %s on cancellation and timeout', async (phase) => {
|
||||
for (const cancellation of [true, false]) {
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
const server = createServer((_request, response) => {
|
||||
if (phase === 'body') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.write('{"data":[')
|
||||
}
|
||||
started()
|
||||
})
|
||||
try {
|
||||
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing test server address')
|
||||
const controller = new AbortController()
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: `http://127.0.0.1:${address.port}`, apiKey: 'test-key', timeoutMs: cancellation ? 2000 : 200 })
|
||||
const assertion = expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
|
||||
await ready
|
||||
if (cancellation) controller.abort()
|
||||
await assertion
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()) })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('validates %s scalar scores without clamping or treating zero as missing', async (provider) => {
|
||||
for (const score of [0, 12.4, -0.5, undefined, null, '0.8', {}, true]) {
|
||||
const body = provider === 'dify' ? { records: [{ segment: { content: 'Text' }, score }] }
|
||||
: provider === 'fastgpt' ? { code: 200, data: { list: [{ q: 'Text', score }] } }
|
||||
: { code: 0, data: { chunks: [{ content: 'Text', similarity: score }] } }
|
||||
const config: ExternalKnowledgeRetrievalConfig = provider === 'dify' ? { provider, useDatasetDefaults: true }
|
||||
: provider === 'fastgpt' ? { provider, searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false }
|
||||
: { provider, similarityThreshold: 0, vectorSimilarityWeight: 0.3, knnTopK: 128, useKg: false, includeKnowledgeCompilation: false }
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
|
||||
const operation = client.retrieve('dataset', 'query', config)
|
||||
if (score === undefined || score === null || typeof score === 'number') {
|
||||
await expect(operation).resolves.toEqual([expect.objectContaining({ providerScore: score ?? undefined })])
|
||||
} else {
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('counts Unicode code points for the Dify API query limit', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const query = '\u{20000}'.repeat(250)
|
||||
await expect(client.retrieve('dataset', query, { provider: 'dify', useDatasetDefaults: true })).resolves.toEqual([])
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query })
|
||||
await expect(client.retrieve('dataset', `${query}a`, { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not infer RAGFlow capabilities from configuration alone or invalid completion times', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: ['0', '0.0', '', 'invalid'].map((time, index) => ({ id: String(index), name: 'Dataset', graphrag_task_finish_at: time, parser_config: { graphrag: { use_graphrag: true } } })) }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const page = await client.listKnowledgeBases()
|
||||
expect(page.items).toHaveLength(4)
|
||||
for (const item of page.items) expect(item).toMatchObject({ graphEnabled: false, knowledgeCompilationEnabled: false })
|
||||
})
|
||||
|
||||
it('sends Dify weighted configuration without merging and rejects inconsistent weights', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: false, reranking_mode: 'weighted_score', weights: { weight_type: 'customized', vector_setting: { vector_weight: 0.7, embedding_provider_name: 'provider', embedding_model_name: 'model' }, keyword_setting: { keyword_weight: 0.3 } } } as const
|
||||
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, weights: { ...retrievalModel.weights, keyword_setting: { keyword_weight: 0.7 } } } })).rejects.toThrow('weights must sum to one')
|
||||
await expect(client.retrieve('dataset', 'q'.repeat(251), { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('cancels oversized response streams (declared length: %s)', async (declared) => {
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('123456')) }, cancel }), { headers: declared ? { 'content-length': '6' } : {} })
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', maximumResponseBytes: 5, fetcher: vi.fn<typeof fetch>().mockResolvedValue(response) })
|
||||
await expect(client.listKnowledgeBases()).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
expect(cancel).toHaveBeenCalledTimes(1)
|
||||
expect(response.body?.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('maps RAGFlow graph capability and retrieval fields', async () => {
|
||||
|
||||
+144
-92
@@ -1,44 +1,21 @@
|
||||
export type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow'
|
||||
import {
|
||||
externalKnowledgeProviderConfigSchema,
|
||||
type ExternalKnowledgeProvider,
|
||||
type ExternalKnowledgeCatalogItem,
|
||||
type ExternalKnowledgeCatalogPage,
|
||||
type ExternalKnowledgeProviderConfig,
|
||||
type ExternalKnowledgeRemoteResult
|
||||
} from '../../../shared/external-knowledge-contracts'
|
||||
|
||||
export type ExternalKnowledgeCatalogItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
graphEnabled?: boolean
|
||||
}
|
||||
export type { ExternalKnowledgeProvider, ExternalKnowledgeCatalogItem, ExternalKnowledgeCatalogPage }
|
||||
export type ExternalKnowledgeRetrievalConfig = ExternalKnowledgeProviderConfig
|
||||
export type ExternalKnowledgeRetrievalResult = ExternalKnowledgeRemoteResult
|
||||
|
||||
export type ExternalKnowledgeCatalogPage = {
|
||||
items: ExternalKnowledgeCatalogItem[]
|
||||
total?: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalConfig =
|
||||
| { provider: 'dify'; useDatasetDefaults: true }
|
||||
| {
|
||||
provider: 'fastgpt'
|
||||
searchMode: 'embedding' | 'fullTextRecall' | 'mixedRecall'
|
||||
tokenLimit: number
|
||||
similarity: number
|
||||
usingRerank: boolean
|
||||
}
|
||||
| {
|
||||
provider: 'ragflow'
|
||||
similarityThreshold: number
|
||||
vectorSimilarityWeight: number
|
||||
knnTopK: number
|
||||
useKg: boolean
|
||||
includeKnowledgeCompilation: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalResult = {
|
||||
documentTitle: string
|
||||
sourceDisplayName: string
|
||||
snippet: string
|
||||
providerScore?: number
|
||||
remoteDocumentId?: string
|
||||
remoteChunkId?: string
|
||||
location?: string
|
||||
export type ExternalKnowledgeCatalogInput = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
parentId?: string | null
|
||||
search?: string
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeError extends Error {
|
||||
@@ -46,6 +23,8 @@ export class ExternalKnowledgeError extends Error {
|
||||
readonly code:
|
||||
| 'EXTERNAL_KB_NETWORK'
|
||||
| 'EXTERNAL_KB_TIMEOUT'
|
||||
| 'EXTERNAL_KB_CANCELLED'
|
||||
| 'EXTERNAL_KB_INCOMPATIBLE'
|
||||
| 'EXTERNAL_KB_AUTH'
|
||||
| 'EXTERNAL_KB_FORBIDDEN'
|
||||
| 'EXTERNAL_KB_NOT_FOUND'
|
||||
@@ -60,7 +39,7 @@ export class ExternalKnowledgeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalKnowledgeClientOptions = {
|
||||
export type ExternalKnowledgeClientOptions = {
|
||||
provider: ExternalKnowledgeProvider
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
@@ -109,6 +88,7 @@ function endpoint(baseUrl: URL, path: string): URL {
|
||||
}
|
||||
|
||||
function errorForStatus(status: number): ExternalKnowledgeError {
|
||||
if (status === 405) return new ExternalKnowledgeError('EXTERNAL_KB_INCOMPATIBLE', 'External knowledge operation is not supported by this deployment')
|
||||
if (status === 401) return new ExternalKnowledgeError('EXTERNAL_KB_AUTH', 'External knowledge authentication failed')
|
||||
if (status === 403) return new ExternalKnowledgeError('EXTERNAL_KB_FORBIDDEN', 'External knowledge access is forbidden')
|
||||
if (status === 404) return new ExternalKnowledgeError('EXTERNAL_KB_NOT_FOUND', 'External knowledge target was not found')
|
||||
@@ -116,36 +96,42 @@ function errorForStatus(status: number): ExternalKnowledgeError {
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`)
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> {
|
||||
async function readBoundedJson(response: Response, maximumBytes: number, signal: AbortSignal): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
void response.body?.cancel().catch(() => {})
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const cancel = (): void => { void reader.cancel().catch(() => {}) }
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
while (true) {
|
||||
const part = await reader.read()
|
||||
signal.throwIfAborted()
|
||||
if (part.done) break
|
||||
total += part.value.byteLength
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel()
|
||||
cancel()
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
chunks.push(part.value)
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
reader.releaseLock()
|
||||
}
|
||||
const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')
|
||||
try {
|
||||
return JSON.parse(body) as unknown
|
||||
} catch (error) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON', { cause: error })
|
||||
} catch {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,16 +155,23 @@ export class ExternalKnowledgeClient {
|
||||
this.maximumResponseBytes = options.maximumResponseBytes ?? 5_000_000
|
||||
}
|
||||
|
||||
async listKnowledgeBases(signal?: AbortSignal): Promise<ExternalKnowledgeCatalogPage> {
|
||||
async listKnowledgeBases(signal?: AbortSignal, input: ExternalKnowledgeCatalogInput = {}): Promise<ExternalKnowledgeCatalogPage> {
|
||||
const { page = 1, pageSize = 100, parentId = null } = input
|
||||
if (!Number.isInteger(page) || page < 1 || page > 500 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
||||
throw new RangeError('Invalid external knowledge catalog pagination')
|
||||
}
|
||||
const search = input.search?.trim().toLowerCase()
|
||||
const filter = (items: ExternalKnowledgeCatalogItem[]): ExternalKnowledgeCatalogItem[] =>
|
||||
search ? items.filter((item) => `${item.name}\n${item.description ?? ''}`.toLowerCase().includes(search)) : items
|
||||
if (this.provider === 'dify') {
|
||||
const url = endpoint(this.normalizedBaseUrl, '/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('limit', '100')
|
||||
url.searchParams.set('page', String(page))
|
||||
url.searchParams.set('limit', String(pageSize))
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
const items = Array.isArray(body?.data) ? body.data : undefined
|
||||
if (!items) throw this.invalidResponse()
|
||||
return {
|
||||
items: items.slice(0, 100).map((item) => this.catalogItem(item)),
|
||||
items: filter(items.slice(0, pageSize).map((item) => this.catalogItem(item))),
|
||||
total: finiteNumber(body?.total),
|
||||
hasMore: body?.has_more === true
|
||||
}
|
||||
@@ -187,63 +180,81 @@ export class ExternalKnowledgeClient {
|
||||
if (this.provider === 'fastgpt') {
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, '/core/dataset/list'),
|
||||
{ method: 'POST', body: JSON.stringify({ pageNum: 1, pageSize: 100 }) },
|
||||
{ method: 'POST', body: JSON.stringify({ parentId }) },
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
const items = Array.isArray(body?.data)
|
||||
? body.data
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data?.list)
|
||||
? data.list
|
||||
: undefined
|
||||
const items = Array.isArray(body?.data) ? body.data : undefined
|
||||
if (body?.code !== 200 || !items) throw this.invalidResponse()
|
||||
const datasets = items.filter((item) => asRecord(item)?.type === 'dataset')
|
||||
const datasets = filter(items.filter((item) => ['dataset', 'folder'].includes(String(asRecord(item)?.type)))
|
||||
.map((item) => this.catalogItem(item, '_id', 'intro')))
|
||||
const offset = (page - 1) * pageSize
|
||||
return {
|
||||
items: datasets.slice(0, 100).map((item) => this.catalogItem(item, '_id', 'intro')),
|
||||
total: finiteNumber(data?.total) ?? datasets.length,
|
||||
hasMore: datasets.length > 100
|
||||
items: datasets.slice(offset, offset + pageSize),
|
||||
total: datasets.length,
|
||||
hasMore: datasets.length > offset + pageSize
|
||||
}
|
||||
}
|
||||
|
||||
const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('page_size', '100')
|
||||
url.searchParams.set('page', String(page))
|
||||
url.searchParams.set('page_size', String(pageSize))
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
|
||||
return {
|
||||
items: body.data.slice(0, 100).map((item) => {
|
||||
const record = asRecord(item)
|
||||
const graphFinishedAt = record?.graphrag_task_finish_at
|
||||
return {
|
||||
...this.catalogItem(item),
|
||||
graphEnabled:
|
||||
(typeof graphFinishedAt === 'number' && graphFinishedAt > 0) ||
|
||||
(typeof graphFinishedAt === 'string' && graphFinishedAt.length > 0)
|
||||
}
|
||||
}),
|
||||
items: filter(body.data.slice(0, pageSize).map((item) => this.catalogItem(item))),
|
||||
total: finiteNumber(body.total_datasets),
|
||||
hasMore: (finiteNumber(body.total_datasets) ?? 0) > 100
|
||||
hasMore: finiteNumber(body.total_datasets) === undefined
|
||||
? body.data.length >= pageSize
|
||||
: Number(body.total_datasets) > page * pageSize
|
||||
}
|
||||
}
|
||||
|
||||
async getKnowledgeBase(remoteKnowledgeBaseId: string, signal?: AbortSignal): Promise<ExternalKnowledgeCatalogItem> {
|
||||
const id = remoteKnowledgeBaseId.trim()
|
||||
if (!id) throw new TypeError('Remote knowledge base ID is required')
|
||||
const url = endpoint(this.normalizedBaseUrl, this.provider === 'dify'
|
||||
? `/v1/datasets/${encodeURIComponent(id)}`
|
||||
: this.provider === 'fastgpt' ? '/core/dataset/detail' : '/api/v1/datasets')
|
||||
if (this.provider !== 'dify') url.searchParams.set('id', id)
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
let item: ExternalKnowledgeCatalogItem
|
||||
if (this.provider === 'dify') item = this.catalogItem(body)
|
||||
else if (this.provider === 'fastgpt') {
|
||||
if (body?.code !== 200) throw this.invalidResponse()
|
||||
item = this.catalogItem(body.data, '_id', 'intro')
|
||||
} else {
|
||||
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
|
||||
const match = body.data.find((value) => asRecord(value)?.id === id)
|
||||
if (!match) throw errorForStatus(404)
|
||||
item = this.catalogItem(match)
|
||||
}
|
||||
if (item.id !== id) throw this.invalidResponse()
|
||||
return item
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
remoteKnowledgeBaseId: string,
|
||||
query: string,
|
||||
config: ExternalKnowledgeRetrievalConfig,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExternalKnowledgeRetrievalResult[]> {
|
||||
config = externalKnowledgeProviderConfigSchema.parse(config)
|
||||
if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client')
|
||||
const remoteId = remoteKnowledgeBaseId.trim()
|
||||
const question = query.trim()
|
||||
if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required')
|
||||
|
||||
if (this.provider === 'dify') {
|
||||
if (question.length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
|
||||
if (this.provider === 'dify' && config.provider === 'dify') {
|
||||
if (!config.useDatasetDefaults && config.retrievalModel.reranking_mode === 'weighted_score' && config.retrievalModel.weights) {
|
||||
const weights = config.retrievalModel.weights
|
||||
if (Math.abs(weights.vector_setting.vector_weight + weights.keyword_setting.keyword_weight - 1) > 1e-9) {
|
||||
throw new TypeError('Dify retrieval weights must sum to one')
|
||||
}
|
||||
}
|
||||
if (Array.from(question).length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, `/v1/datasets/${encodeURIComponent(remoteId)}/retrieve`),
|
||||
{ method: 'POST', body: JSON.stringify({ query: question }) },
|
||||
{ method: 'POST', body: JSON.stringify({ query: question, ...(!config.useDatasetDefaults ? { retrieval_model: config.retrievalModel } : {}) }) },
|
||||
signal
|
||||
))
|
||||
if (!Array.isArray(body?.records)) throw this.invalidResponse()
|
||||
@@ -267,8 +278,10 @@ export class ExternalKnowledgeClient {
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
if (body?.code !== 200 || !Array.isArray(data?.list)) throw this.invalidResponse()
|
||||
return data.list.slice(0, 20).map((item) => this.fastGptResult(item))
|
||||
// The official legacy manual documents data[]; current OpenAPI uses data.list[].
|
||||
const items = Array.isArray(body?.data) ? body.data : data?.list
|
||||
if (body?.code !== 200 || !Array.isArray(items)) throw this.invalidResponse()
|
||||
return items.slice(0, 20).map((item) => this.fastGptResult(item))
|
||||
}
|
||||
|
||||
if (this.provider === 'ragflow' && config.provider === 'ragflow') {
|
||||
@@ -299,12 +312,13 @@ export class ExternalKnowledgeClient {
|
||||
}
|
||||
|
||||
private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise<unknown> {
|
||||
const timeoutSignal = AbortSignal.timeout(this.timeoutMs)
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)])
|
||||
: AbortSignal.timeout(this.timeoutMs)
|
||||
let response: Response
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal
|
||||
try {
|
||||
response = await this.fetcher(url, {
|
||||
effectiveSignal.throwIfAborted()
|
||||
const response = await this.fetcher(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: effectiveSignal,
|
||||
@@ -314,20 +328,40 @@ export class ExternalKnowledgeClient {
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {})
|
||||
}
|
||||
})
|
||||
if (effectiveSignal.aborted || !response.ok) {
|
||||
void response.body?.cancel().catch(() => {})
|
||||
effectiveSignal.throwIfAborted()
|
||||
throw errorForStatus(response.status)
|
||||
}
|
||||
return await readBoundedJson(response, this.maximumResponseBytes, effectiveSignal)
|
||||
} catch (error) {
|
||||
const code = effectiveSignal.aborted ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_NETWORK'
|
||||
throw new ExternalKnowledgeError(code, code === 'EXTERNAL_KB_TIMEOUT' ? 'External knowledge request timed out' : 'External knowledge service is unreachable', { cause: error })
|
||||
if (effectiveSignal.aborted) {
|
||||
const timedOut = effectiveSignal.reason === timeoutSignal.reason && timeoutSignal.aborted
|
||||
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
|
||||
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
|
||||
}
|
||||
if (error instanceof ExternalKnowledgeError) throw error
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_NETWORK', 'External knowledge service is unreachable')
|
||||
}
|
||||
if (!response.ok) throw errorForStatus(response.status)
|
||||
return readBoundedJson(response, this.maximumResponseBytes)
|
||||
}
|
||||
|
||||
private catalogItem(value: unknown, idKey = 'id', descriptionKey = 'description'): ExternalKnowledgeCatalogItem {
|
||||
const record = asRecord(value)
|
||||
const id = boundedString(record?.[idKey], 128)
|
||||
const id = boundedString(record?.[idKey], 512)
|
||||
const name = boundedString(record?.name, 512)
|
||||
if (!id || !name) throw this.invalidResponse()
|
||||
return { id, name, description: boundedString(record?.[descriptionKey], 2_000) }
|
||||
const item: ExternalKnowledgeCatalogItem = { id, name, description: boundedString(record?.[descriptionKey], 2_000), kind: 'dataset' }
|
||||
if (this.provider === 'fastgpt') {
|
||||
if (record?.type !== 'dataset' && record?.type !== 'folder') throw this.invalidResponse()
|
||||
item.kind = record.type
|
||||
}
|
||||
if (this.provider === 'ragflow') {
|
||||
const finished = record?.graphrag_task_finish_at
|
||||
item.graphEnabled = typeof finished === 'number' ? Number.isFinite(finished) && finished > 0
|
||||
: typeof finished === 'string' && (Number.isFinite(Number(finished)) ? Number(finished) > 0 : Date.parse(finished) > 0)
|
||||
item.knowledgeCompilationEnabled = !!boundedString(record?.compilation_template_group_id, 512)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private difyResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
@@ -341,7 +375,7 @@ export class ExternalKnowledgeClient {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.score),
|
||||
providerScore: this.resultScore(record?.score),
|
||||
remoteDocumentId: boundedString(document?.id, 128),
|
||||
remoteChunkId: boundedString(segment?.id, 128),
|
||||
location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}`
|
||||
@@ -351,15 +385,26 @@ export class ExternalKnowledgeClient {
|
||||
private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
const record = asRecord(value)
|
||||
const question = boundedString(record?.q, 8_000)
|
||||
if (!question) throw this.invalidResponse()
|
||||
const answer = boundedString(record?.a, 8_000)
|
||||
const snippet = [question, answer].filter(Boolean).join('\n')
|
||||
if (!snippet) throw this.invalidResponse()
|
||||
const title = boundedString(record?.sourceName, 512) ?? 'FastGPT document'
|
||||
// Preserve channel semantics and provider order; these are not one comparable total score.
|
||||
const providerScores = Array.isArray(record?.score) ? record.score.slice(0, 20).map((value) => {
|
||||
const score = asRecord(value)
|
||||
const type = boundedString(score?.type, 128)
|
||||
const number = finiteNumber(score?.value)
|
||||
const index = finiteNumber(score?.index)
|
||||
if (!type || number === undefined || (score?.index !== undefined && (index === undefined || !Number.isInteger(index)))) throw this.invalidResponse()
|
||||
return { type, value: number, ...(index === undefined ? {} : { index }) }
|
||||
}) : undefined
|
||||
return {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet: snippet.slice(0, 8_000),
|
||||
providerScore: finiteNumber(record?.score),
|
||||
providerScore: providerScores ? undefined : this.resultScore(record?.score),
|
||||
...(providerScores ? { providerScores } : {}),
|
||||
remoteDocumentId: boundedString(record?.sourceId, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}`
|
||||
@@ -376,13 +421,20 @@ export class ExternalKnowledgeClient {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.similarity),
|
||||
providerScore: this.resultScore(record?.similarity),
|
||||
remoteDocumentId: boundedString(record?.document_id, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: positions ? JSON.stringify(positions).slice(0, 8_192) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private resultScore(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
const score = finiteNumber(value)
|
||||
if (score === undefined) throw this.invalidResponse()
|
||||
return score
|
||||
}
|
||||
|
||||
private invalidResponse(): ExternalKnowledgeError {
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { KnowledgeService } from '../knowledge-service'
|
||||
import { KnowledgeMcpGateway } from '../../agent/knowledge-mcp-gateway'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../../shared/knowledge-reference'
|
||||
import { conversationMessageSchema } from '../../../shared/assistant-contracts'
|
||||
|
||||
const services: KnowledgeService[] = []
|
||||
const directories: string[] = []
|
||||
const servers: Server[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map(service => service.dispose()))
|
||||
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))))
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function setup(fetcher?: typeof fetch) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'external-kb-'))
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'knowledge.db')
|
||||
const cipher = { isAvailable: () => true, encrypt: (value: string) => Buffer.from(value), decrypt: (value: Buffer) => value.toString() }
|
||||
const options = { databasePath: path, managedRoot: join(directory, 'managed'), externalFetcher: fetcher, credentialCipher: cipher }
|
||||
const service = new KnowledgeService(options)
|
||||
await service.initialize()
|
||||
services.push(service)
|
||||
return { service, path, cipher, options }
|
||||
}
|
||||
|
||||
const commonConfig = { resultLimit: 20, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 }
|
||||
const providerConfig = { provider: 'dify' as const, useDatasetDefaults: true as const }
|
||||
const records = (count = 1, size = 16) => ({ records: Array.from({ length: count }, (_, index) => ({
|
||||
score: 0.8, segment: { id: `chunk-${index}`, content: 'e'.repeat(size), document: { id: 'remote-doc', name: 'Handbook' } }
|
||||
})) })
|
||||
|
||||
function instance(service: KnowledgeService, baseUrl = 'https://kb.example/v1') {
|
||||
return service.external.saveInstance({ name: 'Dify', provider: 'dify', baseUrl, enabled: true, credential: { action: 'replace', value: 'test-key' } })
|
||||
}
|
||||
|
||||
function bindingInput(instanceId: string, remoteKnowledgeBaseId = 'dataset') {
|
||||
return { instanceId, remoteKnowledgeBaseId, name: 'Handbook', remoteName: 'Remote handbook', testQuery: 'policy', commonConfig, providerConfig }
|
||||
}
|
||||
|
||||
function retrievalInput(instanceId: string) {
|
||||
return { instanceId, remoteKnowledgeBaseId: 'dataset', testQuery: 'policy', commonConfig, providerConfig }
|
||||
}
|
||||
|
||||
it('routes the real HTTP client through directory, detail, binding and scoped MCP without remote writes', async () => {
|
||||
const requests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(`${request.method} ${request.url}`)
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify(request.url?.includes('/retrieve') ? records() : request.url?.includes('?')
|
||||
? { data: [{ id: 'dataset', name: 'Handbook' }], has_more: false }
|
||||
: { id: 'dataset', name: 'Handbook' }))
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const { service } = await setup()
|
||||
const saved = instance(service, `http://127.0.0.1:${address.port}/v1`)
|
||||
expect(saved).not.toHaveProperty('credential')
|
||||
expect((await service.external.listCatalog({ instanceId: saved.id })).items).toHaveLength(1)
|
||||
expect(await service.external.getCatalog({ instanceId: saved.id, remoteKnowledgeBaseId: 'dataset' })).toMatchObject({ id: 'dataset' })
|
||||
const binding = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
const token = gateway.grant('request', [binding.knowledgeBaseId], new AbortController().signal)!
|
||||
try {
|
||||
const references = await gateway.search(token, { query: 'policy' })
|
||||
expect(references).toHaveLength(1)
|
||||
expect(references[0]).toMatchObject({ external: { remoteDocumentId: 'remote-doc', remoteChunkId: 'chunk-0', providerScore: 0.8 } })
|
||||
expect(references[0]?.documentId).toBeUndefined()
|
||||
expect(service.database.listDocuments(binding.knowledgeBaseId)).toEqual([])
|
||||
expect(requests).toEqual(['GET /v1/datasets?page=1&limit=100', 'GET /v1/datasets/dataset', 'POST /v1/datasets/dataset/retrieve', 'POST /v1/datasets/dataset/retrieve'])
|
||||
} finally { await gateway.dispose() }
|
||||
})
|
||||
|
||||
it('keeps actual failures, bounds mixed retrieval to 48k, and rejects local mutations', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records(20, 8000)))
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const first = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const second = await service.external.saveBinding(bindingInput(saved.id, 'second'))
|
||||
const results = await service.retrieveMany([first.knowledgeBaseId, second.knowledgeBaseId], 'policy')
|
||||
expect(results.flatMap(item => item.response.results).reduce((sum, result) => sum + result.snippet.length, 0)).toBe(48000)
|
||||
fetcher.mockResolvedValue(new Response('', { status: 401 }))
|
||||
const failed = await service.retrieveMany([first.knowledgeBaseId], 'policy')
|
||||
expect(failed[0]?.response.diagnostics.failure).toBe('EXTERNAL_KB_AUTH')
|
||||
await expect(service.importPaths(first.knowledgeBaseId, [])).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
await expect(service.rebuildLibrary({ knowledgeBaseId: first.knowledgeBaseId })).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
expect(() => service.updateSettings({ knowledgeBaseId: first.knowledgeBaseId, retrieval: service.database.getKnowledgeBase(first.knowledgeBaseId)!.retrievalSettings })).toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
expect(() => service.database.updateKnowledgeBase(first.knowledgeBaseId, { graphEnabled: true })).toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
})
|
||||
|
||||
it('rolls metadata back with binding failures and rejects concurrent duplicate creates', async () => {
|
||||
const { service, path } = await setup(async () => Response.json(records()))
|
||||
const saved = instance(service)
|
||||
const first = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const inspection = new DatabaseSync(path)
|
||||
inspection.exec("CREATE TRIGGER reject_binding BEFORE UPDATE ON external_knowledge_bindings BEGIN SELECT RAISE(ABORT, 'binding failed'); END")
|
||||
try {
|
||||
await expect(service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: first.knowledgeBaseId, name: 'Changed' })).rejects.toThrow('binding failed')
|
||||
expect(service.database.getKnowledgeBase(first.knowledgeBaseId)?.name).toBe('Handbook')
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 12 })
|
||||
} finally { inspection.close() }
|
||||
const outcomes = await Promise.allSettled([service.external.saveBinding(bindingInput(saved.id, 'new')), service.external.saveBinding(bindingInput(saved.id, 'new'))])
|
||||
expect(outcomes.filter(item => item.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(service.database.listKnowledgeBases()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels pending probes on disable and prevents shutdown writeback', async () => {
|
||||
let release!: (value: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>(() => new Promise<Response>(resolve => { release = resolve }))
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const probe = service.external.testInstance(saved.id)
|
||||
service.external.setEnabled(saved.id, false)
|
||||
release(Response.json({ data: [], has_more: false }))
|
||||
await expect(probe).rejects.toThrow('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
expect(service.external.listInstances()[0]?.enabled).toBe(false)
|
||||
service.external.setEnabled(saved.id, true)
|
||||
const pending = service.external.saveBinding(bindingInput(saved.id))
|
||||
const rejected = expect(pending).rejects.toThrow()
|
||||
await service.dispose()
|
||||
services.splice(services.indexOf(service), 1)
|
||||
release(Response.json(records()))
|
||||
await rejected
|
||||
expect(() => service.external.listInstances()).toThrow('EXTERNAL_KB_CANCELLED')
|
||||
})
|
||||
|
||||
it('persists external locators without local IDs and distinguishes remote chunks', async () => {
|
||||
const { service } = await setup(async () => Response.json(records(2)))
|
||||
const binding = await service.external.saveBinding(bindingInput(instance(service).id))
|
||||
const response = await service.retrieve({ knowledgeBaseId: binding.knowledgeBaseId, query: 'policy' })
|
||||
const references = response.results.map(result => toKnowledgeReference(result, 'Handbook'))
|
||||
expect(knowledgeReferenceKey(references[0]!)).not.toBe(knowledgeReferenceKey(references[1]!))
|
||||
const message = conversationMessageSchema.parse({ id: crypto.randomUUID(), role: 'assistant', content: '', createdAt: 1, state: 'complete', sourceReferences: references })
|
||||
expect(message.sourceReferences?.[0]?.external).toEqual(references[0]?.external)
|
||||
})
|
||||
|
||||
it('reports unusable credentials and preserves the stored key when encryption fails', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records()))
|
||||
const { service, cipher } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const before = service.database.externalStore.getInstance(saved.id)
|
||||
const decrypt = vi.spyOn(cipher, 'decrypt').mockImplementation(() => { throw new Error('OS key unavailable') })
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
decrypt.mockReturnValue(JSON.stringify(' '))
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
decrypt.mockRestore()
|
||||
vi.spyOn(cipher, 'encrypt').mockImplementation(() => { throw new Error('OS encryption failed') })
|
||||
expect(() => service.external.saveInstance({ id: saved.id, name: 'Changed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true,
|
||||
credential: { action: 'replace', value: 'new-key' } })).toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
expect(service.database.externalStore.getInstance(saved.id)).toEqual(before)
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('configured')
|
||||
})
|
||||
|
||||
it('persists keep and clear credential mutations and normalized binding updates across restart', async () => {
|
||||
const { service, options } = await setup(async () => Response.json(records()))
|
||||
const saved = instance(service)
|
||||
const binding = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const credential = service.database.externalStore.getInstance(saved.id)?.credential
|
||||
service.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'keep' } })
|
||||
const updated = await service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: ` ${binding.knowledgeBaseId} `, name: 'Updated' })
|
||||
expect(updated.knowledgeBaseId).toBe(binding.knowledgeBaseId)
|
||||
expect(service.database.externalStore.getInstance(saved.id)?.credential).toEqual(credential)
|
||||
await service.dispose()
|
||||
services.splice(services.indexOf(service), 1)
|
||||
const reopened = new KnowledgeService(options)
|
||||
services.push(reopened)
|
||||
await reopened.initialize()
|
||||
expect(reopened.external.listInstances()[0]).toMatchObject({ name: 'Renamed', credentialStatus: 'configured', bindingCount: 1 })
|
||||
expect(reopened.database.getKnowledgeBase(binding.knowledgeBaseId)?.name).toBe('Updated')
|
||||
expect(reopened.database.externalStore.listBindings()).toEqual([updated])
|
||||
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).resolves.toMatchObject({ results: [expect.anything()] })
|
||||
reopened.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'clear' } })
|
||||
expect(reopened.external.listInstances()[0]).toMatchObject({ credentialStatus: 'missing', bindingCount: 1 })
|
||||
const inspection = new DatabaseSync(options.databasePath)
|
||||
try {
|
||||
const row = inspection.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(saved.id)!
|
||||
expect(JSON.parse(String(row.value_json))).not.toHaveProperty('credential')
|
||||
} finally { inspection.close() }
|
||||
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('does not invalidate retrieval or binding verification when a concurrent probe persists its result', async () => {
|
||||
const releases: ((response: Response) => void)[] = []
|
||||
const { service } = await setup(() => new Promise<Response>(resolve => releases.push(resolve)))
|
||||
const saved = instance(service)
|
||||
const binding = service.external.saveBinding(bindingInput(saved.id))
|
||||
const retrieval = service.external.testRetrieval(retrievalInput(saved.id))
|
||||
const probe = service.external.testInstance(saved.id)
|
||||
releases[2]!(Response.json({ data: [], has_more: false }))
|
||||
await expect(probe).resolves.toMatchObject({ probeStatus: 'catalog-ready' })
|
||||
releases[0]!(Response.json(records()))
|
||||
releases[1]!(Response.json(records()))
|
||||
await expect(binding).resolves.toMatchObject({ instanceId: saved.id })
|
||||
await expect(retrieval).resolves.toMatchObject({ results: [expect.anything()] })
|
||||
expect(service.external.listInstances()[0]?.probeStatus).toBe('catalog-ready')
|
||||
})
|
||||
|
||||
it('normalizes pre-request cancellation and persists network probe failures as unreachable', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') })
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id), AbortSignal.abort())).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await expect(service.external.testInstance(saved.id)).resolves.toMatchObject({ probeStatus: 'unreachable', lastErrorCode: 'EXTERNAL_KB_NETWORK' })
|
||||
})
|
||||
|
||||
it('includes the RAGFlow capability check in the retrieval timeout budget', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async url => {
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
return Response.json(String(url).includes('/datasets')
|
||||
? { code: 0, data: [{ id: 'dataset', name: 'Graph', graphrag_task_finish_at: 1 }] }
|
||||
: { code: 0, data: { chunks: [] } })
|
||||
})
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = service.external.saveInstance({ name: 'RAGFlow', provider: 'ragflow', baseUrl: 'https://kb.example', enabled: true, credential: { action: 'replace', value: 'test-key' } })
|
||||
await expect(service.external.testRetrieval({ ...retrievalInput(saved.id), providerConfig: {
|
||||
provider: 'ragflow', similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 10, useKg: true, includeKnowledgeCompilation: false
|
||||
} })).rejects.toMatchObject({ code: 'EXTERNAL_KB_TIMEOUT' })
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingTestInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeInstanceSaveInputSchema, type ExternalKnowledgeBinding, type ExternalKnowledgeBindingSaveInput, type ExternalKnowledgeBindingTestInput, type ExternalKnowledgeInstanceSaveInput, type ExternalKnowledgeInstanceSummary, type ExternalKnowledgeTestResult } from '../../../shared/external-knowledge-contracts'
|
||||
import { decryptSettingsCredential, encryptSettingsCredential, type SettingsCredentialCipher } from '../../settings-credential-cipher'
|
||||
import type { KnowledgeDatabase } from '../knowledge-database'
|
||||
import { ExternalKnowledgeClient, ExternalKnowledgeError } from './external-knowledge-client'
|
||||
import type { StoredExternalInstance } from './external-knowledge-store'
|
||||
|
||||
// Probe results are persisted metadata, not a change to an in-flight request's configuration.
|
||||
function instanceConfiguration(value: StoredExternalInstance): string {
|
||||
return JSON.stringify([value.id, value.name, value.provider, value.baseUrl, value.enabled, value.credential])
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeService {
|
||||
private disposed = false
|
||||
private readonly controllers = new Map<AbortController,string>()
|
||||
constructor(private readonly database: KnowledgeDatabase, private readonly cipher?: SettingsCredentialCipher, private readonly fetcher?: typeof fetch) {}
|
||||
dispose(): void { this.disposed = true; for (const controller of this.controllers.keys()) controller.abort(); this.controllers.clear() }
|
||||
private checkActive(): void { if (this.disposed) throw new Error('EXTERNAL_KB_CANCELLED') }
|
||||
private cancelInstance(id: string): void {
|
||||
for (const [controller, instanceId] of this.controllers) if (instanceId === id) controller.abort()
|
||||
}
|
||||
private instance(id:string): StoredExternalInstance {
|
||||
this.checkActive()
|
||||
const value=this.database.externalStore.getInstance(id)
|
||||
if (!value) throw new Error('EXTERNAL_KB_NOT_FOUND')
|
||||
return value
|
||||
}
|
||||
listInstances(): ExternalKnowledgeInstanceSummary[] {
|
||||
this.checkActive()
|
||||
const bindings=this.database.externalStore.listBindings()
|
||||
return this.database.externalStore.listInstances().map(({credential,...value})=>{
|
||||
let credentialStatus: ExternalKnowledgeInstanceSummary['credentialStatus'] = 'missing'
|
||||
if (credential) {
|
||||
try { this.credential({ ...value, credential }); credentialStatus = 'configured' }
|
||||
catch { credentialStatus = 'unavailable' }
|
||||
}
|
||||
return {...value,credentialStatus,bindingCount:bindings.filter(binding=>binding.instanceId===value.id).length}
|
||||
})
|
||||
}
|
||||
private credential(instance: StoredExternalInstance): string {
|
||||
try {
|
||||
if (!instance.credential || !this.cipher?.isAvailable()) throw new Error()
|
||||
const key = decryptSettingsCredential(this.cipher, instance.credential)
|
||||
if (typeof key !== 'string' || !key.trim()) throw new Error()
|
||||
return key
|
||||
} catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
|
||||
}
|
||||
saveInstance(raw:ExternalKnowledgeInstanceSaveInput):ExternalKnowledgeInstanceSummary {
|
||||
this.checkActive()
|
||||
const input=externalKnowledgeInstanceSaveInputSchema.parse(raw)
|
||||
const previous=input.id ? this.instance(input.id):undefined
|
||||
if (previous && previous.provider!==input.provider && this.database.externalStore.listBindings().some(binding=>binding.instanceId===previous.id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
|
||||
let credential=previous?.credential
|
||||
if (input.credential.action==='clear') credential=undefined
|
||||
if (input.credential.action==='replace') {
|
||||
if (!this.cipher?.isAvailable()) throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
try { credential=encryptSettingsCredential(this.cipher,input.credential.value) }
|
||||
catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
|
||||
}
|
||||
const value:StoredExternalInstance={id:previous?.id??randomUUID(),name:input.name,provider:input.provider,baseUrl:input.baseUrl.replace(/\/+$/,''),enabled:input.enabled,credential,probeStatus:'untested'}
|
||||
this.database.externalStore.saveInstance(value)
|
||||
this.cancelInstance(value.id)
|
||||
return this.listInstances().find(item=>item.id===value.id)!
|
||||
}
|
||||
setEnabled(id:string,enabled:boolean):ExternalKnowledgeInstanceSummary {
|
||||
const instance=this.instance(id)
|
||||
this.database.externalStore.saveInstance({...instance,enabled})
|
||||
if (!enabled) for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
|
||||
return this.listInstances().find(item=>item.id===id)!
|
||||
}
|
||||
deleteInstance(id:string):void {
|
||||
this.instance(id)
|
||||
if(this.database.externalStore.listBindings().some(binding=>binding.instanceId===id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
|
||||
for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
|
||||
this.database.externalStore.deleteInstance(id)
|
||||
}
|
||||
private async request<T>(id:string,timeout:number,signal:AbortSignal|undefined,operation:(client:ExternalKnowledgeClient,signal:AbortSignal)=>Promise<T>):Promise<T> {
|
||||
const instance=this.instance(id)
|
||||
if(!instance.enabled) throw new Error('EXTERNAL_KB_DISABLED')
|
||||
const key = this.credential(instance)
|
||||
const controller=new AbortController()
|
||||
this.controllers.set(controller,id)
|
||||
const timeoutSignal = AbortSignal.timeout(timeout)
|
||||
const combined = AbortSignal.any([controller.signal, timeoutSignal, ...(signal ? [signal] : [])])
|
||||
try {
|
||||
combined.throwIfAborted()
|
||||
const result = await operation(new ExternalKnowledgeClient({provider:instance.provider,baseUrl:instance.baseUrl,apiKey:key,timeoutMs:timeout,fetcher:this.fetcher}),combined)
|
||||
combined.throwIfAborted()
|
||||
return result
|
||||
} catch (error) {
|
||||
if (combined.aborted) {
|
||||
const timedOut = timeoutSignal.aborted && combined.reason === timeoutSignal.reason
|
||||
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
|
||||
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
|
||||
}
|
||||
throw error
|
||||
} finally {this.controllers.delete(controller)}
|
||||
}
|
||||
async testInstance(id:string):Promise<ExternalKnowledgeInstanceSummary> {
|
||||
const instance=this.instance(id)
|
||||
let probeStatus:StoredExternalInstance['probeStatus']='catalog-ready'
|
||||
let lastErrorCode:string|undefined
|
||||
try {await this.listCatalog({instanceId:id})} catch(error) {
|
||||
lastErrorCode=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_/.test(error.message) ? error.message : 'EXTERNAL_KB_NETWORK'
|
||||
probeStatus=lastErrorCode==='EXTERNAL_KB_FORBIDDEN'?'list-restricted':lastErrorCode==='EXTERNAL_KB_AUTH'?'auth-failed':lastErrorCode==='EXTERNAL_KB_NETWORK'||lastErrorCode==='EXTERNAL_KB_TIMEOUT'?'unreachable':'failed'
|
||||
}
|
||||
const current = this.instance(id)
|
||||
if (instanceConfiguration(current) !== instanceConfiguration(instance)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
this.database.externalStore.saveInstance({...current,probeStatus,lastErrorCode,lastTestedAt:new Date().toISOString()})
|
||||
return this.listInstances().find(item=>item.id===id)!
|
||||
}
|
||||
listCatalog(input:{instanceId:string;page?:number;pageSize?:number;parentId?:string|null;search?:string},signal?:AbortSignal) {
|
||||
return this.request(input.instanceId,15000,signal,(client,current)=>client.listKnowledgeBases(current,input))
|
||||
}
|
||||
getCatalog(input:{instanceId:string;remoteKnowledgeBaseId:string},signal?:AbortSignal) {
|
||||
return this.request(input.instanceId,15000,signal,(client,current)=>client.getKnowledgeBase(input.remoteKnowledgeBaseId,current))
|
||||
}
|
||||
async testRetrieval(raw:ExternalKnowledgeBindingTestInput,signal?:AbortSignal):Promise<ExternalKnowledgeTestResult> {
|
||||
const input=externalKnowledgeBindingTestInputSchema.parse(raw)
|
||||
const instance=this.instance(input.instanceId)
|
||||
if(instance.provider!==input.providerConfig.provider) throw new Error('EXTERNAL_KB_CONFIG_INVALID')
|
||||
const started=Date.now()
|
||||
const results=await this.request(input.instanceId,input.commonConfig.requestTimeoutMs,signal,async (client,current)=>{
|
||||
if(input.providerConfig.provider==='ragflow' && (input.providerConfig.useKg||input.providerConfig.includeKnowledgeCompilation)) {
|
||||
const detail=await client.getKnowledgeBase(input.remoteKnowledgeBaseId,current)
|
||||
if((input.providerConfig.useKg&&!detail.graphEnabled)||(input.providerConfig.includeKnowledgeCompilation&&!detail.knowledgeCompilationEnabled)) throw new Error('EXTERNAL_KB_CAPABILITY_UNAVAILABLE')
|
||||
}
|
||||
return client.retrieve(input.remoteKnowledgeBaseId,input.testQuery,input.providerConfig,current)
|
||||
})
|
||||
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
let remaining = 48_000
|
||||
const bounded = results.slice(0, input.commonConfig.resultLimit).map(item => {
|
||||
const snippet = item.snippet.slice(0, Math.min(remaining, input.commonConfig.maxSnippetCharacters))
|
||||
remaining -= snippet.length
|
||||
return { ...item, snippet }
|
||||
}).filter(item => item.snippet.length > 0)
|
||||
return {results:bounded,durationMs:Date.now()-started}
|
||||
}
|
||||
async saveBinding(raw:ExternalKnowledgeBindingSaveInput & {knowledgeBaseId?:string}):Promise<ExternalKnowledgeBinding> {
|
||||
this.checkActive()
|
||||
const input=raw.knowledgeBaseId !== undefined ? externalKnowledgeBindingUpdateInputSchema.parse(raw):externalKnowledgeBindingSaveInputSchema.parse(raw)
|
||||
const knowledgeBaseId = 'knowledgeBaseId' in input ? input.knowledgeBaseId : undefined
|
||||
const existing=knowledgeBaseId ? this.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===knowledgeBaseId):undefined
|
||||
if(knowledgeBaseId&&!existing) throw new Error('EXTERNAL_KB_NOT_FOUND')
|
||||
if(this.database.externalStore.listBindings().some(item=>item.instanceId===input.instanceId&&item.remoteKnowledgeBaseId===input.remoteKnowledgeBaseId&&item.knowledgeBaseId!==existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
|
||||
const instance = this.instance(input.instanceId)
|
||||
await this.testRetrieval({instanceId:input.instanceId,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,commonConfig:input.commonConfig,providerConfig:input.providerConfig,testQuery:input.testQuery})
|
||||
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
const bindings = this.database.externalStore.listBindings()
|
||||
if (existing && JSON.stringify(bindings.find(item => item.knowledgeBaseId === existing.knowledgeBaseId)) !== JSON.stringify(existing)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
if (bindings.some(item => item.instanceId === input.instanceId && item.remoteKnowledgeBaseId === input.remoteKnowledgeBaseId && item.knowledgeBaseId !== existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
|
||||
return this.database.saveExternalBinding({instanceId:input.instanceId,provider:input.providerConfig.provider,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,remoteName:input.remoteName,commonConfig:input.commonConfig,providerConfig:input.providerConfig,lastVerifiedAt:new Date().toISOString()}, {name:input.name,description:input.description,knowledgeBaseId:existing?.knowledgeBaseId})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import type { ExternalKnowledgeBinding, ExternalKnowledgeInstanceSummary } from '../../../shared/external-knowledge-contracts'
|
||||
import type { EncryptedSettingsCredential } from '../../settings-credential-cipher'
|
||||
|
||||
export type StoredExternalInstance = Omit<ExternalKnowledgeInstanceSummary, 'credentialStatus' | 'bindingCount'> & {credential?:EncryptedSettingsCredential}
|
||||
|
||||
export class ExternalKnowledgeStore {
|
||||
constructor(private readonly database: DatabaseSync) {}
|
||||
getInstance(id: string): StoredExternalInstance | undefined {
|
||||
const row = this.database.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(id)
|
||||
return row ? JSON.parse(String(row.value_json)) as StoredExternalInstance : undefined
|
||||
}
|
||||
listInstances(): StoredExternalInstance[] {
|
||||
return this.database.prepare('SELECT value_json FROM external_knowledge_instances ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as StoredExternalInstance)
|
||||
}
|
||||
saveInstance(value: StoredExternalInstance): void {
|
||||
this.database.prepare('INSERT INTO external_knowledge_instances(id,value_json) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET value_json=excluded.value_json').run(value.id, JSON.stringify(value))
|
||||
}
|
||||
deleteInstance(id: string): void {
|
||||
this.database.prepare('DELETE FROM external_knowledge_instances WHERE id=?').run(id)
|
||||
}
|
||||
listBindings(): ExternalKnowledgeBinding[] {
|
||||
return this.database.prepare('SELECT value_json FROM external_knowledge_bindings ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as ExternalKnowledgeBinding)
|
||||
}
|
||||
saveBinding(value: ExternalKnowledgeBinding): void {
|
||||
this.database.prepare('INSERT INTO external_knowledge_bindings(knowledge_base_id,instance_id,remote_id,value_json) VALUES (?,?,?,?) ON CONFLICT(knowledge_base_id) DO UPDATE SET instance_id=excluded.instance_id,remote_id=excluded.remote_id,value_json=excluded.value_json').run(value.knowledgeBaseId,value.instanceId,value.remoteKnowledgeBaseId,JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe('KnowledgeDatabase', () => {
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(
|
||||
inspection.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 11 })
|
||||
).toEqual({ user_version: 12 })
|
||||
expect(
|
||||
inspection
|
||||
.prepare('SELECT version FROM schema_migrations ORDER BY version')
|
||||
@@ -102,7 +102,8 @@ describe('KnowledgeDatabase', () => {
|
||||
{ version: 8 },
|
||||
{ version: 9 },
|
||||
{ version: 10 },
|
||||
{ version: 11 }
|
||||
{ version: 11 },
|
||||
{ version: 12 }
|
||||
])
|
||||
inspection.close()
|
||||
|
||||
@@ -143,14 +144,14 @@ describe('KnowledgeDatabase', () => {
|
||||
expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1)
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 11
|
||||
user_version: 12
|
||||
})
|
||||
inspection.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true],
|
||||
['user version', 'PRAGMA user_version = 12', false]
|
||||
['migration version', 'INSERT INTO schema_migrations VALUES (13, ?)', true],
|
||||
['user version', 'PRAGMA user_version = 13', false]
|
||||
])('rejects a future %s without downgrading it', async (
|
||||
_label,
|
||||
statement,
|
||||
@@ -168,18 +169,18 @@ describe('KnowledgeDatabase', () => {
|
||||
|
||||
const unsupported = new KnowledgeDatabase(path)
|
||||
expect(() => unsupported.initialize()).toThrow(
|
||||
'newer than supported version 11'
|
||||
'newer than supported version 12'
|
||||
)
|
||||
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: hasParameter ? 11 : 12
|
||||
user_version: hasParameter ? 12 : 13
|
||||
})
|
||||
expect(
|
||||
inspection
|
||||
.prepare('SELECT MAX(version) AS version FROM schema_migrations')
|
||||
.get()
|
||||
).toEqual({ version: hasParameter ? 12 : 11 })
|
||||
).toEqual({ version: hasParameter ? 13 : 12 })
|
||||
inspection.close()
|
||||
})
|
||||
|
||||
@@ -319,7 +320,7 @@ describe('KnowledgeDatabase', () => {
|
||||
DROP TABLE chunk_embeddings;
|
||||
DROP TABLE knowledge_tasks;
|
||||
DELETE FROM schema_migrations
|
||||
WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
|
||||
WHERE version > 1;
|
||||
PRAGMA user_version = 1;
|
||||
`)
|
||||
downgrade.close()
|
||||
@@ -329,7 +330,7 @@ describe('KnowledgeDatabase', () => {
|
||||
upgraded.initialize()
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 11
|
||||
user_version: 12
|
||||
})
|
||||
expect(
|
||||
inspection
|
||||
@@ -436,7 +437,7 @@ describe('KnowledgeDatabase', () => {
|
||||
WHERE new.enabled = 1 AND new.role <> 'parent';
|
||||
END;
|
||||
INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild');
|
||||
DELETE FROM schema_migrations WHERE version IN (9, 10, 11);
|
||||
DELETE FROM schema_migrations WHERE version > 8;
|
||||
PRAGMA user_version = 8;
|
||||
`)
|
||||
downgrade
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { ExternalKnowledgeStore } from './external/external-knowledge-store'
|
||||
import { DatabaseSync, type StatementSync } from 'node:sqlite'
|
||||
import {
|
||||
embeddingIndexJobSchema,
|
||||
@@ -80,7 +81,7 @@ import type {
|
||||
VectorSearchOptions
|
||||
} from './types'
|
||||
|
||||
const DATABASE_VERSION = 11
|
||||
const DATABASE_VERSION = 12
|
||||
const MAX_ID_LENGTH = 128
|
||||
const MAX_NAME_LENGTH = 512
|
||||
const MAX_LOCATION_LENGTH = 8192
|
||||
@@ -739,6 +740,7 @@ function mapKnowledgeTask(row: Row): KnowledgeTaskItem {
|
||||
}
|
||||
|
||||
export class KnowledgeDatabase {
|
||||
externalStore!: ExternalKnowledgeStore
|
||||
private database?: DatabaseSync
|
||||
private readonly taskPrunedLibraries = new Set<string>()
|
||||
private readonly taskPrunePending = new Set<string>()
|
||||
@@ -765,6 +767,7 @@ export class KnowledgeDatabase {
|
||||
this.assertFts5(database)
|
||||
this.migrate(database)
|
||||
this.database = database
|
||||
this.externalStore = new ExternalKnowledgeStore(database)
|
||||
this.interruptActiveKnowledgeTasks()
|
||||
database
|
||||
.prepare('DELETE FROM embedding_rebuild_staging')
|
||||
@@ -903,11 +906,30 @@ export class KnowledgeDatabase {
|
||||
return row ? mapKnowledgeBase(row) : undefined
|
||||
}
|
||||
|
||||
saveExternalBinding(
|
||||
binding: Omit<import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding, 'knowledgeBaseId'>,
|
||||
metadata: { name: string; description?: string; knowledgeBaseId?: string }
|
||||
): import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding {
|
||||
let saved!: import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding
|
||||
this.transaction(this.requireDatabase(), () => {
|
||||
const library = metadata.knowledgeBaseId
|
||||
? this.updateKnowledgeBase(metadata.knowledgeBaseId, { name: metadata.name, description: metadata.description })
|
||||
: this.createKnowledgeBase({ name: metadata.name, description: metadata.description, storageMode: 'reference', graphEnabled: false })
|
||||
saved = { ...binding, knowledgeBaseId: library.id }
|
||||
this.externalStore.saveBinding(saved)
|
||||
})
|
||||
return saved
|
||||
}
|
||||
|
||||
updateKnowledgeBase(
|
||||
id: string,
|
||||
input: UpdateKnowledgeBaseInput
|
||||
): KnowledgeBase {
|
||||
const current = this.requiredKnowledgeBase(id)
|
||||
if (this.externalStore.listBindings().some(item => item.knowledgeBaseId === id) &&
|
||||
(input.graphEnabled !== undefined || input.graphStrategy !== undefined || input.storageMode !== undefined)) {
|
||||
throw new Error('EXTERNAL_KB_READ_ONLY')
|
||||
}
|
||||
const name =
|
||||
input.name === undefined
|
||||
? current.name
|
||||
@@ -4587,6 +4609,19 @@ export class KnowledgeDatabase {
|
||||
)
|
||||
.run(11, new Date().toISOString())
|
||||
}
|
||||
if (currentVersion < 12) {
|
||||
database.exec(`CREATE TABLE IF NOT EXISTS external_knowledge_instances (
|
||||
id TEXT PRIMARY KEY, value_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS external_knowledge_bindings (
|
||||
knowledge_base_id TEXT PRIMARY KEY REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
instance_id TEXT NOT NULL REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
|
||||
remote_id TEXT NOT NULL, value_json TEXT NOT NULL,
|
||||
UNIQUE(instance_id, remote_id)
|
||||
);`)
|
||||
database.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
|
||||
.run(12, new Date().toISOString())
|
||||
}
|
||||
database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
|
||||
@@ -1113,6 +1113,42 @@ describe('KnowledgeService', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('retains ranked evidence from each external library within the shared budget', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const service = new KnowledgeService({
|
||||
databasePath: join(directory, 'knowledge.sqlite'),
|
||||
managedRoot: join(directory, 'managed'),
|
||||
credentialCipher: {
|
||||
isAvailable: () => true,
|
||||
encrypt: value => Buffer.from(value),
|
||||
decrypt: value => value.toString()
|
||||
},
|
||||
externalFetcher: async () => Response.json({ records: Array.from({ length: 6 }, (_, index) => ({
|
||||
segment: { id: `chunk-${index}`, content: 'e'.repeat(8000), document: { id: 'document', name: 'Handbook' } }
|
||||
})) })
|
||||
})
|
||||
services.push(service)
|
||||
await service.initialize()
|
||||
const instance = service.external.saveInstance({
|
||||
name: 'Test', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
|
||||
credential: { action: 'replace', value: 'test-key' }
|
||||
})
|
||||
const bindings = []
|
||||
for (const remoteKnowledgeBaseId of ['first', 'second']) {
|
||||
bindings.push(await service.external.saveBinding({
|
||||
instanceId: instance.id, remoteKnowledgeBaseId, name: remoteKnowledgeBaseId,
|
||||
remoteName: remoteKnowledgeBaseId, testQuery: 'policy',
|
||||
commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 },
|
||||
providerConfig: { provider: 'dify', useDatasetDefaults: true }
|
||||
}))
|
||||
}
|
||||
const outcomes = await service.retrieveMany(bindings.map(binding => binding.knowledgeBaseId), 'policy')
|
||||
expect(outcomes.map(({ response }) => response.results.map(result => result.rank))).toEqual([[1, 2, 3], [1, 2, 3]])
|
||||
expect(outcomes.map(({ response }) => response.context.characterCount)).toEqual([24000, 24000])
|
||||
expect(outcomes.every(({ response }) => response.context.truncated)).toBe(true)
|
||||
})
|
||||
|
||||
it('reranks multiple libraries concurrently while preserving input order', async () => {
|
||||
let releaseReranks: (() => void) | undefined
|
||||
let startedReranks = 0
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExternalKnowledgeService } from './external/external-knowledge-service'
|
||||
import { ExternalKnowledgeError } from './external/external-knowledge-client'
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
@@ -144,6 +146,8 @@ export type KnowledgeSnapshot = {
|
||||
}
|
||||
|
||||
export type KnowledgeServiceOptions = {
|
||||
credentialCipher?: import('../settings-credential-cipher').SettingsCredentialCipher
|
||||
externalFetcher?: typeof fetch
|
||||
databasePath: string
|
||||
managedRoot: string
|
||||
extractStructured?: ExtractStructured
|
||||
@@ -208,6 +212,7 @@ function embedKnowledgeQuery(
|
||||
}
|
||||
|
||||
export class KnowledgeService {
|
||||
readonly external: ExternalKnowledgeService
|
||||
readonly database: KnowledgeDatabase
|
||||
private readonly managedRoot: string
|
||||
private readonly extractStructured?: ExtractStructured
|
||||
@@ -255,6 +260,7 @@ export class KnowledgeService {
|
||||
|
||||
constructor(options: KnowledgeServiceOptions) {
|
||||
this.database = new KnowledgeDatabase(options.databasePath)
|
||||
this.external = new ExternalKnowledgeService(this.database, options.credentialCipher, options.externalFetcher)
|
||||
this.managedRoot = resolve(options.managedRoot)
|
||||
this.extractStructured = options.extractStructured
|
||||
this.urlImporter = options.urlImporter ?? new UrlImporter()
|
||||
@@ -293,6 +299,7 @@ export class KnowledgeService {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.external.dispose()
|
||||
this.lifecycleController.abort(
|
||||
new Error('Knowledge service is shutting down')
|
||||
)
|
||||
@@ -440,6 +447,7 @@ export class KnowledgeService {
|
||||
private async getEmbeddingIndexCoordinator(
|
||||
knowledgeBaseId: string
|
||||
): Promise<EmbeddingIndexCoordinator> {
|
||||
this.requireLibrary(knowledgeBaseId)
|
||||
const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -1234,9 +1242,23 @@ export class KnowledgeService {
|
||||
signal?: AbortSignal,
|
||||
preparedQueryEmbedding?: PreparedQueryEmbedding
|
||||
): Promise<KnowledgeRetrievalResponse> {
|
||||
signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
|
||||
signal.throwIfAborted()
|
||||
const input = knowledgeRetrieveInputSchema.parse(rawInput)
|
||||
const library = this.requireLibrary(input.knowledgeBaseId)
|
||||
const library = this.requireLibrary(input.knowledgeBaseId, false)
|
||||
const settings = input.settings ?? library.retrievalSettings
|
||||
const binding = this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)
|
||||
if (binding) {
|
||||
const response = await this.external.testRetrieval({instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,commonConfig:binding.commonConfig,providerConfig:binding.providerConfig,testQuery:input.query}, signal)
|
||||
signal.throwIfAborted()
|
||||
if (JSON.stringify(this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)) !== JSON.stringify(binding)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
return {
|
||||
query:input.query,durationMs:response.durationMs,settings,
|
||||
diagnostics:{external:{provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId},requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},
|
||||
results:response.results.map((item,index)=>({knowledgeBaseId:library.id,documentTitle:item.documentTitle,sourceDisplayName:item.sourceDisplayName,snippet:item.snippet,rank:index+1,channels:[],scores:{fusedScore:0},external:{kind:'external' as const,provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,remoteDocumentId:item.remoteDocumentId,remoteChunkId:item.remoteChunkId,providerScore:item.providerScore,providerScores:item.providerScores,location:item.location,sourceUrl:item.sourceUrl}})),
|
||||
context:{characterCount:response.results.reduce((sum,item)=>sum+item.snippet.length,0),truncated:false,groups:[]}
|
||||
}
|
||||
}
|
||||
const hasHanQuery = containsHanText(input.query)
|
||||
const startedAt = Date.now()
|
||||
const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = []
|
||||
@@ -1665,32 +1687,64 @@ export class KnowledgeService {
|
||||
response: KnowledgeRetrievalResponse
|
||||
}>
|
||||
> {
|
||||
const libraries = knowledgeBaseIds.map((id) =>
|
||||
this.requireLibrary(id)
|
||||
signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
|
||||
signal.throwIfAborted()
|
||||
const libraries = [...new Set(knowledgeBaseIds)].map((id) =>
|
||||
this.requireLibrary(id, false)
|
||||
)
|
||||
const preparedEmbedding = libraries.some(
|
||||
(library) => library.retrievalSettings.vectorWeight > 0
|
||||
(library) => !this.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) && library.retrievalSettings.vectorWeight > 0
|
||||
)
|
||||
? await this.prepareQueryEmbedding(query, signal)
|
||||
: undefined
|
||||
signal?.throwIfAborted()
|
||||
return Promise.all(
|
||||
libraries.map(async (library) => ({
|
||||
knowledgeBaseId: library.id,
|
||||
response: await this.retrieve(
|
||||
const outcomes = await Promise.all(
|
||||
libraries.map(async (library) => {
|
||||
try { return { knowledgeBaseId: library.id, response: await this.retrieve(
|
||||
{
|
||||
knowledgeBaseId: library.id,
|
||||
query
|
||||
},
|
||||
signal,
|
||||
preparedEmbedding
|
||||
)
|
||||
}))
|
||||
)} } catch(error) {
|
||||
signal?.throwIfAborted()
|
||||
const failure=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_[A-Z_]+$/.test(error.message) ? error.message : 'Knowledge retrieval failed'
|
||||
return {knowledgeBaseId:library.id,response:{query,durationMs:0,settings:library.retrievalSettings,diagnostics:{failure,requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},results:[],context:{characterCount:0,truncated:false,groups:[]}} as KnowledgeRetrievalResponse}
|
||||
}
|
||||
})
|
||||
)
|
||||
let remaining = 48_000
|
||||
let remainingContext = 48_000
|
||||
// Spend the shared budget by rank so one library cannot crowd out later libraries.
|
||||
const ranked = outcomes.flatMap(({ response }) =>
|
||||
response.results.map(result => ({ response, result }))
|
||||
).sort((left, right) => left.result.rank - right.result.rank)
|
||||
for (const { response, result } of ranked) {
|
||||
const snippet = result.snippet.slice(0, remaining)
|
||||
if (snippet.length < result.snippet.length) response.context.truncated = true
|
||||
result.snippet = snippet
|
||||
remaining -= snippet.length
|
||||
const group = response.context.groups.find(item => item.resultChunkId === result.chunkId)
|
||||
if (!group) continue
|
||||
const content = group.content.slice(0, remainingContext)
|
||||
if (content.length < group.content.length) response.context.truncated = true
|
||||
group.content = content
|
||||
remainingContext -= content.length
|
||||
}
|
||||
for (const { response } of outcomes) {
|
||||
response.results = response.results.filter(result => result.snippet.length > 0)
|
||||
response.context.groups = response.context.groups.filter(group => group.content.length > 0)
|
||||
response.context.characterCount = response.context.groups.length
|
||||
? response.context.groups.reduce((sum, group) => sum + group.content.length, 0)
|
||||
: response.results.reduce((sum, result) => sum + result.snippet.length, 0)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
updateSettings(rawInput: KnowledgeSettingsUpdateInput): KnowledgeBase {
|
||||
const input = knowledgeSettingsUpdateInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.database.updateKnowledgeSettings(input)
|
||||
}
|
||||
|
||||
@@ -1704,6 +1758,7 @@ export class KnowledgeService {
|
||||
rawInput: KnowledgeChunkUpdateInput
|
||||
): Promise<ReturnType<KnowledgeDatabase['updateChunk']>> {
|
||||
const input = knowledgeChunkUpdateInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.withDocumentMutation(input.documentId, async () => {
|
||||
const current = this.database.getChunkForReference(
|
||||
input.knowledgeBaseId,
|
||||
@@ -1733,6 +1788,7 @@ export class KnowledgeService {
|
||||
|
||||
async deleteChunk(rawInput: KnowledgeChunkDeleteInput): Promise<boolean> {
|
||||
const input = knowledgeChunkDeleteInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.withDocumentMutation(input.documentId, async () => {
|
||||
const deleted = this.database.deleteChunk(input)
|
||||
if (deleted) {
|
||||
@@ -3806,7 +3862,10 @@ export class KnowledgeService {
|
||||
: candidate
|
||||
}
|
||||
|
||||
private requireLibrary(id: string): KnowledgeBase {
|
||||
private requireLibrary(id: string, localOnly = true): KnowledgeBase {
|
||||
if (localOnly && this.database.externalStore.listBindings().some(item => item.knowledgeBaseId === id)) {
|
||||
throw new Error('EXTERNAL_KB_READ_ONLY')
|
||||
}
|
||||
const library = this.database.getKnowledgeBase(id)
|
||||
if (!library) {
|
||||
throw new Error('知识库不存在')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { remoteQuestionResponseSchema } from '../../shared/remote-question-contracts'
|
||||
import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
@@ -187,6 +188,10 @@ export const AGENT_PROTOCOL_METHODS = {
|
||||
params: remoteOwnedPromptAttachRequestSchema,
|
||||
result: remoteOwnedPromptStartResultSchema
|
||||
},
|
||||
'runtime/respondToQuestion': {
|
||||
params: remoteQuestionResponseSchema,
|
||||
result: z.object({}).strict()
|
||||
},
|
||||
'runtime/pagePromptTranscript': {
|
||||
params: remoteSemanticTranscriptPageRequestSchema,
|
||||
result: remoteSemanticTranscriptPageResultSchema
|
||||
|
||||
@@ -453,6 +453,10 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
return this.remote.getStatus()
|
||||
}
|
||||
|
||||
respondToQuestion(questionId: string, answers: string[][] = []): Promise<void> {
|
||||
return this.remote.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
|
||||
@@ -489,6 +489,17 @@ async function openChannel(fixture = connectionFixture()) {
|
||||
}
|
||||
|
||||
describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
it('sends question replies on the authenticated control channel and rejects foreign bindings', async () => {
|
||||
const fixture = await openChannel()
|
||||
const response = { bindingId: 'binding-1', operationId: 'operation-1', questionId: 'question-1', answers: [] }
|
||||
await expect(fixture.channel.respondToQuestion({ ...response, bindingId: 'other' })).rejects.toThrow('binding identity')
|
||||
await fixture.channel.respondToQuestion(response)
|
||||
expect(fixture.client.requests.filter(request => request.method === 'runtime/respondToQuestion'))
|
||||
.toEqual([expect.objectContaining({ method: 'runtime/respondToQuestion', params: response })])
|
||||
await fixture.channel.close()
|
||||
await expect(fixture.channel.respondToQuestion(response)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('maps the complete Agent-owned prompt RPC set', async () => {
|
||||
const fixture = await openChannel()
|
||||
const identity = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { remoteQuestionResponseSchema, type RemoteQuestionResponse } from '../../shared/remote-question-contracts'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
ChannelProtocolError,
|
||||
@@ -679,6 +680,13 @@ export class ProtocolRemoteRuntimeChannel
|
||||
)
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const parsed = remoteQuestionResponseSchema.parse(request)
|
||||
this.#assertOwnedPromptIdentity(parsed)
|
||||
await requestWithTimeout(this.#state.client, 'runtime/respondToQuestion', parsed,
|
||||
this.#state.controlTimeoutMs)
|
||||
}
|
||||
|
||||
async pageOwnedPromptTranscript(
|
||||
request: RemoteSemanticTranscriptPageRequest
|
||||
): Promise<RemoteSemanticTranscriptPageResult> {
|
||||
|
||||
Reference in New Issue
Block a user