fix: harden scoped tools and settings persistence

This commit is contained in:
lofyer
2026-08-13 14:56:53 +08:00
parent bf1ec5d2f1
commit aab961226f
62 changed files with 4343 additions and 1314 deletions
+1
View File
@@ -139,6 +139,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
readonly runtimeId = 'continue'
readonly requiresToolApproval = false
readonly supportsToolExecution = true
readonly supportsScopedDataTools = true
private detection?: Promise<RuntimeBinaryDetection>
private readonly hostAdapters = new Map<
RuntimeSettings['continueMode'],
+221
View File
@@ -1096,7 +1096,9 @@ describe('ModelAgentRuntime', () => {
tool_calls: [
{
index: 0,
id: '',
function: {
name: '',
arguments: '"README.md"}'
}
}
@@ -1219,6 +1221,86 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('synthesizes and pairs a missing OpenAI Chat tool call id', async () => {
const responses = [
{
id: 'chatcmpl-missing-call-id-1',
model: 'qwen3',
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
}
]
}
}
]
},
{
id: 'chatcmpl-missing-call-id-2',
model: 'qwen3',
choices: [
{
message: {
role: 'assistant',
content: '读取完成。'
}
}
]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed143',
conversationId: 'conversation-chat-fallback-id',
prompt: '读取 README',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { messages: Array<Record<string, unknown>> }
const assistant = secondBody.messages.at(-2) as {
tool_calls: Array<Record<string, unknown>>
}
const result = secondBody.messages.at(-1) as {
tool_call_id: string
}
const toolCallId = assistant.tool_calls[0]?.id
expect(toolCallId).toEqual(
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
)
expect(result).toMatchObject({
role: 'tool',
tool_call_id: toolCallId
})
})
it('uses refreshed tool definitions in subsequent model rounds', async () => {
const loadTool: ModelToolDefinition = {
name: 'mcp_load_tools',
@@ -1833,6 +1915,81 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('pairs a missing Responses call_id with the function-call item id', async () => {
const responses = [
{
id: 'resp-tool-fallback-1',
model: 'gpt-5',
output: [
{
id: 'fc-responses-fallback-1',
type: 'function_call',
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
]
},
{
id: 'resp-tool-fallback-2',
model: 'gpt-5',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: '读取完成。'
}
]
}
]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5',
protocol: 'openai-responses',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed141',
conversationId: 'conversation-responses-fallback-id',
prompt: '读取 README',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { input: Array<Record<string, unknown>> }
expect(secondBody.input).toContainEqual(
expect.objectContaining({
id: 'fc-responses-fallback-1',
type: 'function_call',
call_id: 'fc-responses-fallback-1'
})
)
expect(secondBody.input).toContainEqual(
expect.objectContaining({
type: 'function_call_output',
call_id: 'fc-responses-fallback-1'
})
)
})
it('fails closed when a direct-model tool is denied', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({
@@ -1980,6 +2137,70 @@ describe('ModelAgentRuntime', () => {
})
})
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
const responses = [
{
id: 'message-tool-missing-id-1',
model: 'claude',
content: [
{
type: 'tool_use',
name: 'workspace_read_text',
input: { path: 'notes.md' }
}
]
},
{
id: 'message-tool-missing-id-2',
model: 'claude',
content: [{ type: 'text', text: '读取完成。' }]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'claude',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed142',
conversationId: 'conversation-anthropic-fallback-id',
prompt: '读取 notes',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { messages: Array<Record<string, unknown>> }
const assistant = secondBody.messages.at(-2) as {
content: Array<Record<string, unknown>>
}
const result = secondBody.messages.at(-1) as {
content: Array<Record<string, unknown>>
}
const toolUseId = assistant.content[0]?.id
expect(toolUseId).toEqual(
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
)
expect(result.content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: toolUseId
})
})
it('does not issue a follow-up model request after tool cancellation', async () => {
const response = {
choices: [
+29 -9
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import type {
ApprovalDecision,
AgentRuntimeStatus,
@@ -725,21 +726,30 @@ function getChatToolImageCarrierContent(
]
}
function createToolCallId(): string {
return `goodbuddy_call_${randomBytes(16).toString('hex')}`
}
function parseToolCallIdentity(
id: unknown,
name: unknown
name: unknown,
fallbackId?: unknown
): { id: string; name: string } {
const resolvedId =
typeof id === 'string' && id.length > 0
? id
: typeof fallbackId === 'string' && fallbackId.length > 0
? fallbackId
: createToolCallId()
if (
typeof id !== 'string' ||
id.length === 0 ||
id.length > 256 ||
resolvedId.length > 256 ||
typeof name !== 'string' ||
name.length === 0 ||
name.length > 128
) {
throw new Error('模型返回了无效的工具调用标识')
throw new Error('模型返回了无效的工具调用标识或名称')
}
return { id, name }
return { id: resolvedId, name }
}
function parseModelToolResponse(
@@ -771,6 +781,7 @@ function parseModelToolResponse(
reasoning.push(record.thinking)
} else if (record.type === 'tool_use') {
const identity = parseToolCallIdentity(record.id, record.name)
record.id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(record.input)
@@ -845,8 +856,10 @@ function parseModelToolResponse(
} else if (output.type === 'function_call') {
const identity = parseToolCallIdentity(
output.call_id,
output.name
output.name,
output.id
)
output.call_id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(output.arguments)
@@ -893,6 +906,7 @@ function parseModelToolResponse(
toolCall.id,
functionCall.name
)
toolCall.id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(functionCall.arguments)
@@ -1112,6 +1126,10 @@ export class ModelAgentRuntime implements AgentRuntime {
return this.capability === 'chat'
}
get supportsScopedDataTools(): boolean {
return this.capability === 'chat'
}
private isConfigured(): boolean {
return (
this.options.authentication === 'none' ||
@@ -1665,11 +1683,13 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.arguments
: ''),
id:
typeof toolDelta?.id === 'string'
typeof toolDelta?.id === 'string' &&
toolDelta.id.length > 0
? toolDelta.id
: current.id,
name:
typeof functionDelta?.name === 'string'
typeof functionDelta?.name === 'string' &&
functionDelta.name.length > 0
? functionDelta.name
: current.name
}
+4
View File
@@ -565,6 +565,10 @@ export class OpenCodeRuntime implements AgentRuntime {
return this.options.embedded && !this.options.baseUrl
}
get supportsScopedDataTools(): boolean {
return this.usesEmbeddedPermissionMediation()
}
private async acquireEmbeddedRun(
signal: AbortSignal
): Promise<() => void> {
+4
View File
@@ -45,6 +45,10 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.supportsToolExecution
}
get supportsScopedDataTools(): boolean {
return this.current.runtime.supportsScopedDataTools !== false
}
get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability
}
+2
View File
@@ -51,6 +51,8 @@ export interface AgentRuntime {
readonly runtimeId?: AgentRuntimeStatus['id']
readonly requiresToolApproval: boolean
readonly supportsToolExecution: boolean
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
readonly supportsScopedDataTools?: boolean
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>
+1
View File
@@ -11,6 +11,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
readonly runtimeId = 'setup'
readonly requiresToolApproval = false
readonly supportsToolExecution = false
readonly supportsScopedDataTools = false
getStatus(): Promise<AgentRuntimeStatus> {
return Promise.resolve({