fix: harden scoped tools and settings persistence
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user