feat: enhance local knowledge retrieval

This commit is contained in:
lofyer
2026-08-12 21:45:47 +08:00
parent e0e5a8c1b3
commit 111f487e20
65 changed files with 13517 additions and 1155 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ export type ContinueHostAdapterOptions = {
}
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'plan' | 'execute'
workMode?: 'ask' | 'execute'
images?: AgentImage[]
knowledgeCapability?: {
endpoint: string
+1 -1
View File
@@ -35,7 +35,7 @@ function createRuntime(): ContinueAgentRuntime {
async function collectEvents(
runtime: ContinueAgentRuntime,
workMode?: 'ask' | 'plan' | 'execute'
workMode?: 'ask' | 'execute'
): Promise<RuntimeEvent[]> {
const events: RuntimeEvent[] = []
for await (const event of runtime.run(
+3
View File
@@ -61,6 +61,9 @@ function settings(
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
workspacePath: process.cwd(),
toolApproval: 'always',
...overrides
+9 -1
View File
@@ -26,11 +26,16 @@ function createService() {
displayName: `来源 ${index}`,
location: `/private/${index}`
},
chunk: { location: `${index + 1}` },
chunk: {
id: `44444444-4444-4444-8444-44444444444${index}`,
location: `${index + 1}`
},
snippet: `<mark>匹配</mark> ${index}`,
rank: index + 1,
retrieval: {
score: 0.5,
channels: ['fts'] as const,
lexicalRank: 1,
evidenceIds: []
}
}
@@ -115,9 +120,12 @@ describe('KnowledgeMcpGateway', () => {
expect.objectContaining({
libraryId: secondLibraryId,
libraryName: '二号知识库',
chunkId: '44444444-4444-4444-8444-444444444440',
score: 0.5,
snippet: '匹配 0'
})
])
expect(references[0]?.sourceLocation).toBeUndefined()
expect(gateway.drainReferences(token)).toEqual(references)
expect(gateway.drainReferences(token)).toEqual([])
await expect(
+9 -6
View File
@@ -9,6 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
import type { KnowledgeSearchReference } from '../../shared/contracts'
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import type {
MagicNoteDetail,
@@ -236,15 +237,12 @@ function referenceKey(reference: KnowledgeSearchReference): string {
return [
reference.libraryId,
reference.documentId,
reference.chunkId ?? '',
reference.locator ?? '',
reference.snippet
].join('\0')
}
function stripMarkTags(value: string): string {
return value.replace(/<\/?mark\b[^>]*>/giu, '')
}
function sendJson(
response: ServerResponse,
status: number,
@@ -465,12 +463,17 @@ export class KnowledgeMcpGateway {
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),
sourceLocation: result.source.location?.slice(0, 4_096),
locator: result.chunk.location?.slice(0, 1_000),
snippet: stripMarkTags(result.snippet).slice(0, 12_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)
}
+4 -7
View File
@@ -453,9 +453,7 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.listTools).not.toHaveBeenCalled()
})
it.each(['ask', 'plan'] as const)(
'keeps browser and workspace tools out of %s mode',
async (workMode) => {
it('keeps browser and workspace tools out of Ask mode', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', {
status: 200,
@@ -475,9 +473,9 @@ describe('ModelAgentRuntime', () => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: `conversation-${workMode}`,
conversationId: 'conversation-ask',
prompt: '只读',
workMode
workMode: 'ask'
},
new AbortController().signal
)) {
@@ -486,8 +484,7 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.listTools).not.toHaveBeenCalled()
expect(toolProvider.callTool).not.toHaveBeenCalled()
}
)
})
it('uses the OpenAI Responses endpoint and streams output text', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
+18 -27
View File
@@ -461,27 +461,25 @@ describe('ModelToolProvider', () => {
} satisfies ModelToolCallContext
const signal = new AbortController().signal
for (const workMode of ['ask', 'plan'] as const) {
const readOnlyContext = {
conversationId: `browser-${workMode}`,
workMode
} satisfies ModelToolCallContext
await expect(
provider.listTools(readOnlyContext, signal)
).resolves.not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'browser_screenshot' })
])
const readOnlyContext = {
conversationId: 'browser-ask',
workMode: 'ask'
} satisfies ModelToolCallContext
await expect(
provider.listTools(readOnlyContext, signal)
).resolves.not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'browser_screenshot' })
])
)
await expect(
provider.callTool(
'browser_screenshot',
{},
signal,
readOnlyContext
)
await expect(
provider.callTool(
'browser_screenshot',
{},
signal,
readOnlyContext
)
).rejects.toThrow('未知工具')
}
).rejects.toThrow('未知工具')
expect(browserService.screenshot).not.toHaveBeenCalled()
const tools = await provider.listTools(firstContext, signal)
@@ -603,13 +601,6 @@ describe('ModelToolProvider', () => {
source: 'builtin'
})
])
await expect(
provider.listTools(
{ ...askContext, workMode: 'plan' },
signal
)
).resolves.toEqual([])
await provider.callTool(
'web_search',
{ query: 'GoodBuddy current release', numResults: 3 },
+4 -5
View File
@@ -208,7 +208,7 @@ export type ModelToolResult = {
export type ModelToolCallContext = {
conversationId: string
workMode: 'ask' | 'plan' | 'execute'
workMode: 'ask' | 'execute'
knowledgeCapabilityToken?: string
}
@@ -1218,10 +1218,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
): Promise<ModelToolDefinition[]> {
signal.throwIfAborted()
const scopedTools = this.getScopedTools(context)
const webTools =
this.webSearchEnabled && context.workMode !== 'plan'
? this.getWebSearchDefinitions()
: []
const webTools = this.webSearchEnabled
? this.getWebSearchDefinitions()
: []
if (context.workMode !== 'execute') {
return [...webTools, ...scopedTools]
}
+4 -7
View File
@@ -274,7 +274,7 @@ function embeddedRuntime(
async function collectRun(
runtime: OpenCodeRuntime,
workMode: 'ask' | 'plan' | 'execute' = 'execute'
workMode: 'ask' | 'execute' = 'execute'
) {
const events = []
for await (const event of runtime.run(
@@ -2083,9 +2083,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it.each(['ask', 'plan'] as const)(
'uses deny-all session rules and hard tool disable in %s mode',
async (workMode) => {
it('uses deny-all session rules and hard tool disable in Ask mode', async () => {
const { client, session, tool } = runClient([
{
id: 'event-idle',
@@ -2095,7 +2093,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
])
const runtime = embeddedRuntime(client)
await collectRun(runtime, workMode)
await collectRun(runtime, 'ask')
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
@@ -2119,8 +2117,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.anything()
)
await runtime.dispose()
}
)
})
it('updates reused sessions when the work mode changes', async () => {
const { client, session } = runClient([
+3 -6
View File
@@ -160,9 +160,7 @@ describe('AgentRuntimeController', () => {
await stream.return()
})
it.each(['ask', 'plan'] as const)(
'denies tool authorization in %s mode without prompting the user',
async (workMode) => {
it('denies tool authorization in Ask mode without prompting the user', async () => {
const runtime = new TestRuntime(false, false, true)
const controller = new AgentRuntimeController(runtime)
const authorize = vi.fn(async () => 'once' as const)
@@ -171,7 +169,7 @@ describe('AgentRuntimeController', () => {
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b09',
conversationId: 'conversation-3',
prompt: 'test',
workMode
workMode: 'ask'
},
new AbortController().signal,
authorize
@@ -179,8 +177,7 @@ describe('AgentRuntimeController', () => {
await expect(stream.next()).rejects.toThrow('tool denied')
expect(authorize).not.toHaveBeenCalled()
}
)
})
it('forwards per-tool authorization without adding a whole-run gate', async () => {
const runtime = new TestRuntime(false, false, true)
+2 -2
View File
@@ -1,9 +1,9 @@
import type {
AgentQuestionAnswer,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent
@@ -113,7 +113,7 @@ export class AgentRuntimeController implements AgentRuntime {
}
async *run(
request: AgentRequest,
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
+3
View File
@@ -78,6 +78,9 @@ function settings(
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'embedding',
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
workspacePath: process.cwd(),
toolApproval: 'always',
...overrides
+3 -1
View File
@@ -5,6 +5,7 @@ import type {
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { WorkMode } from '../../shared/assistant-contracts'
export type RuntimeApprovalRequest = {
scopeKey: string
@@ -72,7 +73,8 @@ export type AgentImage = {
data: string
}
export type AgentExecutionRequest = AgentRequest & {
export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
workMode?: WorkMode
images?: AgentImage[]
/** Main-process-only instructions placed in the model system layer. */
trustedInstructions?: string