feat: improve runtime visibility and browser interaction

This commit is contained in:
lofyer
2026-08-07 17:53:14 +08:00
parent 2c715e5e81
commit 53d18e2b06
36 changed files with 1680 additions and 907 deletions
+52 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
boundedToolDetail,
safeToolArgumentSummary,
safeToolErrorDetail
} from './approval-summary'
@@ -30,8 +31,29 @@ describe('safeToolArgumentSummary', () => {
})
})
describe('boundedToolDetail', () => {
it('preserves conversation details verbatim while bounding output', () => {
expect(
boundedToolDetail(
{
command: 'npm test',
token: 'secret-token',
output: 'Authorization: Bearer inline-secret'
},
1_000
)
).toBe(
'{\n "command": "npm test",\n "token": "secret-token",\n "output": "Authorization: Bearer inline-secret"\n}'
)
expect(
boundedToolDetail(' exact output\r\n', 1_000)
).toBe(' exact output\r\n')
expect(boundedToolDetail('x'.repeat(100), 20)).toHaveLength(20)
})
})
describe('safeToolErrorDetail', () => {
it('extracts nested runtime errors while redacting secrets', () => {
it('extracts nested runtime errors without rewriting their contents', () => {
expect(
safeToolErrorDetail([
{
@@ -39,14 +61,14 @@ describe('safeToolErrorDetail', () => {
'exit code 1\nAuthorization: Bearer secret-token'
}
])
).toBe('exit code 1\nAuthorization: [REDACTED]')
).toBe('exit code 1\nAuthorization: Bearer secret-token')
expect(
safeToolErrorDetail({
message:
'{"token":"json-secret","authorization":"Basic abc123"}'
})
).toBe(
'{"token":"[REDACTED]","authorization":"[REDACTED]"}'
'{"token":"json-secret","authorization":"Basic abc123"}'
)
})
@@ -66,4 +88,31 @@ describe('safeToolErrorDetail', () => {
})
).toBeUndefined()
})
it('includes nested fetch causes and network diagnostics', () => {
const cause = Object.assign(
new Error('connect ECONNREFUSED 127.0.0.1:11434'),
{
code: 'ECONNREFUSED',
errno: -4078,
syscall: 'connect',
address: '127.0.0.1',
port: 11434
}
)
const error = new TypeError('fetch failed', { cause })
expect(safeToolErrorDetail(error)).toBe(
[
'fetch failed',
'cause:',
'connect ECONNREFUSED 127.0.0.1:11434',
'code: ECONNREFUSED',
'errno: -4078',
'syscall: connect',
'address: 127.0.0.1',
'port: 11434'
].join('\n')
)
})
})
+74 -25
View File
@@ -8,6 +8,9 @@ function redactValue(
if (depth > 8) {
return '[TRUNCATED]'
}
if (typeof value === 'string') {
return redactSensitiveText(value)
}
if (!value || typeof value !== 'object') {
return value
}
@@ -64,6 +67,32 @@ export function safeToolErrorDetail(
let remaining = maximum
const seen = new WeakSet<object>()
const append = (value: string): void => {
const text = [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return (
code === 9 ||
code === 10 ||
code === 13 ||
(code > 31 && code !== 127)
)
})
.join('')
.trim()
if (!text || remaining <= 0) {
return
}
const separator = parts.length > 0 ? '\n' : ''
const available = Math.max(0, remaining - separator.length)
if (available === 0) {
return
}
const bounded = text.slice(0, available)
parts.push(`${separator}${bounded}`)
remaining -= separator.length + bounded.length
}
const collect = (candidate: unknown, depth = 0): void => {
if (remaining <= 0 || depth > 4 || candidate === undefined) {
return
@@ -73,30 +102,7 @@ export function safeToolErrorDetail(
0,
Math.min(candidate.length, remaining * 4)
)
const text = redactSensitiveText(
[...boundedCandidate]
.filter((character) => {
const code = character.charCodeAt(0)
return (
code === 9 ||
code === 10 ||
code === 13 ||
(code > 31 && code !== 127)
)
})
.join('')
).trim()
if (!text) {
return
}
const separator = parts.length > 0 ? '\n' : ''
const available = Math.max(0, remaining - separator.length)
if (available === 0) {
return
}
const bounded = text.slice(0, available)
parts.push(`${separator}${bounded}`)
remaining -= separator.length + bounded.length
append(boundedCandidate)
return
}
if (!candidate || typeof candidate !== 'object') {
@@ -113,10 +119,33 @@ export function safeToolErrorDetail(
return
}
const record = candidate as Record<string, unknown>
collect(record.message, depth + 1)
for (const key of [
'code',
'errno',
'syscall',
'hostname',
'address',
'port',
'status',
'statusCode'
]) {
const metadata = record[key]
if (
typeof metadata === 'string' ||
typeof metadata === 'number'
) {
append(`${key}: ${metadata}`)
}
}
if (record.cause !== undefined) {
append('cause:')
collect(record.cause, depth + 1)
}
for (const key of [
'content',
'message',
'error',
'errors',
'stderr',
'detail',
'data'
@@ -152,3 +181,23 @@ export function safeToolArgumentSummary(
redactValue(toolArguments, new WeakSet())
).slice(0, maximum)
}
export function boundedToolDetail(
value: unknown,
maximum: number
): string | undefined {
if (!Number.isSafeInteger(maximum) || maximum < 1 || value === undefined) {
return undefined
}
let text: string | undefined
if (typeof value === 'string') {
text = value
} else {
try {
text = JSON.stringify(value, null, 2)
} catch {
return undefined
}
}
return text ? text.slice(0, maximum) : undefined
}
+18 -6
View File
@@ -876,7 +876,7 @@ describe('ContinueHostAdapter', () => {
name: 'Bash',
state: 'failed',
error:
'PowerShell parser failed Authorization: [REDACTED]'
'PowerShell parser failed Authorization: Bearer secret-token'
}
]
})
@@ -940,7 +940,9 @@ describe('ContinueHostAdapter', () => {
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'running'
state: 'running',
input:
'{"command":"npm test","token":"secret-token"}'
}
]
})
@@ -973,7 +975,9 @@ describe('ContinueHostAdapter', () => {
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'completed'
state: 'completed',
output:
'Tests passed\nAuthorization: Bearer secret-token'
},
{ type: 'text', delta: 'TOOLS_OK' }
]
@@ -1012,7 +1016,11 @@ describe('ContinueHostAdapter', () => {
{
callId: 'call-1',
name: 'Bash',
state: 'completed'
state: 'completed',
input:
'{"command":"npm test","token":"secret-token"}',
output:
'Tests passed\nAuthorization: Bearer secret-token'
}
]
})
@@ -1023,7 +1031,9 @@ describe('ContinueHostAdapter', () => {
tool: {
callId: 'call-1',
name: 'Bash',
state: 'running'
state: 'running',
input:
'{"command":"npm test","token":"secret-token"}'
}
},
{
@@ -1031,7 +1041,9 @@ describe('ContinueHostAdapter', () => {
tool: {
callId: 'call-1',
name: 'Bash',
state: 'completed'
state: 'completed',
output:
'Tests passed\nAuthorization: Bearer secret-token'
}
},
{ type: 'text', delta: 'TOOLS_OK' }
+36 -5
View File
@@ -33,7 +33,7 @@ import {
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import {
redactSensitiveText,
boundedToolDetail,
safeToolErrorDetail
} from './approval-summary'
@@ -88,6 +88,8 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
callId: z.string().min(1).max(256),
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
})
.strict()
@@ -142,6 +144,8 @@ export type ContinueHostTool = {
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
output?: string
error?: string
}
@@ -383,7 +387,7 @@ function parseContinueFailure(text: string): string | undefined {
: record.message
const detail =
typeof message === 'string' && message.trim()
? `${redactSensitiveText(message.trim()).slice(0, 500)}`
? `${message.trim().slice(0, 500)}`
: ''
return `Continue 模型请求失败${detail}`
} catch {
@@ -465,10 +469,23 @@ function extractContinueTools(
normalizedState === 'failed'
? normalizeContinueToolError(state.output)
: undefined
const input =
toolFunction && typeof toolFunction === 'object'
? boundedToolDetail(
(toolFunction as Record<string, unknown>).arguments,
4_000
)
: undefined
const output =
normalizedState === 'completed'
? boundedToolDetail(state.output, 16_000)
: undefined
tools.set(callId, {
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
})
}
@@ -482,7 +499,14 @@ function mergeContinueTools(
): ContinueHostTool[] {
const tools = new Map(current.map((tool) => [tool.callId, tool]))
for (const tool of updates) {
tools.set(tool.callId, tool)
const previous = tools.get(tool.callId)
tools.set(tool.callId, {
...previous,
...tool,
input: tool.input ?? previous?.input,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
})
}
return [...tools.values()]
}
@@ -661,7 +685,7 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
streamCallbacksMarker,
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
)
patched = replaceExactly(
patched,
@@ -1112,6 +1136,12 @@ export class ContinueHostAdapter {
callId: event.callId,
name: event.name,
state: event.state,
...(event.input
? { input: boundedToolDetail(event.input, 4_000) }
: {}),
...(event.output
? { output: boundedToolDetail(event.output, 16_000) }
: {}),
...(event.error
? { error: normalizeContinueToolError(event.error) }
: {})
@@ -1143,7 +1173,8 @@ export class ContinueHostAdapter {
{
callId: pendingCallId,
name: pending.toolName,
state: 'pending'
state: 'pending',
input: boundedToolDetail(pending.toolArgs, 4_000)
}
]
}
+10 -2
View File
@@ -384,7 +384,13 @@ describe('ContinueAgentRuntime', () => {
mocks.runHost.mockResolvedValue({
text: 'Continue response',
tools: [
{ callId: 'call-1', name: 'Bash', state: 'completed' },
{
callId: 'call-1',
name: 'Bash',
state: 'completed',
input: '{"command":"npm test"}',
output: 'Tests passed'
},
{ callId: 'call-2', name: 'Write', state: 'completed' }
]
})
@@ -396,7 +402,9 @@ describe('ContinueAgentRuntime', () => {
type: 'tool',
name: 'Bash',
state: 'completed',
summary: 'Continue 工具:Bash'
summary: 'Continue 工具:Bash',
input: '{"command":"npm test"}',
output: 'Tests passed'
}),
expect.objectContaining({
type: 'tool',
+2
View File
@@ -72,6 +72,8 @@ function toContinueToolEvent(
? 'failed'
: tool.state,
summary: `Continue 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
}
}
+12 -3
View File
@@ -286,7 +286,7 @@ describe('ModelAgentRuntime', () => {
await expect(consume()).rejects.toThrow('意外中断')
})
it('redacts credentials from provider error messages', async () => {
it('preserves bounded provider error messages', async () => {
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
@@ -319,7 +319,7 @@ describe('ModelAgentRuntime', () => {
}
await expect(consume()).rejects.toThrow(
'upstream failed Authorization: [REDACTED]'
'upstream failed Authorization: Bearer secret-token'
)
})
@@ -702,6 +702,15 @@ describe('ModelAgentRuntime', () => {
.filter((event) => event.type === 'tool')
.map((event) => event.state)
).toEqual(['pending', 'running', 'completed'])
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
state: 'completed',
input: '{\n "path": "README.md"\n}',
output:
'tool result\n\n[图片结果 1image/png]'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
@@ -1693,7 +1702,7 @@ describe('ModelAgentRuntime', () => {
'x-request-id': 'image-request-502'
},
expected:
'upstream unavailable Authorization: [REDACTED]HTTP 502,请求 ID image-request-502'
'upstream unavailable Authorization: Bearer secret-tokenHTTP 502,请求 ID image-request-502'
},
{
body: '<html>Bad Gateway</html>',
+46 -17
View File
@@ -31,8 +31,8 @@ import type {
RuntimeModelUsageEvent
} from './runtime'
import {
redactSensitiveText,
safeToolArgumentSummary
boundedToolDetail,
safeToolErrorDetail
} from './approval-summary'
type ConversationMessage = {
@@ -121,7 +121,7 @@ function getErrorMessage(value: unknown): string | undefined {
}
const error = 'error' in value ? value.error : undefined
if (typeof error === 'string') {
return redactSensitiveText(error).slice(0, 1_000)
return error.slice(0, 1_000)
}
if (
error &&
@@ -129,13 +129,13 @@ function getErrorMessage(value: unknown): string | undefined {
'message' in error &&
typeof error.message === 'string'
) {
return redactSensitiveText(error.message).slice(0, 1_000)
return error.message.slice(0, 1_000)
}
if (
'message' in value &&
typeof value.message === 'string'
) {
return redactSensitiveText(value.message).slice(0, 1_000)
return value.message.slice(0, 1_000)
}
return undefined
}
@@ -624,14 +624,29 @@ function getChatToolResultText(parts: ModelToolResultPart[]): string {
.join('\n\n')
}
function getToolResultPreview(parts: ModelToolResultPart[]): string {
let imageNumber = 0
return parts
.map((part) => {
if (part.type === 'text') {
return part.text
}
imageNumber += 1
return `[图片结果 ${imageNumber}${part.mimeType}]`
})
.filter(Boolean)
.join('\n\n')
.slice(0, 16_000)
}
function createRecoverableToolErrorResult(
error: RecoverableModelToolError
): ModelToolResult {
const text = JSON.stringify({
ok: false,
recoverable: true,
error: redactSensitiveText(error.message).slice(0, 1_000),
nextAction: redactSensitiveText(error.nextAction).slice(0, 1_000)
error: error.message.slice(0, 1_000),
nextAction: error.nextAction.slice(0, 1_000)
})
return {
parts: [{ type: 'text', text }],
@@ -1250,7 +1265,7 @@ export class ModelAgentRuntime implements AgentRuntime {
providerMessage?.includes('模型接口请求失败')
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
: providerMessage
? redactSensitiveText(providerMessage).slice(0, 1_000)
? providerMessage.slice(0, 1_000)
: '图像生成请求失败'
throw new Error(
`${publicMessage}HTTP ${response.status}${
@@ -1546,13 +1561,15 @@ export class ModelAgentRuntime implements AgentRuntime {
seenCallIds.add(call.id)
const tool = toolsByName.get(call.name)
const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000)
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'pending',
summary: `直连模型工具:${displayName}`
summary: `直连模型工具:${displayName}`,
input
}
if (!tool) {
yield {
@@ -1561,7 +1578,8 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型请求了未知工具:${displayName}`
summary: `直连模型请求了未知工具:${displayName}`,
input
}
throw new Error(`模型请求了未知工具「${displayName}`)
}
@@ -1581,19 +1599,22 @@ export class ModelAgentRuntime implements AgentRuntime {
this.toolProvider.getApproval(
tool,
call.arguments,
safeToolArgumentSummary(call.arguments),
boundedToolDetail(call.arguments, 1_000) ?? '',
toolContext
)
)
}
} catch (error) {
const detail = safeToolErrorDetail(error)
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具审批失败:${displayName}`
summary: `直连模型工具审批失败:${displayName}`,
input,
...(detail ? { error: detail } : {})
}
throw error
}
@@ -1604,7 +1625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'failed',
summary: `用户拒绝了直连模型工具:${displayName}`
summary: `用户拒绝了直连模型工具:${displayName}`,
input
}
throw new Error(`用户拒绝了工具「${displayName}`)
}
@@ -1615,7 +1637,8 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'running',
summary: `正在执行直连模型工具:${displayName}`
summary: `正在执行直连模型工具:${displayName}`,
input
}
let result: ModelToolResult
@@ -1629,6 +1652,7 @@ export class ModelAgentRuntime implements AgentRuntime {
)
} catch (error) {
const recoverable = error instanceof RecoverableModelToolError
const detail = safeToolErrorDetail(error)
yield {
requestId: request.requestId,
type: 'tool',
@@ -1638,7 +1662,9 @@ export class ModelAgentRuntime implements AgentRuntime {
summary:
recoverable
? `直连模型工具需要刷新后重试:${displayName}`
: `直连模型工具执行失败:${displayName}`
: `直连模型工具执行失败:${displayName}`,
input,
...(detail ? { error: detail } : {})
}
if (recoverable) {
result = createRecoverableToolErrorResult(error)
@@ -1657,7 +1683,8 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具结果超过限制:${displayName}`
summary: `直连模型工具结果超过限制:${displayName}`,
input
}
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
}
@@ -1691,7 +1718,9 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`
summary: `直连模型工具已完成:${displayName}`,
input,
output: getToolResultPreview(result.parts)
}
}
}
+17 -5
View File
@@ -141,7 +141,14 @@ function completedToolEvent(
callID: callId,
type: 'tool',
tool,
state: { status: 'completed' }
state: {
status: 'completed',
input: {
command: 'npm test',
token: 'visible-token'
},
output: 'Tests passed\nAuthorization: Bearer secret-token'
}
}
}
}
@@ -1503,7 +1510,11 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'completed'
state: 'completed',
input:
'{\n "command": "npm test",\n "token": "visible-token"\n}',
output:
'Tests passed\nAuthorization: Bearer secret-token'
})
)
expect(events).toContainEqual(
@@ -1631,11 +1642,12 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
type: 'tool',
callId: 'call-1',
state: 'failed',
error: 'write failed Authorization: [REDACTED]'
error:
'write failed Authorization: Bearer secret-token'
}
})
await expect(stream.next()).rejects.toThrow(
'write failed Authorization: [REDACTED]'
'write failed Authorization: Bearer secret-token'
)
expect(session.abort).toHaveBeenCalledOnce()
await runtime.dispose()
@@ -1661,7 +1673,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
const runtime = embeddedRuntime(client)
await expect(collectRun(runtime)).rejects.toThrow(
'prompt rejected Authorization: [REDACTED]'
'prompt rejected Authorization: Bearer secret-token'
)
await runtime.dispose()
})
+17
View File
@@ -35,6 +35,7 @@ import {
type RuntimeSandboxResolution
} from './runtime-sandbox'
import {
boundedToolDetail,
safeToolErrorDetail
} from './approval-summary'
@@ -978,6 +979,8 @@ export class OpenCodeRuntime implements AgentRuntime {
{
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
output?: string
error?: string
}
>()
@@ -1077,9 +1080,19 @@ export class OpenCodeRuntime implements AgentRuntime {
part.state.status === 'error'
? safeToolErrorDetail(part.state.error)
: undefined
const input = isRecord(part.state.input)
? boundedToolDetail(part.state.input, 4_000)
: undefined
const output =
part.state.status === 'completed' &&
typeof part.state.output === 'string'
? part.state.output.slice(0, 16_000)
: undefined
toolStates.set(callId, {
name: toolName,
state,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
})
yield {
@@ -1089,6 +1102,8 @@ export class OpenCodeRuntime implements AgentRuntime {
name: toolName,
state,
summary: `OpenCode 工具:${toolName}`,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
}
}
@@ -1309,6 +1324,8 @@ export class OpenCodeRuntime implements AgentRuntime {
name: tool.name,
state: 'failed',
summary: `OpenCode 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
}
}