fix: remove avoidable model runtime limits
Preserve long histories and parsed attachments, expose paged process and Subagent output, isolate MCP discovery failures, and expand browser snapshots and knowledge result selection. Remove fixed Harness output and updated remote Runtime duration limits. Keep released Agent profile and v1 bridge compatibility while enabling larger framed exchanges and optional limits for updated peers. Document remote coverage requirements and real-model boundary validation. Release note: Long model tasks can retain more context and continue reading complete tool output; remote execution preserves compatibility with released Agents. Updated remote limits require an updated Agent and Runtime bundle.
This commit is contained in:
@@ -207,14 +207,14 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('text'),
|
||||
content: z.string().min(1).max(1_000_000)
|
||||
content: z.string().min(1)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('reasoning'),
|
||||
content: z.string().min(1).max(1_000_000)
|
||||
content: z.string().min(1)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
@@ -314,7 +314,7 @@ export const conversationMessageSchema = z
|
||||
id: assistantIdSchema,
|
||||
queueItemId: assistantIdSchema.optional(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().max(1_000_000),
|
||||
content: z.string(),
|
||||
reasoning: z.string().optional(),
|
||||
blocks: conversationMessageBlocksSchema.optional(),
|
||||
displayCaptureTruncated: z.boolean().optional(),
|
||||
@@ -402,17 +402,12 @@ export type ConversationMessage = z.infer<
|
||||
typeof conversationMessageSchema
|
||||
>
|
||||
|
||||
export const maximumConversationHistoryMessages = 500
|
||||
export const maximumConversationHistoryCharacters = 2_000_000
|
||||
export const conversationHistoryMessageSchema =
|
||||
conversationMessageSchema
|
||||
.pick({
|
||||
role: true,
|
||||
content: true
|
||||
})
|
||||
.extend({
|
||||
content: z.string().max(100_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const conversationContextMetricsSchema = z
|
||||
@@ -452,7 +447,7 @@ export type ConversationContextMetrics = z.infer<
|
||||
export const conversationContextCompressionStateSchema = z
|
||||
.object({
|
||||
coveredHistoryDigest: z.string().regex(/^[0-9a-f]{64}$/u),
|
||||
coveredMessageCount: z.number().int().nonnegative().max(500),
|
||||
coveredMessageCount: z.number().int().nonnegative(),
|
||||
coveredFromMessageId: assistantIdSchema.optional(),
|
||||
coveredThroughMessageId: assistantIdSchema.optional(),
|
||||
summary: z.string().trim().min(1).max(100_000)
|
||||
@@ -502,9 +497,7 @@ export const conversationSnapshotSchema = z
|
||||
branch: conversationBranchSchema.optional(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
updatedAt: z.number().int().nonnegative(),
|
||||
messages: z
|
||||
.array(conversationMessageSchema)
|
||||
.max(500)
|
||||
messages: z.array(conversationMessageSchema)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -528,7 +521,7 @@ export type LocalConversationHeader = z.infer<
|
||||
export const localConversationSaveSchema = z
|
||||
.object({
|
||||
header: localConversationHeaderSchema,
|
||||
messages: z.array(conversationMessageSchema).max(500)
|
||||
messages: z.array(conversationMessageSchema)
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('built-in model tool catalog', () => {
|
||||
builtinModelToolGroups
|
||||
.find((group) => group.id === 'programming')
|
||||
?.tools.map((tool) => tool.name)
|
||||
).toEqual(['process_execute', 'subagent_delegate'])
|
||||
).toEqual(['process_execute', 'output_read', 'subagent_delegate'])
|
||||
})
|
||||
|
||||
it('classifies programming tool access for mode filtering', () => {
|
||||
@@ -45,6 +45,7 @@ describe('built-in model tool catalog', () => {
|
||||
|
||||
expect(programmingAccess).toEqual({
|
||||
process_execute: 'write',
|
||||
output_read: 'read',
|
||||
subagent_delegate: 'read'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,15 +99,22 @@ export const builtinModelTools = [
|
||||
name: 'process_execute',
|
||||
displayName: '进程执行',
|
||||
description:
|
||||
'使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。',
|
||||
'使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,可指定工作目录;长输出保留完整内容并支持分页续读。',
|
||||
access: 'write',
|
||||
group: 'programming'
|
||||
},
|
||||
{
|
||||
name: 'output_read',
|
||||
displayName: '续读工具输出',
|
||||
description: '按 cursor 分页读取当前会话已保留的进程或 Subagent 输出;会话释放后失效。',
|
||||
access: 'read',
|
||||
group: 'programming'
|
||||
},
|
||||
{
|
||||
name: 'subagent_delegate',
|
||||
displayName: '编程 Subagent',
|
||||
description:
|
||||
'将聚焦任务委派给同一模型连接;继承当前工作模式和工作区。',
|
||||
'将聚焦任务委派给同一模型连接,继承当前工作模式和工作区;长输出可分页续读。',
|
||||
access: 'read',
|
||||
group: 'programming'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { expect, it } from 'vitest'
|
||||
import { agentRequestSchema, runtimeConversationCompactInputSchema } from './contracts'
|
||||
import { conversationMessageSchema } from './assistant-contracts'
|
||||
|
||||
it('accepts full history for execution and compaction while validating message IDs', () => {
|
||||
const history = Array.from({ length: 502 }, (_, index) => ({
|
||||
role: index % 2 ? 'assistant' as const : 'user' as const,
|
||||
content: index === 0 ? 'x'.repeat(100_001) : 'x'.repeat(4_200)
|
||||
}))
|
||||
const request = {
|
||||
requestId: randomUUID(),
|
||||
conversationId: randomUUID(),
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
history,
|
||||
historyMessageIds: history.map(() => randomUUID())
|
||||
}
|
||||
expect(agentRequestSchema.parse({ ...request, prompt: 'continue' }).history).toEqual(history)
|
||||
expect(runtimeConversationCompactInputSchema.parse(request).history).toEqual(history)
|
||||
expect(() => runtimeConversationCompactInputSchema.parse({
|
||||
...request, historyMessageIds: request.historyMessageIds.slice(1)
|
||||
})).toThrow()
|
||||
expect(conversationMessageSchema.parse({
|
||||
id: randomUUID(), role: 'assistant', state: 'complete', createdAt: 0,
|
||||
content: 'x'.repeat(1_000_001)
|
||||
}).content).toHaveLength(1_000_001)
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalJson } from './agent-protocol/canonical'
|
||||
import {
|
||||
MODEL_BRIDGE_LIMITS,
|
||||
MODEL_BRIDGE_PROTOCOL,
|
||||
ModelBridgeCodecError,
|
||||
ModelBridgeMessageBuffer,
|
||||
createModelBridgeRequestMessage,
|
||||
decodeModelBridgeMessage,
|
||||
digestModelBridgeRequest,
|
||||
@@ -46,6 +48,31 @@ const request = {
|
||||
} as const
|
||||
|
||||
describe('model bridge contracts and codec', () => {
|
||||
it('preserves released v1 canonical bytes and accepts a released single-frame message immediately', async () => {
|
||||
const message = await createModelBridgeRequestMessage({ identity, policy, request })
|
||||
const released = Buffer.from(canonicalJson(message))
|
||||
expect(Buffer.from(await encodeModelBridgeMessage(message))).toEqual(released)
|
||||
const buffer = new ModelBridgeMessageBuffer()
|
||||
await expect(decodeModelBridgeMessage(buffer.push(released)!)).resolves.toEqual(message)
|
||||
await expect(decodeModelBridgeMessage(Buffer.concat([released, Buffer.from('\n')]))).rejects.toMatchObject({ code: 'invalid-message' })
|
||||
})
|
||||
|
||||
it('reassembles v1 across arbitrary frame boundaries including escaped strings and resets for the ACK', async () => {
|
||||
const message = modelBridgeErrorMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL, kind: 'error', identity,
|
||||
requestDigest: await digestModelBridgeRequest(request),
|
||||
error: { code: 'test', message: 'nested { [ \\"quoted\\" ] } and \\ slash', retryable: false, poisoned: false, outcomeUnknown: false }
|
||||
})
|
||||
const bytes = await encodeModelBridgeMessage(message)
|
||||
const buffer = new ModelBridgeMessageBuffer()
|
||||
for (let index = 0; index < bytes.length - 1; index += 1) {
|
||||
expect(buffer.push(bytes.subarray(index, index + 1))).toBeUndefined()
|
||||
}
|
||||
await expect(decodeModelBridgeMessage(buffer.push(bytes.subarray(-1))!)).resolves.toEqual(message)
|
||||
const ack = { protocol: MODEL_BRIDGE_PROTOCOL, kind: 'response-delivered', identity, requestDigest: message.requestDigest }
|
||||
await expect(decodeModelBridgeMessage(buffer.push(await encodeModelBridgeMessage(ack))!)).resolves.toEqual(ack)
|
||||
})
|
||||
|
||||
it('round trips one bounded request message with an exact digest', async () => {
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity,
|
||||
@@ -226,4 +253,4 @@ describe('model bridge contracts and codec', () => {
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,8 @@ export const MODEL_BRIDGE_LIMITS = {
|
||||
} as const
|
||||
|
||||
export const MODEL_BRIDGE_PROTOCOL = 'goodbuddy-model-bridge-v1' as const
|
||||
export const MODEL_BRIDGE_OPTIONAL_LIMITS_CAPABILITY =
|
||||
'runtime/model-bridge-optional-limits' as const
|
||||
|
||||
export const modelBridgeModelProtocolSchema = z.enum([
|
||||
'anthropic-messages',
|
||||
@@ -131,12 +133,13 @@ export const agentPromptModelProfileSchema = z
|
||||
.strict(),
|
||||
limits: z
|
||||
.object({
|
||||
maximumOutputTokens: z.number().int().min(1).max(1_000_000),
|
||||
maximumOutputTokens: z.number().int().positive().safe().optional(),
|
||||
requestTimeoutMilliseconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(300_000)
|
||||
.max(0x7fff_ffff)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
@@ -385,6 +388,45 @@ export type ModelBridgeDecodeOptions = {
|
||||
maximumMessageBytes?: number
|
||||
}
|
||||
|
||||
// Keep the released v1 bytes unchanged, including for single-frame peers.
|
||||
export class ModelBridgeMessageBuffer {
|
||||
#chunks: Uint8Array[] = []
|
||||
#bytes = 0
|
||||
#depth = 0
|
||||
#inString = false
|
||||
#escaped = false
|
||||
|
||||
push(chunk: Uint8Array): Uint8Array | undefined {
|
||||
this.#bytes += chunk.byteLength
|
||||
if (this.#bytes > MODEL_BRIDGE_LIMITS.maximumMessageBytes) {
|
||||
this.clear()
|
||||
throw new ModelBridgeCodecError('oversized', 'Model bridge message exceeds its byte limit')
|
||||
}
|
||||
this.#chunks.push(chunk)
|
||||
for (const byte of chunk) {
|
||||
if (this.#inString) {
|
||||
if (this.#escaped) this.#escaped = false
|
||||
else if (byte === 92) this.#escaped = true
|
||||
else if (byte === 34) this.#inString = false
|
||||
} else if (byte === 34) this.#inString = true
|
||||
else if (byte === 123 || byte === 91) this.#depth += 1
|
||||
else if (byte === 125 || byte === 93) this.#depth -= 1
|
||||
}
|
||||
if (this.#inString || this.#depth > 0) return undefined
|
||||
const payload = Buffer.concat(this.#chunks, this.#bytes)
|
||||
this.clear()
|
||||
return payload
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#chunks = []
|
||||
this.#bytes = 0
|
||||
this.#depth = 0
|
||||
this.#inString = false
|
||||
this.#escaped = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeModelBridgeMessage(
|
||||
input: Uint8Array,
|
||||
options: ModelBridgeDecodeOptions = {}
|
||||
|
||||
@@ -16,6 +16,12 @@ const validRequest = {
|
||||
} as const
|
||||
|
||||
describe('remote model gateway contracts', () => {
|
||||
it('accepts request and response bodies above the old 768 KiB ceiling', () => {
|
||||
const bodyBase64 = Buffer.alloc(2 * 1024 * 1024, 'x').toString('base64')
|
||||
expect(remoteModelGatewayRequestSchema.parse({ ...validRequest, bodyBase64 }).bodyBase64).toBe(bodyBase64)
|
||||
expect(remoteModelGatewayResponseSchema.parse({ status: 200, headers: {}, bodyBase64 }).bodyBase64).toBe(bodyBase64)
|
||||
})
|
||||
|
||||
it('accepts only POST requests to fixed provider API paths', () => {
|
||||
expect(remoteModelGatewayRequestSchema.parse(validRequest)).toEqual(
|
||||
validRequest
|
||||
|
||||
@@ -2,8 +2,9 @@ import { z } from 'zod'
|
||||
import { utf8StringSchema } from './agent-protocol/contracts'
|
||||
|
||||
export const REMOTE_MODEL_GATEWAY_LIMITS = {
|
||||
maximumRequestBodyBytes: 768 * 1024,
|
||||
maximumResponseBodyBytes: 768 * 1024,
|
||||
// Whole-body JSON transport needs a memory bound, including image input.
|
||||
maximumRequestBodyBytes: 64 * 1024 * 1024,
|
||||
maximumResponseBodyBytes: 64 * 1024 * 1024,
|
||||
maximumHeaderValueBytes: 1_024
|
||||
} as const
|
||||
|
||||
@@ -35,7 +36,7 @@ function boundedCanonicalBase64Schema(maximumBytes: number) {
|
||||
.string()
|
||||
.max(Math.ceil(maximumBytes / 3) * 4)
|
||||
.regex(
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u,
|
||||
/^[A-Za-z0-9+/]*={0,2}$/u,
|
||||
'Body must use canonical base64'
|
||||
)
|
||||
.refine(
|
||||
|
||||
@@ -95,10 +95,11 @@ export const remoteRuntimeBundleFileSchema = z
|
||||
|
||||
export const remoteRuntimeLimitsSchema = z
|
||||
.object({
|
||||
// Zero adds no bundle-wide cap to the prompt's own deadline.
|
||||
maximumPromptRuntimeMilliseconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.min(0)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumRuntimeMilliseconds),
|
||||
maximumPromptInputBytes: z
|
||||
.number()
|
||||
|
||||
@@ -2,9 +2,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationContextCompressionStateSchema,
|
||||
conversationHistoryMessageSchema,
|
||||
maximumConversationHistoryCharacters,
|
||||
maximumConversationHistoryMessages
|
||||
conversationHistoryMessageSchema
|
||||
} from './assistant-contracts'
|
||||
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
|
||||
@@ -466,12 +464,8 @@ export const runtimeConversationCompactInputSchema = z
|
||||
conversationId: assistantIdSchema,
|
||||
projectId: assistantIdSchema.optional(),
|
||||
runtimeSelection: agentRuntimeSelectionSchema,
|
||||
history: z
|
||||
.array(conversationHistoryMessageSchema)
|
||||
.max(maximumConversationHistoryMessages),
|
||||
historyMessageIds: z
|
||||
.array(assistantIdSchema)
|
||||
.max(maximumConversationHistoryMessages),
|
||||
history: z.array(conversationHistoryMessageSchema),
|
||||
historyMessageIds: z.array(assistantIdSchema),
|
||||
contextCompressionState:
|
||||
conversationContextCompressionStateSchema.optional()
|
||||
})
|
||||
@@ -496,18 +490,6 @@ export const runtimeConversationCompactInputSchema = z
|
||||
message: '会话历史消息 ID 不得重复'
|
||||
})
|
||||
}
|
||||
if (
|
||||
request.history.reduce(
|
||||
(total, message) => total + message.content.length,
|
||||
0
|
||||
) > maximumConversationHistoryCharacters
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['history'],
|
||||
message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type RuntimeConversationCompactInput = z.infer<
|
||||
|
||||
@@ -23,7 +23,7 @@ const knowledgeListInputSchema = z.object({}).strict()
|
||||
const knowledgeSearchInputSchema = z
|
||||
.object({
|
||||
query: z.string().trim().min(1).max(4_000),
|
||||
limit: z.number().int().min(1).max(8).default(6)
|
||||
limit: z.number().int().min(1).max(100).default(6)
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user