fix: allow concurrent and unrestricted Runtime sessions
Local embedded OpenCode serialized otherwise independent conversations, and GoodBuddy could stop long Runtime tasks after fixed tool, activity, model-call, or accumulated display-output limits. Different conversations can now run concurrently while requests in the same conversation stay ordered. Dynamic MCP tools remain isolated to the current request. OpenCode, Continue, managed ACP, and the Agent model gateway no longer stop native execution at GoodBuddy-defined call counts or prompt-wide output totals; display copies truncate and transport queues apply backpressure instead. Prompt-wide quota fields are removed from the current contracts. Per-call model settings, provider request timeouts, cancellation, and same-conversation ordering remain unchanged. Release note: 修复本机 OpenCode 不同会话互相等待的问题,并移除 GoodBuddy 对单次 Runtime 工具、活动和模型调用次数的人为上限;长输出现在只截断展示或施加传输背压,不再取消原生任务。
This commit is contained in:
@@ -36,8 +36,6 @@ const profile: AgentPromptModelProfile = {
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 8,
|
||||
maximumTotalOutputTokens: 32_768,
|
||||
requestTimeoutMilliseconds: 5_000
|
||||
}
|
||||
}
|
||||
@@ -124,6 +122,34 @@ describe('AgentModelGateway', () => {
|
||||
ledger.close()
|
||||
})
|
||||
|
||||
it('does not impose prompt-wide model-call or output-token quotas', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'response',
|
||||
usage: { output_tokens: 4_096 }
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
)
|
||||
)
|
||||
const { gateway, ledger } = setup(fetcher)
|
||||
|
||||
for (let roundIndex = 0; roundIndex < 101; roundIndex += 1) {
|
||||
const result = await gateway.dispatch(
|
||||
{ ...context, roundIndex },
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
await result.acknowledgeDelivery()
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(101)
|
||||
ledger.close()
|
||||
})
|
||||
|
||||
it('caps OpenCode chat-completions max_tokens before dispatch', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
|
||||
@@ -313,7 +313,6 @@ export type AgentModelGatewayContext = {
|
||||
export class AgentModelGateway {
|
||||
readonly #ledger: AgentModelCallLedger
|
||||
readonly #fetch: typeof fetch
|
||||
readonly #promptOutputTokens = new Map<string, number>()
|
||||
|
||||
constructor(options: {
|
||||
ledger: AgentModelCallLedger
|
||||
@@ -334,24 +333,10 @@ export class AgentModelGateway {
|
||||
}> {
|
||||
signal.throwIfAborted()
|
||||
const request = remoteModelGatewayRequestSchema.parse(requestInput)
|
||||
const promptKey = `${context.bindingId}\0${context.operationId}`
|
||||
const usedOutputTokens = this.#promptOutputTokens.get(promptKey) ?? 0
|
||||
const remainingOutputTokens =
|
||||
context.profile.limits.maximumTotalOutputTokens - usedOutputTokens
|
||||
if (remainingOutputTokens < 1) {
|
||||
throw new AgentModelGatewayError(
|
||||
'policy',
|
||||
'Prompt total output token limit is exhausted'
|
||||
)
|
||||
}
|
||||
const reservedOutputTokens = Math.min(
|
||||
context.profile.limits.maximumOutputTokens,
|
||||
remainingOutputTokens
|
||||
)
|
||||
const prepared = prepareProviderRequest(
|
||||
context.profile,
|
||||
request,
|
||||
reservedOutputTokens
|
||||
context.profile.limits.maximumOutputTokens
|
||||
)
|
||||
const requestDigest = sha256(prepared.canonicalRequest)
|
||||
const callId = createAgentModelCallId(context)
|
||||
@@ -444,14 +429,6 @@ export class AgentModelGateway {
|
||||
headers,
|
||||
bodyBase64: Buffer.from(bytes).toString('base64')
|
||||
})
|
||||
this.#promptOutputTokens.set(
|
||||
promptKey,
|
||||
usedOutputTokens +
|
||||
Math.min(
|
||||
reservedOutputTokens,
|
||||
responseOutputTokens(bytes) ?? reservedOutputTokens
|
||||
)
|
||||
)
|
||||
this.#ledger.complete(callId)
|
||||
let finalized = false
|
||||
return {
|
||||
@@ -474,9 +451,6 @@ export class AgentModelGateway {
|
||||
}
|
||||
}
|
||||
|
||||
finalizePrompt(bindingId: string, operationId: string): void {
|
||||
this.#promptOutputTokens.delete(`${bindingId}\0${operationId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentModelCallId(
|
||||
@@ -598,29 +572,6 @@ function prepareProviderRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function responseOutputTokens(body: Uint8Array): number | undefined {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder().decode(body))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const usage = (value as { usage?: unknown }).usage
|
||||
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
|
||||
return undefined
|
||||
}
|
||||
const record = usage as Record<string, unknown>
|
||||
const tokens = record.output_tokens ?? record.completion_tokens
|
||||
return typeof tokens === 'number' &&
|
||||
Number.isSafeInteger(tokens) &&
|
||||
tokens >= 0
|
||||
? tokens
|
||||
: undefined
|
||||
}
|
||||
|
||||
function providerUrl(profile: AgentPromptModelProfile): URL {
|
||||
const url = new URL(profile.baseUrl)
|
||||
const path = url.pathname.replace(/\/+$/u, '')
|
||||
|
||||
@@ -254,6 +254,56 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses stdout backpressure instead of stopping when output queues fill', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const launched = identity()
|
||||
const sendSignal = vi.fn<typeof process.kill>(() => true)
|
||||
let releaseFirst!: () => void
|
||||
const firstConsumed = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-output', processId: 'process-output' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||
maximumInputBytes: 1024,
|
||||
maximumOutputQueueChunks: 1,
|
||||
platform: 'linux',
|
||||
spawn: () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
},
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => launched,
|
||||
sendSignal
|
||||
})
|
||||
const pause = vi.spyOn(child.stdout, 'pause')
|
||||
const resume = vi.spyOn(child.stdout, 'resume')
|
||||
const received: string[] = []
|
||||
owner.subscribeOutput(async ({ data }) => {
|
||||
received.push(Buffer.from(data).toString())
|
||||
if (received.length === 1) {
|
||||
await firstConsumed
|
||||
}
|
||||
})
|
||||
|
||||
child.stdout.write('one')
|
||||
await vi.waitFor(() => expect(pause).toHaveBeenCalled())
|
||||
child.stdout.write('two')
|
||||
releaseFirst()
|
||||
await vi.waitFor(() => expect(received).toEqual(['one', 'two']))
|
||||
|
||||
expect(resume).toHaveBeenCalled()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('startup reconciles only owners from the current installation', async () => {
|
||||
const registry = createRegistry()
|
||||
const reservation = {
|
||||
|
||||
@@ -170,13 +170,13 @@ export class DirectLinuxStdioProcessOwner {
|
||||
#maximumPendingStdinBytes: number
|
||||
#inputBytes = 0
|
||||
#pendingInputBytes = 0
|
||||
#outputBytes = 0
|
||||
#queuedOutputBytes = 0
|
||||
#writeTail: Promise<void> = Promise.resolve()
|
||||
#deadlineTimer?: NodeJS.Timeout
|
||||
#promptGeneration = 1
|
||||
#promptActive = true
|
||||
#draining = false
|
||||
#outputPaused = false
|
||||
#closed = false
|
||||
#stdinClosed = false
|
||||
#stopPromise?: Promise<void>
|
||||
@@ -280,7 +280,6 @@ export class DirectLinuxStdioProcessOwner {
|
||||
this.#maximumPendingStdinBytes = this.#maximumInputBytes
|
||||
this.#inputBytes = 0
|
||||
this.#pendingInputBytes = 0
|
||||
this.#outputBytes = 0
|
||||
this.#promptActive = true
|
||||
this.#promptGeneration += 1
|
||||
this.#scheduleDeadline(
|
||||
@@ -449,24 +448,17 @@ export class DirectLinuxStdioProcessOwner {
|
||||
|
||||
#acceptOutput(chunk: Buffer | Uint8Array): void {
|
||||
if (this.#closed || chunk.byteLength === 0) return
|
||||
if (
|
||||
this.#outputBytes + chunk.byteLength > this.#maximumOutputBytes ||
|
||||
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
|
||||
this.#queuedOutputBytes + chunk.byteLength > this.#maximumOutputBytes
|
||||
) {
|
||||
this.#child.stdout.pause()
|
||||
this.#outputQueue.splice(0)
|
||||
this.#queuedOutputBytes = 0
|
||||
void this.stop({
|
||||
reason: 'output-quota',
|
||||
deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString()
|
||||
}).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const copy = Uint8Array.from(chunk)
|
||||
this.#outputBytes += copy.byteLength
|
||||
this.#queuedOutputBytes += copy.byteLength
|
||||
this.#outputQueue.push(copy)
|
||||
if (
|
||||
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
|
||||
(this.#maximumOutputBytes > 0 &&
|
||||
this.#queuedOutputBytes >= this.#maximumOutputBytes)
|
||||
) {
|
||||
this.#child.stdout.pause()
|
||||
this.#outputPaused = true
|
||||
}
|
||||
this.#scheduleDrain()
|
||||
}
|
||||
|
||||
@@ -484,6 +476,15 @@ export class DirectLinuxStdioProcessOwner {
|
||||
for (const listener of [...this.#outputListeners]) {
|
||||
await listener({ stream: 'stdout', data: data.slice() })
|
||||
}
|
||||
if (
|
||||
this.#outputPaused &&
|
||||
this.#outputQueue.length < this.#maximumOutputQueueChunks &&
|
||||
(this.#maximumOutputBytes === 0 ||
|
||||
this.#queuedOutputBytes < this.#maximumOutputBytes)
|
||||
) {
|
||||
this.#outputPaused = false
|
||||
this.#child.stdout.resume()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
void this.stop({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
import { AgentModelGatewayError } from './agent-model-gateway'
|
||||
import { EventJournalCapacityError } from './event-journal'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import type {
|
||||
ModelBridgeBrokerDispatch,
|
||||
@@ -43,8 +44,16 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
|
||||
}> = []
|
||||
mainAckSequence = '0'
|
||||
failAppend = false
|
||||
capacityBlocked = false
|
||||
|
||||
appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } {
|
||||
if (this.capacityBlocked) {
|
||||
throw new EventJournalCapacityError(
|
||||
'journal capacity',
|
||||
'stream',
|
||||
false
|
||||
)
|
||||
}
|
||||
if (this.failAppend) {
|
||||
throw new Error('journal capacity')
|
||||
}
|
||||
@@ -415,8 +424,6 @@ function harness(input: {
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 100,
|
||||
maximumTotalOutputTokens: 409_600,
|
||||
requestTimeoutMilliseconds: 60_000
|
||||
}
|
||||
},
|
||||
@@ -541,6 +548,52 @@ describe('RuntimeAcpBackend', () => {
|
||||
expect(fixture.blobSink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows more than 100 Agent-local model calls in one prompt', async () => {
|
||||
const dispatch = vi.fn(async () => ({
|
||||
response: {
|
||||
status: 200,
|
||||
headers: {},
|
||||
bodyBase64: Buffer.from('{}').toString('base64')
|
||||
},
|
||||
acknowledgeDelivery: vi.fn(async () => undefined),
|
||||
failDelivery: vi.fn()
|
||||
}))
|
||||
const fixture = harness({
|
||||
agentOwned: true,
|
||||
modelGateway: {
|
||||
dispatch,
|
||||
finalizePrompt: vi.fn()
|
||||
}
|
||||
})
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
fixture,
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation()
|
||||
)
|
||||
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
const exchange = await fixture.bridgeDispatch!(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.from('{}').toString('base64')
|
||||
},
|
||||
{
|
||||
requestId: `provider-request-${index}`,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
)
|
||||
if (!('acknowledgeDelivery' in exchange)) {
|
||||
throw new Error('Expected an acknowledged model exchange')
|
||||
}
|
||||
await exchange.acknowledgeDelivery()
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(101)
|
||||
})
|
||||
|
||||
it('runs open, prepare, ACP delivery, durable output, cursors, and close', async () => {
|
||||
const fixture = harness()
|
||||
const opened = await invoke(
|
||||
@@ -1162,7 +1215,7 @@ describe('RuntimeAcpBackend', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stops the process when its output quota is exceeded', async () => {
|
||||
it('does not stop the process when cumulative output exceeds the display budget', async () => {
|
||||
const fixture = harness({ maximumOutputBytes: 3 })
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
@@ -1172,9 +1225,30 @@ describe('RuntimeAcpBackend', () => {
|
||||
)
|
||||
await expect(
|
||||
fixture.process.emit('stdout', 'four')
|
||||
).rejects.toMatchObject({ code: 'output-quota' })
|
||||
expect(fixture.process.stops).toEqual(['output-quota'])
|
||||
).resolves.toBeUndefined()
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.outputFrames).toEqual(['four'])
|
||||
})
|
||||
|
||||
it('waits for durable journal capacity instead of stopping Runtime output', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
fixture,
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation()
|
||||
)
|
||||
fixture.journal.capacityBlocked = true
|
||||
const output = fixture.process.emit('stdout', 'later')
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10))
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.outputFrames).toEqual([])
|
||||
|
||||
fixture.journal.capacityBlocked = false
|
||||
await output
|
||||
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.outputFrames).toEqual(['later'])
|
||||
})
|
||||
|
||||
it('stops with output quota when the detached durable journal is exhausted', async () => {
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
type AgentModelGateway
|
||||
} from './agent-model-gateway'
|
||||
import { openCodeModelBridgeModelId } from './model-bridge-helper'
|
||||
import { EventJournalCapacityError } from './event-journal'
|
||||
|
||||
const ZERO_CURSOR = '0'
|
||||
const DEFAULT_MAXIMUM_BINDINGS = 128
|
||||
@@ -82,6 +83,7 @@ const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_MILLISECONDS = 25
|
||||
|
||||
export type RuntimeAcpBackendErrorCode =
|
||||
| 'capacity'
|
||||
@@ -317,8 +319,6 @@ type BindingState = {
|
||||
activeOperationId?: string
|
||||
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
inputBytes: number
|
||||
outputBytes: number
|
||||
maximumOutputBytes?: number
|
||||
nextOutputSequence: bigint
|
||||
pendingInput: number
|
||||
pendingOutput: number
|
||||
@@ -721,8 +721,6 @@ export class RuntimeAcpBackend {
|
||||
existing.stopRequested = false
|
||||
existing.terminalState = undefined
|
||||
existing.inputBytes = 0
|
||||
existing.outputBytes = 0
|
||||
existing.maximumOutputBytes = undefined
|
||||
existing.nextOutputSequence = 1n
|
||||
existing.pendingInput = 0
|
||||
existing.pendingOutput = 0
|
||||
@@ -774,7 +772,6 @@ export class RuntimeAcpBackend {
|
||||
stopRequested: false,
|
||||
operations: new Map(),
|
||||
inputBytes: 0,
|
||||
outputBytes: 0,
|
||||
nextOutputSequence: 1n,
|
||||
pendingInput: 0,
|
||||
pendingOutput: 0,
|
||||
@@ -1234,11 +1231,6 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
binding.inputBytes = 0
|
||||
binding.outputBytes = 0
|
||||
binding.maximumOutputBytes = Math.min(
|
||||
preparation.budget.maximumOutputBytes,
|
||||
verified.manifest.limits.maximumPromptOutputBytes
|
||||
)
|
||||
if (!binding.operations.has(preparation.operationId)) {
|
||||
binding.operations.set(preparation.operationId, {
|
||||
preparationDigest,
|
||||
@@ -1308,10 +1300,6 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
await this.#closeModelBridge(binding, true)
|
||||
this.#options.modelGateway?.finalizePrompt(
|
||||
binding.request.bindingId,
|
||||
operation.operationId
|
||||
)
|
||||
if (binding.poisoned) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Model response delivery was not proven before prompt completion',
|
||||
@@ -1336,8 +1324,6 @@ export class RuntimeAcpBackend {
|
||||
prepared.completion = completion
|
||||
binding.activeOperationId = undefined
|
||||
binding.inputBytes = 0
|
||||
binding.outputBytes = 0
|
||||
binding.maximumOutputBytes = undefined
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
binding.deadlineTimer = undefined
|
||||
@@ -1527,10 +1513,6 @@ export class RuntimeAcpBackend {
|
||||
return
|
||||
}
|
||||
await this.#closeModelBridge(binding, true)
|
||||
this.#options.modelGateway?.finalizePrompt(
|
||||
binding.request.bindingId,
|
||||
operationId
|
||||
)
|
||||
try {
|
||||
await binding.process.completePrompt()
|
||||
} catch {
|
||||
@@ -1568,8 +1550,6 @@ export class RuntimeAcpBackend {
|
||||
prepared.modelProfile = undefined
|
||||
binding.activeOperationId = undefined
|
||||
binding.inputBytes = 0
|
||||
binding.outputBytes = 0
|
||||
binding.maximumOutputBytes = undefined
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
binding.deadlineTimer = undefined
|
||||
@@ -1673,12 +1653,6 @@ export class RuntimeAcpBackend {
|
||||
'closed'
|
||||
)
|
||||
}
|
||||
if (binding.nextModelRound >= profile.limits.maximumModelCalls) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Prompt model call limit reached',
|
||||
'capacity'
|
||||
)
|
||||
}
|
||||
const roundIndex = binding.nextModelRound
|
||||
let exchanged
|
||||
try {
|
||||
@@ -1990,21 +1964,6 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
this.#assertBeforeDeadline(binding)
|
||||
const budget = this.#activeBudget(binding)
|
||||
const maximumOutputBytes = Math.min(
|
||||
budget.maximumOutputBytes,
|
||||
binding.maximumOutputBytes ?? budget.maximumOutputBytes
|
||||
)
|
||||
if (binding.outputBytes + output.data.byteLength > maximumOutputBytes) {
|
||||
await this.#stopAndReconcile(binding, 'output-quota').catch(
|
||||
() => undefined
|
||||
)
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime output quota reached',
|
||||
'output-quota'
|
||||
)
|
||||
}
|
||||
binding.outputBytes += output.data.byteLength
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < output.data.byteLength;
|
||||
@@ -2017,14 +1976,31 @@ export class RuntimeAcpBackend {
|
||||
try {
|
||||
await this.#enqueueOutput(binding, payload, async () => {
|
||||
const sequence = binding.nextOutputSequence.toString()
|
||||
this.#options.journal.appendAcpFrame({
|
||||
controllerId: binding.controllerId,
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
direction: 'runtime-to-main',
|
||||
sequence,
|
||||
payload
|
||||
})
|
||||
while (true) {
|
||||
try {
|
||||
this.#options.journal.appendAcpFrame({
|
||||
controllerId: binding.controllerId,
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
direction: 'runtime-to-main',
|
||||
sequence,
|
||||
payload
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
if (!(error instanceof EventJournalCapacityError)) {
|
||||
throw error
|
||||
}
|
||||
if (binding.state !== 'running') {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime output arrived after the binding stopped',
|
||||
'closed'
|
||||
)
|
||||
}
|
||||
this.#assertBeforeDeadline(binding)
|
||||
await delay(ACP_JOURNAL_RETRY_MILLISECONDS)
|
||||
}
|
||||
}
|
||||
binding.nextOutputSequence += 1n
|
||||
if (binding.transportState === 'live') {
|
||||
try {
|
||||
@@ -2287,12 +2263,6 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#finishProcess(binding: BindingState): void {
|
||||
if (binding.activeOperationId !== undefined) {
|
||||
this.#options.modelGateway?.finalizePrompt(
|
||||
binding.request.bindingId,
|
||||
binding.activeOperationId
|
||||
)
|
||||
}
|
||||
if (binding.process !== undefined) {
|
||||
this.#processIdentityBindings.delete(
|
||||
processIdentityKey(binding.process.identity)
|
||||
@@ -2750,6 +2720,13 @@ function processIdentityKey(identity: RuntimeAcpProcessIdentity): string {
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
async function delay(milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, milliseconds)
|
||||
timer.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
function digestSecretBearingPreparation(
|
||||
preparation: RemotePromptOperationPreparation
|
||||
): string {
|
||||
|
||||
@@ -1544,7 +1544,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds native subagent output per event and per request', async () => {
|
||||
it('bounds native subagent output per event without a request-wide stop', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
@@ -1620,10 +1620,12 @@ describe('AcpRemoteRuntime', () => {
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
).rejects.toThrow('远端 Runtime 请求输出超过安全上限')
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({ type: 'done' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects more than 100 distinct remote tool calls', async () => {
|
||||
it('accepts more than 100 distinct remote tool calls', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
@@ -1634,7 +1636,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: `call-${index}`,
|
||||
title: 'tool',
|
||||
status: 'in_progress'
|
||||
status: 'completed'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1643,17 +1645,18 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
client.value = server.client
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime(server).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
const events = await collect(
|
||||
runtime(server).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'远端 Runtime 单次运行的工具调用超过 100 个'
|
||||
)
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === 'tool')
|
||||
).toHaveLength(101)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('does not impose a default wall-clock deadline on remote prompts', async () => {
|
||||
@@ -2235,7 +2238,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels and truncates a flood when the consumer is slow', async () => {
|
||||
it('backpressures a flood without cancelling the Runtime', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const cancel = vi.fn(async () => {})
|
||||
@@ -2276,15 +2279,26 @@ describe('AcpRemoteRuntime', () => {
|
||||
done: false
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await expect(stream.next()).rejects.toThrow('输出已截断')
|
||||
const events = []
|
||||
while (true) {
|
||||
const next = await stream.next()
|
||||
if (next.done) {
|
||||
break
|
||||
}
|
||||
events.push(next.value)
|
||||
}
|
||||
expect(
|
||||
events.filter((event) => event.type === 'text')
|
||||
).toHaveLength(20)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
|
||||
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(server.setInboundPaused).toHaveBeenCalledWith(false)
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
store.getByConversation(request.conversationId)
|
||||
).resolves.toMatchObject({
|
||||
state: 'outcome-unknown',
|
||||
activePromptOperationId: request.requestId
|
||||
state: 'ready',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2504,8 +2518,6 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 100,
|
||||
maximumTotalOutputTokens: 409_600,
|
||||
requestTimeoutMilliseconds: 60_000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
import type {
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
assertRemotePromptAcceptanceMatchesPreparation,
|
||||
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS,
|
||||
@@ -148,7 +145,6 @@ type ActivePrompt = {
|
||||
updates: QueuedSessionUpdate[]
|
||||
pendingUpdateBytes: number
|
||||
inboundPaused: boolean
|
||||
outputCharacters: number
|
||||
toolCalls: Map<
|
||||
string,
|
||||
{
|
||||
@@ -1846,19 +1842,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
this.options.maxPendingUpdateBytes ??
|
||||
DEFAULT_MAX_PENDING_UPDATE_BYTES
|
||||
)
|
||||
if (
|
||||
prompt.updates.length >= maximumItems ||
|
||||
prompt.pendingUpdateBytes + bytes > maximumBytes
|
||||
) {
|
||||
const overflow = new Error(
|
||||
'远端 Runtime 待消费事件超过安全上限,输出已截断且请求状态未知'
|
||||
)
|
||||
prompt.interruption = overflow
|
||||
prompt.interrupt?.(overflow)
|
||||
prompt.wake?.()
|
||||
prompt.wake = undefined
|
||||
throw overflow
|
||||
}
|
||||
let consume: (() => void) | undefined
|
||||
const consumed = new Promise<void>((resolve) => {
|
||||
consume = resolve
|
||||
@@ -2030,19 +2013,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
private limitEvent(
|
||||
prompt: ActivePrompt,
|
||||
event: RuntimePublicEvent
|
||||
): RuntimePublicEvent {
|
||||
private limitEvent(event: RuntimePublicEvent): RuntimePublicEvent {
|
||||
const maximum =
|
||||
this.options.maxEventCharacters ??
|
||||
DEFAULT_MAX_EVENT_CHARACTERS
|
||||
let characters = 0
|
||||
if (event.type === 'text' || event.type === 'reasoning') {
|
||||
characters = event.delta.length
|
||||
if (characters > maximum) {
|
||||
if (event.delta.length > maximum) {
|
||||
event = { ...event, delta: `${event.delta.slice(0, maximum)}…` }
|
||||
characters = maximum + 1
|
||||
}
|
||||
} else if (event.type === 'tool') {
|
||||
const fieldMaximum = Math.max(1, Math.floor(maximum / 3))
|
||||
@@ -2056,10 +2033,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
output: bounded(event.output),
|
||||
error: bounded(event.error)
|
||||
}
|
||||
characters =
|
||||
(event.input?.length ?? 0) +
|
||||
(event.output?.length ?? 0) +
|
||||
(event.error?.length ?? 0)
|
||||
} else if (event.type === 'subagent') {
|
||||
const fieldMaximum = Math.max(1, Math.floor(maximum / 2))
|
||||
const bounded = (value: string | undefined): string | undefined =>
|
||||
@@ -2071,10 +2044,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
output: bounded(event.output),
|
||||
error: bounded(event.error)
|
||||
}
|
||||
characters =
|
||||
(event.reason?.length ?? 0) +
|
||||
(event.output?.length ?? 0) +
|
||||
(event.error?.length ?? 0)
|
||||
} else if (event.type === 'status') {
|
||||
if (event.message.length > maximum) {
|
||||
event = {
|
||||
@@ -2082,15 +2051,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
message: `${event.message.slice(0, maximum)}…`
|
||||
}
|
||||
}
|
||||
characters = event.message.length
|
||||
}
|
||||
prompt.outputCharacters += characters
|
||||
if (
|
||||
prompt.outputCharacters >
|
||||
(this.options.maxRequestOutputBytes ??
|
||||
DEFAULT_MAX_REQUEST_OUTPUT_BYTES)
|
||||
) {
|
||||
throw new Error('远端 Runtime 请求输出超过安全上限')
|
||||
}
|
||||
return event
|
||||
}
|
||||
@@ -2120,15 +2080,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
) {
|
||||
const callId = update.toolCallId.slice(0, 256)
|
||||
const previous = prompt.toolCalls.get(callId)
|
||||
if (
|
||||
!previous &&
|
||||
prompt.toolCalls.size >=
|
||||
maximumConversationToolActivities
|
||||
) {
|
||||
throw new Error(
|
||||
'远端 Runtime 单次运行的工具调用超过 100 个'
|
||||
)
|
||||
}
|
||||
const retainedMaximum =
|
||||
this.options.maxEventCharacters ??
|
||||
DEFAULT_MAX_EVENT_CHARACTERS
|
||||
@@ -2359,7 +2310,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
updates: [],
|
||||
pendingUpdateBytes: 0,
|
||||
inboundPaused: false,
|
||||
outputCharacters: 0,
|
||||
toolCalls: new Map(),
|
||||
open: true,
|
||||
completed: false,
|
||||
@@ -2559,7 +2509,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
)
|
||||
const mapped = this.mapUpdate(prompt, notification.update)
|
||||
if (mapped !== undefined) {
|
||||
publicEvents.push(this.limitEvent(prompt, mapped))
|
||||
publicEvents.push(this.limitEvent(mapped))
|
||||
}
|
||||
} else if (transcriptEvent.kind === 'prompt-terminal') {
|
||||
const terminal = transcriptTerminal(
|
||||
@@ -2764,7 +2714,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
updates: [],
|
||||
pendingUpdateBytes: 0,
|
||||
inboundPaused: false,
|
||||
outputCharacters: 0,
|
||||
toolCalls: new Map(),
|
||||
open: true,
|
||||
completed: false,
|
||||
@@ -2867,7 +2816,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
}
|
||||
const event = this.mapUpdate(prompt, update)
|
||||
if (event) {
|
||||
yield this.limitEvent(prompt, event)
|
||||
yield this.limitEvent(event)
|
||||
}
|
||||
}
|
||||
await this.awaitOperation(
|
||||
|
||||
@@ -1176,7 +1176,7 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(killed).toBe(true)
|
||||
})
|
||||
|
||||
it('fails when the patched host reports dropped stream events', async () => {
|
||||
it('completes from final history when the patched stream is truncated', async () => {
|
||||
const distribution = await createDistribution()
|
||||
let stateRequests = 0
|
||||
vi.stubGlobal(
|
||||
@@ -1185,11 +1185,23 @@ describe('ContinueHostAdapter', () => {
|
||||
if (String(input).endsWith('/state')) {
|
||||
stateRequests += 1
|
||||
return Response.json({
|
||||
session: { history: [] },
|
||||
isProcessing: stateRequests > 1,
|
||||
session: {
|
||||
history:
|
||||
stateRequests === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'CONTINUE_OK'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
isProcessing: false,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null,
|
||||
goodbuddyEventsOverflow: stateRequests > 1
|
||||
goodbuddyEventsOverflow: stateRequests === 2
|
||||
})
|
||||
}
|
||||
return Response.json({})
|
||||
@@ -1224,27 +1236,15 @@ describe('ContinueHostAdapter', () => {
|
||||
new AbortController().signal,
|
||||
async () => 'deny'
|
||||
)
|
||||
).rejects.toThrow('流式事件超过安全限制')
|
||||
).resolves.toMatchObject({
|
||||
text: 'CONTINUE_OK',
|
||||
streamTruncated: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'event count',
|
||||
limits: {
|
||||
maximumStreamEvents: 1,
|
||||
maximumStreamEventBytes: 10_000
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'event bytes',
|
||||
limits: {
|
||||
maximumStreamEvents: 10,
|
||||
maximumStreamEventBytes: 60
|
||||
}
|
||||
}
|
||||
])(
|
||||
'enforces cumulative streamed $label across state polls',
|
||||
async ({ limits }) => {
|
||||
it(
|
||||
'truncates cumulative streamed bytes without stopping the run',
|
||||
async () => {
|
||||
const distribution = await createDistribution()
|
||||
let stateRequests = 0
|
||||
vi.stubGlobal(
|
||||
@@ -1253,8 +1253,20 @@ describe('ContinueHostAdapter', () => {
|
||||
if (String(input).endsWith('/state')) {
|
||||
stateRequests += 1
|
||||
return Response.json({
|
||||
session: { history: [] },
|
||||
isProcessing: stateRequests > 1,
|
||||
session: {
|
||||
history:
|
||||
stateRequests < 3
|
||||
? []
|
||||
: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '1234567890CONTINUE_OK'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
isProcessing: stateRequests === 2,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null,
|
||||
goodbuddyEvents:
|
||||
@@ -1291,8 +1303,7 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
...limits,
|
||||
maximumToolCalls: 100,
|
||||
maximumStreamEventBytes: 60,
|
||||
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
)
|
||||
@@ -1308,14 +1319,18 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('流式事件超过安全限制')
|
||||
).resolves.toMatchObject({
|
||||
text: '1234567890CONTINUE_OK',
|
||||
streamedText: true,
|
||||
streamTruncated: true
|
||||
})
|
||||
expect(forwarded).toEqual([
|
||||
{ type: 'text', delta: '1234567890' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('enforces cumulative unique tool calls before forwarding a later batch', async () => {
|
||||
it('forwards more than 100 unique tool calls', async () => {
|
||||
const distribution = await createDistribution()
|
||||
let stateRequests = 0
|
||||
vi.stubGlobal(
|
||||
@@ -1324,20 +1339,30 @@ describe('ContinueHostAdapter', () => {
|
||||
if (String(input).endsWith('/state')) {
|
||||
stateRequests += 1
|
||||
return Response.json({
|
||||
session: { history: [] },
|
||||
isProcessing: stateRequests > 1,
|
||||
session: {
|
||||
history:
|
||||
stateRequests === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'TOOLS_OK'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
isProcessing: false,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null,
|
||||
goodbuddyEvents:
|
||||
stateRequests > 1
|
||||
? [
|
||||
{
|
||||
type: 'tool',
|
||||
callId: `call-${stateRequests}`,
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
}
|
||||
]
|
||||
stateRequests === 2
|
||||
? Array.from({ length: 101 }, (_, index) => ({
|
||||
type: 'tool',
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
}))
|
||||
: []
|
||||
})
|
||||
}
|
||||
@@ -1369,9 +1394,7 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
maximumStreamEvents: 10,
|
||||
maximumStreamEventBytes: 10_000,
|
||||
maximumToolCalls: 1,
|
||||
maximumStreamEventBytes: 1_000_000,
|
||||
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
)
|
||||
@@ -1387,11 +1410,11 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('工具调用超过 100 个')
|
||||
expect(forwarded).toHaveLength(1)
|
||||
expect(forwarded[0]).toMatchObject({
|
||||
).resolves.toMatchObject({ text: 'TOOLS_OK' })
|
||||
expect(forwarded).toHaveLength(101)
|
||||
expect(forwarded.at(-1)).toMatchObject({
|
||||
type: 'tool',
|
||||
tool: { callId: 'call-2' }
|
||||
tool: { callId: 'call-101' }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1461,9 +1484,7 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
maximumStreamEvents: 10,
|
||||
maximumStreamEventBytes: 10_000,
|
||||
maximumToolCalls: 10,
|
||||
terminateProcessTree
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,9 +26,6 @@ import type {
|
||||
RuntimeNativeSnapshot,
|
||||
RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
continueConfigurationPresetSchema,
|
||||
runtimeNativeInventoryLimits,
|
||||
@@ -69,9 +66,7 @@ const maximumConfiguredMcpServers =
|
||||
runtimeNativeInventoryLimits.mcpServers
|
||||
const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
|
||||
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
|
||||
const maximumStreamEvents = 5_000
|
||||
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||
const maximumToolCalls = maximumConversationToolActivities
|
||||
const defaultControlRequestTimeoutMs = 30_000
|
||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||
const customMcpName = 'goodbuddy-custom-mcp'
|
||||
@@ -142,7 +137,7 @@ const continueHostQuestionSchema = z
|
||||
|
||||
const stateSchema = z.object({
|
||||
session: z.object({
|
||||
history: z.array(z.unknown()).max(5_000),
|
||||
history: z.array(z.unknown()),
|
||||
usage: sessionUsageSchema.optional()
|
||||
}),
|
||||
isProcessing: z.boolean(),
|
||||
@@ -152,12 +147,11 @@ const stateSchema = z.object({
|
||||
toolName: z.string().min(1).max(128),
|
||||
toolArgs: z.record(z.string(), z.unknown()),
|
||||
requestId: z.string().min(1).max(256),
|
||||
toolCallPreview: z.array(z.unknown()).max(100).optional()
|
||||
toolCallPreview: z.array(z.unknown()).optional()
|
||||
})
|
||||
.nullable(),
|
||||
goodbuddyEvents: z
|
||||
.array(continueHostStreamEventSchema)
|
||||
.max(maximumStreamEvents)
|
||||
.optional(),
|
||||
goodbuddyEventsOverflow: z.boolean().optional(),
|
||||
goodbuddyQuestion: continueHostQuestionSchema.nullable().optional()
|
||||
@@ -199,6 +193,7 @@ export type ContinueHostTool = {
|
||||
export type ContinueHostRunResult = {
|
||||
text: string
|
||||
streamedText?: true
|
||||
streamTruncated?: true
|
||||
usage?: ContinueHostUsage
|
||||
tools?: ContinueHostTool[]
|
||||
}
|
||||
@@ -249,9 +244,7 @@ export type ContinueHostAdapterOptions = {
|
||||
|
||||
export type ContinueHostAdapterDependencies = {
|
||||
terminateProcessTree: typeof terminateProcessTreeAndWait
|
||||
maximumStreamEvents: number
|
||||
maximumStreamEventBytes: number
|
||||
maximumToolCalls: number
|
||||
controlRequestTimeoutMs: number
|
||||
}
|
||||
|
||||
@@ -713,9 +706,6 @@ function extractContinueTools(
|
||||
if (!callId || typeof name !== 'string' || !name.trim()) {
|
||||
continue
|
||||
}
|
||||
if (!tools.has(callId) && tools.size >= 100) {
|
||||
throw new Error('Continue 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
const status = state.status
|
||||
const normalizedState =
|
||||
status === 'done' || status === 'completed'
|
||||
@@ -834,9 +824,7 @@ export class ContinueHostAdapter {
|
||||
) {
|
||||
this.dependencies = {
|
||||
terminateProcessTree: terminateProcessTreeAndWait,
|
||||
maximumStreamEvents,
|
||||
maximumStreamEventBytes,
|
||||
maximumToolCalls,
|
||||
controlRequestTimeoutMs: defaultControlRequestTimeoutMs,
|
||||
...dependencies
|
||||
}
|
||||
@@ -985,7 +973,7 @@ export class ContinueHostAdapter {
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
streamCallbacksMarker,
|
||||
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
|
||||
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
@@ -1584,7 +1572,7 @@ export class ContinueHostAdapter {
|
||||
let observedTools: ContinueHostTool[] = []
|
||||
const reportedQuestionIds = new Set<string>()
|
||||
let streamedText = false
|
||||
let streamEventCount = 0
|
||||
let streamTruncated = false
|
||||
let streamEventBytes = 0
|
||||
const observedToolCallIds = new Set<string>()
|
||||
try {
|
||||
@@ -1635,38 +1623,28 @@ export class ContinueHostAdapter {
|
||||
signal: executionSignal
|
||||
})
|
||||
)
|
||||
if (state.goodbuddyEventsOverflow) {
|
||||
throw new Error('Continue 宿主流式事件超过安全限制')
|
||||
}
|
||||
const streamEvents = state.goodbuddyEvents ?? []
|
||||
streamTruncated ||= state.goodbuddyEventsOverflow === true
|
||||
let streamEvents = state.goodbuddyEvents ?? []
|
||||
const batchStreamEventBytes = Buffer.byteLength(
|
||||
JSON.stringify(streamEvents)
|
||||
)
|
||||
const nextStreamEventCount =
|
||||
streamEventCount + streamEvents.length
|
||||
const nextStreamEventBytes =
|
||||
streamEventBytes + batchStreamEventBytes
|
||||
if (
|
||||
nextStreamEventBytes >
|
||||
this.dependencies.maximumStreamEventBytes
|
||||
) {
|
||||
streamTruncated = true
|
||||
streamEvents = []
|
||||
} else {
|
||||
streamEventBytes = nextStreamEventBytes
|
||||
}
|
||||
const nextToolCallIds = new Set(observedToolCallIds)
|
||||
for (const event of streamEvents) {
|
||||
if (event.type === 'tool') {
|
||||
nextToolCallIds.add(event.callId)
|
||||
}
|
||||
}
|
||||
if (
|
||||
nextStreamEventCount >
|
||||
this.dependencies.maximumStreamEvents ||
|
||||
nextStreamEventBytes >
|
||||
this.dependencies.maximumStreamEventBytes
|
||||
) {
|
||||
throw new Error('Continue 宿主流式事件超过安全限制')
|
||||
}
|
||||
if (
|
||||
nextToolCallIds.size > this.dependencies.maximumToolCalls
|
||||
) {
|
||||
throw new Error('Continue 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
streamEventCount = nextStreamEventCount
|
||||
streamEventBytes = nextStreamEventBytes
|
||||
for (const callId of nextToolCallIds) {
|
||||
observedToolCallIds.add(callId)
|
||||
}
|
||||
@@ -1677,11 +1655,6 @@ export class ContinueHostAdapter {
|
||||
for (const tool of historyTools) {
|
||||
observedToolCallIds.add(tool.callId)
|
||||
}
|
||||
if (
|
||||
observedToolCallIds.size > this.dependencies.maximumToolCalls
|
||||
) {
|
||||
throw new Error('Continue 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
observedTools = mergeContinueTools(
|
||||
observedTools,
|
||||
historyTools
|
||||
@@ -1744,12 +1717,6 @@ export class ContinueHostAdapter {
|
||||
}
|
||||
const pending = state.pendingPermission
|
||||
if (pending && !handledPermissionIds.has(pending.requestId)) {
|
||||
if (
|
||||
handledPermissionIds.size >=
|
||||
this.dependencies.maximumToolCalls
|
||||
) {
|
||||
throw new Error('Continue 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
handledPermissionIds.add(pending.requestId)
|
||||
const pendingCallId =
|
||||
observedTools.find(
|
||||
@@ -1761,13 +1728,6 @@ export class ContinueHostAdapter {
|
||||
if (
|
||||
!observedTools.some((tool) => tool.callId === pendingCallId)
|
||||
) {
|
||||
if (
|
||||
!observedToolCallIds.has(pendingCallId) &&
|
||||
observedToolCallIds.size >=
|
||||
this.dependencies.maximumToolCalls
|
||||
) {
|
||||
throw new Error('Continue 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
observedToolCallIds.add(pendingCallId)
|
||||
observedTools = [
|
||||
...observedTools,
|
||||
@@ -1829,6 +1789,9 @@ export class ContinueHostAdapter {
|
||||
return {
|
||||
text,
|
||||
...(streamedText ? { streamedText: true as const } : {}),
|
||||
...(streamTruncated
|
||||
? { streamTruncated: true as const }
|
||||
: {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(observedTools.length > 0
|
||||
? { tools: observedTools }
|
||||
|
||||
@@ -1134,7 +1134,7 @@ describe('ContinueAgentRuntime', () => {
|
||||
).rejects.toThrow('已失效或不存在')
|
||||
})
|
||||
|
||||
it('fails instead of silently dropping an overflowing stream queue', async () => {
|
||||
it('drains more than 1,000 queued stream events without stopping the run', async () => {
|
||||
mocks.runHost.mockImplementation(
|
||||
async (
|
||||
_prompt,
|
||||
@@ -1160,11 +1160,15 @@ describe('ContinueAgentRuntime', () => {
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _event of stream) {
|
||||
void _event
|
||||
}
|
||||
}).rejects.toThrow('流式事件积压超过安全限制')
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === 'text')
|
||||
).toHaveLength(1_001)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('aborts the host run when stream consumption ends early', async () => {
|
||||
|
||||
@@ -64,7 +64,6 @@ export type ContinueRuntimeOptions = {
|
||||
// The prompt reaches the Continue host through a local HTTP POST body, so no
|
||||
// platform command-line limit applies to it.
|
||||
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
|
||||
const MAX_QUEUED_STREAM_EVENTS = 1_000
|
||||
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
||||
|
||||
function continueToolFailureMessage(tool: ContinueHostTool): string {
|
||||
@@ -548,6 +547,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
let result: ContinueHostRunResult
|
||||
const emittedTools = new Map<string, ContinueHostTool>()
|
||||
let emittedText = ''
|
||||
const requestQuestionIds = new Set<string>()
|
||||
try {
|
||||
const host = this.getHostAdapter(
|
||||
@@ -574,9 +574,6 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
const hostController = new AbortController()
|
||||
const hostSignal = AbortSignal.any([signal, hostController.signal])
|
||||
const onEvent = (event: ContinueHostStreamEvent): void => {
|
||||
if (queuedEvents.length >= MAX_QUEUED_STREAM_EVENTS) {
|
||||
throw new Error('Continue 流式事件积压超过安全限制')
|
||||
}
|
||||
queuedEvents.push(event)
|
||||
wakeStream?.()
|
||||
wakeStream = undefined
|
||||
@@ -623,6 +620,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
emittedTools.set(event.tool.callId, event.tool)
|
||||
}
|
||||
if (event.type === 'text') {
|
||||
emittedText += event.delta
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
@@ -742,11 +740,25 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
yield finalEvent
|
||||
}
|
||||
}
|
||||
if (!result.streamedText) {
|
||||
if (result.streamTruncated) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.text
|
||||
type: 'status',
|
||||
message:
|
||||
'Continue 输出较长,已截断部分流式展示;任务已继续完成'
|
||||
}
|
||||
}
|
||||
if (!result.streamedText || result.streamTruncated) {
|
||||
const remainingText =
|
||||
result.streamTruncated && result.text.startsWith(emittedText)
|
||||
? result.text.slice(emittedText.length)
|
||||
: result.text
|
||||
if (remainingText) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: remainingText
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.usage) {
|
||||
|
||||
@@ -2035,7 +2035,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails before emitting text that exceeds the aggregate output budget', async () => {
|
||||
it('truncates oversized text display without stopping the run', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'first-text',
|
||||
@@ -2056,29 +2056,50 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
field: 'text',
|
||||
delta: 'b'.repeat(600_000)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'test',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
const events = await collectRun(runtime, 'execute')
|
||||
const text = events
|
||||
.flatMap((event) => event.type === 'text' ? [event.delta] : [])
|
||||
.join('')
|
||||
|
||||
await stream.next()
|
||||
const firstText = await stream.next()
|
||||
expect(firstText.value).toMatchObject({
|
||||
type: 'text',
|
||||
delta: expect.stringMatching(/^a+$/u)
|
||||
expect(Buffer.byteLength(text)).toBe(1024 * 1024)
|
||||
expect(events).toContainEqual({
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
type: 'status',
|
||||
message:
|
||||
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
|
||||
})
|
||||
await expect(stream.next()).rejects.toThrow(
|
||||
'文本与推理输出超过 1 MB 安全限制'
|
||||
)
|
||||
expect(setup.session.abort).toHaveBeenCalled()
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(setup.session.abort).not.toHaveBeenCalled()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('completes more than 100 distinct tool calls in one run', async () => {
|
||||
const setup = runClient([
|
||||
...Array.from({ length: 101 }, (_, index) =>
|
||||
completedToolEvent(`call-${index + 1}`)
|
||||
),
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
|
||||
const events = await collectRun(runtime, 'execute')
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === 'tool')
|
||||
).toHaveLength(101)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -2184,6 +2205,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
read: false,
|
||||
write: false,
|
||||
bash: false,
|
||||
'goodbuddy-data-*': false,
|
||||
'goodbuddy-custom-*': false,
|
||||
[knowledgeToolId]: true
|
||||
}
|
||||
}),
|
||||
@@ -2264,7 +2287,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('serializes overlapping embedded MCP registration and discovery', async () => {
|
||||
it('runs embedded conversations concurrently with request-scoped MCP registrations', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'idle',
|
||||
@@ -2357,13 +2380,19 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
'conversation-two',
|
||||
'second-token'
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(mcpAdd).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirstAdd()
|
||||
await first
|
||||
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2))
|
||||
await second
|
||||
try {
|
||||
await vi.waitFor(
|
||||
() => expect(mcpAdd).toHaveBeenCalledTimes(2),
|
||||
{ timeout: 500 }
|
||||
)
|
||||
await vi.waitFor(
|
||||
() => expect(setup.session.promptAsync).toHaveBeenCalledOnce(),
|
||||
{ timeout: 500 }
|
||||
)
|
||||
} finally {
|
||||
resolveFirstAdd()
|
||||
}
|
||||
await Promise.all([first, second])
|
||||
expect(
|
||||
mcpAdd.mock.calls.map(
|
||||
([input]) =>
|
||||
@@ -2483,6 +2512,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
write: false,
|
||||
bash: false,
|
||||
task: false,
|
||||
'goodbuddy-data-*': false,
|
||||
'goodbuddy-custom-*': false,
|
||||
skill: true
|
||||
}
|
||||
}),
|
||||
@@ -2557,11 +2588,30 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
title: 'GoodBuddy 对话',
|
||||
directory: process.cwd(),
|
||||
permission: [
|
||||
{ permission: '*', pattern: '*', action: 'allow' }
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{
|
||||
permission: 'goodbuddy-data-*',
|
||||
pattern: '*',
|
||||
action: 'deny'
|
||||
},
|
||||
{
|
||||
permission: 'goodbuddy-custom-*',
|
||||
pattern: '*',
|
||||
action: 'deny'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(session.promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: {
|
||||
'goodbuddy-data-*': false,
|
||||
'goodbuddy-custom-*': false
|
||||
}
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
expect(permissionReply).toHaveBeenCalledOnce()
|
||||
expect(permissionReply).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -2926,7 +2976,9 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
read: false,
|
||||
write: false,
|
||||
bash: false,
|
||||
task: false
|
||||
task: false,
|
||||
'goodbuddy-data-*': false,
|
||||
'goodbuddy-custom-*': false
|
||||
}
|
||||
}),
|
||||
expect.anything()
|
||||
|
||||
@@ -22,9 +22,6 @@ import type {
|
||||
AgentRuntimeStatus,
|
||||
SubagentEvent
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
boundedRuntimeIdentifierSchema,
|
||||
runtimeNativeInventoryLimits,
|
||||
@@ -78,7 +75,6 @@ const MAX_PERMISSION_PATTERNS = 32
|
||||
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
||||
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
|
||||
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
|
||||
const MAX_TOOL_CALLS_PER_RUN = maximumConversationToolActivities
|
||||
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
|
||||
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
|
||||
const MAX_QUESTIONS_PER_REQUEST = 4
|
||||
@@ -99,6 +95,15 @@ const TEMPORARY_MCP_PREFIXES = [
|
||||
'goodbuddy-data-',
|
||||
'goodbuddy-custom-'
|
||||
] as const
|
||||
const temporaryMcpToolOverrides = Object.fromEntries(
|
||||
TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false])
|
||||
)
|
||||
const temporaryMcpDenyRules: PermissionRuleset =
|
||||
TEMPORARY_MCP_PREFIXES.map((prefix) => ({
|
||||
permission: `${prefix}*`,
|
||||
pattern: '*',
|
||||
action: 'deny'
|
||||
}))
|
||||
const OPENCODE_INTERNAL_TOOL_IDS = new Set(['invalid'])
|
||||
const OPENCODE_BUILTIN_TOOL_KINDS: Readonly<
|
||||
Partial<Record<string, RuntimeNativeTool['kind']>>
|
||||
@@ -719,7 +724,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
upstreamQuestionId: string
|
||||
}
|
||||
>()
|
||||
private embeddedRunTail: Promise<void> = Promise.resolve()
|
||||
private readonly conversationRunTails = new Map<string, Promise<void>>()
|
||||
private readonly dependencies: OpenCodeRuntimeDependencies
|
||||
|
||||
@@ -751,36 +755,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return this.usesEmbeddedPermissionMediation()
|
||||
}
|
||||
|
||||
private async acquireEmbeddedRun(
|
||||
signal: AbortSignal
|
||||
): Promise<() => void> {
|
||||
signal.throwIfAborted()
|
||||
const previous = this.embeddedRunTail
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
this.embeddedRunTail = previous.then(
|
||||
() => current,
|
||||
() => current
|
||||
)
|
||||
let abort!: () => void
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(signal.reason)
|
||||
})
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try {
|
||||
await Promise.race([previous, aborted])
|
||||
signal.throwIfAborted()
|
||||
return release
|
||||
} catch (error) {
|
||||
release()
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireConversationRun(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
@@ -1835,12 +1809,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const executionSignal = deadline
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: signal
|
||||
let releaseEmbedded: (() => void) | undefined
|
||||
let releaseConversation: (() => void) | undefined
|
||||
try {
|
||||
releaseEmbedded = this.usesEmbeddedPermissionMediation()
|
||||
? await this.acquireEmbeddedRun(executionSignal)
|
||||
: undefined
|
||||
releaseConversation = await this.acquireConversationRun(
|
||||
request.conversationId,
|
||||
executionSignal
|
||||
@@ -1856,7 +1826,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
clearTimeout(deadlineTimer)
|
||||
}
|
||||
releaseConversation?.()
|
||||
releaseEmbedded?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2054,6 +2023,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
request.workMode === 'execute'
|
||||
? [
|
||||
...executePermissionRules,
|
||||
...(this.usesEmbeddedPermissionMediation()
|
||||
? temporaryMcpDenyRules
|
||||
: []),
|
||||
...nativeSkillPermissionRules,
|
||||
...knowledgeToolIds.map((toolId) => ({
|
||||
permission: toolId,
|
||||
@@ -2075,7 +2047,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
...readOnlyPermissionRules,
|
||||
...nativeSkillPermissionRules
|
||||
]
|
||||
let disabledTools: Record<string, boolean> | undefined
|
||||
let toolOverrides: Record<string, boolean> | undefined
|
||||
if (request.workMode !== 'execute') {
|
||||
const tools = await this.controlRequest(
|
||||
'读取工具清单',
|
||||
@@ -2089,15 +2061,23 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (tools.error || !tools.data) {
|
||||
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
|
||||
}
|
||||
disabledTools = {
|
||||
toolOverrides = {
|
||||
...Object.fromEntries(
|
||||
tools.data.map((toolId) => [toolId, false])
|
||||
),
|
||||
...temporaryMcpToolOverrides,
|
||||
...Object.fromEntries(
|
||||
knowledgeToolIds.map((toolId) => [toolId, true])
|
||||
),
|
||||
...(nativeSkillIds.length > 0 ? { skill: true } : {})
|
||||
}
|
||||
} else if (this.usesEmbeddedPermissionMediation()) {
|
||||
toolOverrides = {
|
||||
...temporaryMcpToolOverrides,
|
||||
...Object.fromEntries(
|
||||
knowledgeToolIds.map((toolId) => [toolId, true])
|
||||
)
|
||||
}
|
||||
}
|
||||
const session = await this.getSessionId(
|
||||
client,
|
||||
@@ -2165,12 +2145,28 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const reasoningPartIds = new Set<string>()
|
||||
const reportedQuestionIds = new Map<string, string>()
|
||||
let aggregateOutputBytes = 0
|
||||
const consumeOutputBudget = (delta: string): void => {
|
||||
aggregateOutputBytes += Buffer.byteLength(delta)
|
||||
if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) {
|
||||
throw new Error(
|
||||
'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制'
|
||||
)
|
||||
let outputTruncated = false
|
||||
const retainOutputDelta = (
|
||||
delta: string
|
||||
): { delta: string; truncated: boolean } => {
|
||||
if (outputTruncated) {
|
||||
return { delta: '', truncated: false }
|
||||
}
|
||||
const bytes = Buffer.from(delta)
|
||||
const remaining =
|
||||
MAX_EXECUTION_OUTPUT_BYTES - aggregateOutputBytes
|
||||
if (bytes.byteLength <= remaining) {
|
||||
aggregateOutputBytes += bytes.byteLength
|
||||
return { delta, truncated: false }
|
||||
}
|
||||
outputTruncated = true
|
||||
aggregateOutputBytes = MAX_EXECUTION_OUTPUT_BYTES
|
||||
return {
|
||||
delta: bytes
|
||||
.subarray(0, Math.max(0, remaining))
|
||||
.toString('utf8')
|
||||
.replace(/\uFFFD+$/u, ''),
|
||||
truncated: true
|
||||
}
|
||||
}
|
||||
let hasResponseTextAfterFailure = false
|
||||
@@ -2228,8 +2224,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
nativeSkillIds.length > 0
|
||||
? undefined
|
||||
: this.options.skillInstructions || undefined,
|
||||
...(disabledTools
|
||||
? { tools: disabledTools }
|
||||
...(toolOverrides
|
||||
? { tools: toolOverrides }
|
||||
: {}),
|
||||
parts: [
|
||||
{ type: 'text' as const, text: promptText },
|
||||
@@ -2276,7 +2272,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
'thinking'
|
||||
].includes(event.properties.field)
|
||||
if (reasoning || event.properties.field === 'text') {
|
||||
consumeOutputBudget(event.properties.delta)
|
||||
if (
|
||||
!reasoning &&
|
||||
/\S/u.test(event.properties.delta) &&
|
||||
@@ -2286,10 +2281,21 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
) {
|
||||
hasResponseTextAfterFailure = true
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: reasoning ? 'reasoning' : 'text',
|
||||
delta: event.properties.delta
|
||||
const retained = retainOutputDelta(event.properties.delta)
|
||||
if (retained.delta) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: reasoning ? 'reasoning' : 'text',
|
||||
delta: retained.delta
|
||||
}
|
||||
}
|
||||
if (retained.truncated) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
message:
|
||||
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2307,12 +2313,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
throw new Error('OpenCode 工具调用 ID 格式无效')
|
||||
}
|
||||
const toolName = part.tool.slice(0, 200)
|
||||
if (
|
||||
!toolStates.has(callId) &&
|
||||
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
|
||||
) {
|
||||
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
const state =
|
||||
part.state.status === 'error' ? 'failed' : part.state.status
|
||||
if (state === 'failed') {
|
||||
@@ -2372,11 +2372,21 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
event.properties.sessionID === sessionId &&
|
||||
event.properties.delta
|
||||
) {
|
||||
consumeOutputBudget(event.properties.delta)
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: event.properties.delta
|
||||
const retained = retainOutputDelta(event.properties.delta)
|
||||
if (retained.delta) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: retained.delta
|
||||
}
|
||||
}
|
||||
if (retained.truncated) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
message:
|
||||
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2469,14 +2479,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
properties.id.length <= MAX_PERMISSION_NAME_LENGTH &&
|
||||
!repliedPermissionIds.has(properties.id)
|
||||
) {
|
||||
if (
|
||||
repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN
|
||||
) {
|
||||
throw new Error(
|
||||
'OpenCode 单次运行的权限请求超过 100 个',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
const permissionId = properties.id
|
||||
repliedPermissionIds.add(properties.id)
|
||||
const rejection = await this.controlRequest(
|
||||
@@ -2505,21 +2507,12 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (repliedPermissionIds.has(permissionRequest.id)) {
|
||||
continue
|
||||
}
|
||||
if (repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN) {
|
||||
throw new Error('OpenCode 单次运行的权限请求超过 100 个')
|
||||
}
|
||||
repliedPermissionIds.add(permissionRequest.id)
|
||||
|
||||
const callId = (
|
||||
permissionRequest.tool?.callID ?? permissionRequest.id
|
||||
)
|
||||
const toolName = permissionRequest.permission.slice(0, 200)
|
||||
if (
|
||||
!toolStates.has(callId) &&
|
||||
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
|
||||
) {
|
||||
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
|
||||
}
|
||||
toolStates.set(callId, { name: toolName, state: 'pending' })
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
@@ -2772,10 +2765,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const executionSignal = deadline
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: signal
|
||||
let releaseEmbedded: (() => void) | undefined
|
||||
let releaseConversation: (() => void) | undefined
|
||||
try {
|
||||
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
|
||||
releaseConversation = await this.acquireConversationRun(
|
||||
request.conversationId,
|
||||
executionSignal
|
||||
@@ -2932,7 +2923,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
clearTimeout(deadlineTimer)
|
||||
}
|
||||
releaseConversation?.()
|
||||
releaseEmbedded?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1256,6 +1256,11 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
)
|
||||
.join('')
|
||||
).toContain('Prism Relay')
|
||||
const modelCalls = events.filter(
|
||||
(event) => event.type === 'model-usage'
|
||||
).length
|
||||
expect(modelCalls).toBe(2)
|
||||
console.info(`REAL_OPENCODE_MCP_MODEL_CALLS=${modelCalls}`)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
|
||||
@@ -159,6 +159,43 @@ describe('AssistantDatabase', () => {
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('persists more than 100 tool activities in one message', async () => {
|
||||
const database = await createDatabase()
|
||||
const conversationId = '00000000-0000-4000-8000-000000000901'
|
||||
const messageId = '00000000-0000-4000-8000-000000000902'
|
||||
const tools = Array.from({ length: 101 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
summary: `Tool ${index + 1}`
|
||||
}))
|
||||
|
||||
database.saveLocalConversations([
|
||||
{
|
||||
header: {
|
||||
id: conversationId,
|
||||
title: '长工具链',
|
||||
updatedAt: 1
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: messageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
tools
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
database.getConversation(conversationId)?.messages[0]?.tools
|
||||
).toHaveLength(101)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('lists projects by creation time with newer projects last', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-07T00:00:00.000Z'))
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
maximumConversationSubagentActivities,
|
||||
maximumConversationToolActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
@@ -1170,7 +1168,6 @@ function serializeConversationMessageMetadata(
|
||||
}
|
||||
|
||||
const maximumRecoveredMessageLength = 1_000_000
|
||||
const maximumRecoveredMessageBlocks = 500
|
||||
|
||||
function appendRecoveredMessageBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
@@ -1190,7 +1187,7 @@ function appendRecoveredMessageBlock(
|
||||
maximumRecoveredMessageLength
|
||||
)
|
||||
}
|
||||
} else if (current.length < maximumRecoveredMessageBlocks) {
|
||||
} else {
|
||||
current.push({
|
||||
id: randomUUID(),
|
||||
type,
|
||||
@@ -1221,12 +1218,10 @@ function upsertRecoveredToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
return blocks.length >= maximumRecoveredMessageBlocks
|
||||
? blocks
|
||||
: [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'tool' as const, tool }
|
||||
]
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'tool' as const, tool }
|
||||
]
|
||||
}
|
||||
|
||||
function upsertRecoveredSubagentBlock(
|
||||
@@ -1262,12 +1257,10 @@ function upsertRecoveredSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
return blocks.length >= maximumRecoveredMessageBlocks
|
||||
? blocks
|
||||
: [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
|
||||
]
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
|
||||
]
|
||||
}
|
||||
|
||||
function terminalizeRecoveredToolBlocks(
|
||||
@@ -1377,7 +1370,7 @@ function reduceRecoveredAgentEvent(
|
||||
)
|
||||
if (index >= 0) {
|
||||
tools[index] = tool
|
||||
} else if (tools.length < maximumConversationToolActivities) {
|
||||
} else {
|
||||
tools.push(tool)
|
||||
}
|
||||
next = {
|
||||
@@ -1410,9 +1403,7 @@ function reduceRecoveredAgentEvent(
|
||||
)
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else if (
|
||||
subagents.length < maximumConversationSubagentActivities
|
||||
) {
|
||||
} else {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
next = {
|
||||
|
||||
@@ -15,7 +15,7 @@ const profile: ResolvedModelProfile = {
|
||||
}
|
||||
|
||||
describe('managed Agent prompt model setup', () => {
|
||||
it('validates and clones a bounded Agent prompt profile', () => {
|
||||
it('validates and clones an Agent prompt profile without prompt-wide quotas', () => {
|
||||
const setup = createManagedModelBridge({ profile })
|
||||
|
||||
expect(setup.profile).toEqual({
|
||||
@@ -32,8 +32,6 @@ describe('managed Agent prompt model setup', () => {
|
||||
capabilities: { imageInput: true },
|
||||
limits: {
|
||||
maximumOutputTokens: 8_192,
|
||||
maximumModelCalls: 100,
|
||||
maximumTotalOutputTokens: 819_200,
|
||||
requestTimeoutMilliseconds: 60_000
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createResolvedModelProfileDigest } from '../agent/remote-model-gateway'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
|
||||
const DEFAULT_MAXIMUM_OUTPUT_TOKENS = 32_000
|
||||
const MAXIMUM_MODEL_CALLS_PER_PROMPT = 100
|
||||
const MODEL_REQUEST_TIMEOUT_MILLISECONDS = 60_000
|
||||
|
||||
/**
|
||||
@@ -61,11 +60,6 @@ export function createManagedModelBridge(options: {
|
||||
},
|
||||
limits: {
|
||||
maximumOutputTokens,
|
||||
maximumModelCalls: MAXIMUM_MODEL_CALLS_PER_PROMPT,
|
||||
maximumTotalOutputTokens: Math.min(
|
||||
10_000_000,
|
||||
maximumOutputTokens * MAXIMUM_MODEL_CALLS_PER_PROMPT
|
||||
),
|
||||
requestTimeoutMilliseconds:
|
||||
MODEL_REQUEST_TIMEOUT_MILLISECONDS
|
||||
}
|
||||
|
||||
@@ -226,8 +226,6 @@ function harness(
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 100,
|
||||
maximumTotalOutputTokens: 409_600,
|
||||
requestTimeoutMilliseconds: 60_000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,8 +729,6 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 100,
|
||||
maximumTotalOutputTokens: 409_600,
|
||||
requestTimeoutMilliseconds: 60_000
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3694,6 +3694,37 @@ describe('App', () => {
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
})
|
||||
|
||||
it('keeps more than 100 tool activities in one response', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '执行长工具链' }
|
||||
})
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed',
|
||||
summary: `OpenCode 工具:read ${index + 1}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('region', { name: '工具执行,共 101 项' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -128,7 +128,6 @@ import {
|
||||
conversationMessageBlocksSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
interactiveWorkModes,
|
||||
maximumConversationSubagentActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -568,7 +567,6 @@ const minimumPrimaryWorkspaceWidth = 480
|
||||
const primarySidebarKeyboardResizeStep = 16
|
||||
|
||||
const maxMessageContentLength = 1_000_000
|
||||
const maxMessageBlocks = 500
|
||||
|
||||
function getPrimarySidebarWidthLimits(viewportWidth: number): {
|
||||
minimum: number
|
||||
@@ -638,9 +636,6 @@ function appendMessageContentBlock(
|
||||
}
|
||||
return current
|
||||
}
|
||||
if (current.length >= maxMessageBlocks) {
|
||||
return current
|
||||
}
|
||||
current.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
@@ -670,9 +665,6 @@ function upsertMessageToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maxMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -718,9 +710,6 @@ function upsertMessageSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maxMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -1327,8 +1316,6 @@ function isConversation(value: unknown): value is Conversation {
|
||||
))) &&
|
||||
(entry.subagents === undefined ||
|
||||
(Array.isArray(entry.subagents) &&
|
||||
entry.subagents.length <=
|
||||
maximumConversationSubagentActivities &&
|
||||
entry.subagents.every(
|
||||
(subagent) =>
|
||||
conversationSubagentActivitySchema.safeParse(subagent)
|
||||
@@ -4564,10 +4551,7 @@ function App(): React.JSX.Element {
|
||||
? current.map((task) =>
|
||||
task.id === event.childTaskId ? childTask : task
|
||||
)
|
||||
: [...current, childTask].slice(
|
||||
0,
|
||||
maximumConversationSubagentActivities
|
||||
)
|
||||
: [...current, childTask]
|
||||
})
|
||||
}
|
||||
if (event.runtimeCallId) {
|
||||
@@ -4638,10 +4622,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else if (
|
||||
subagents.length <
|
||||
maximumConversationSubagentActivities
|
||||
) {
|
||||
} else {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
const runtimeCallId = event.runtimeCallId
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
conversationBranchInputSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
@@ -172,6 +173,52 @@ describe('project execution space contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation activity contracts', () => {
|
||||
it('accepts more than 100 tool and subagent activities', () => {
|
||||
const tools = Array.from({ length: 101 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
summary: `Tool ${index + 1}`
|
||||
}))
|
||||
const subagents = Array.from({ length: 101 }, (_, index) => ({
|
||||
childTaskId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
||||
routingMode: 'native' as const,
|
||||
actor: {
|
||||
kind: 'direct-model' as const,
|
||||
label: '编程 Subagent' as const
|
||||
},
|
||||
state: 'completed' as const
|
||||
}))
|
||||
|
||||
expect(
|
||||
conversationMessageSchema.parse({
|
||||
id: '00000000-0000-4000-8000-000000000999',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
blocks: tools.map((tool, index) => ({
|
||||
id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
||||
type: 'tool',
|
||||
tool
|
||||
})),
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
tools,
|
||||
subagents
|
||||
})
|
||||
).toMatchObject({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({ callId: 'call-101' })
|
||||
]),
|
||||
subagents: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
childTaskId: '00000000-0000-4000-8000-000000000101'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation branch contracts', () => {
|
||||
it('accepts bounded branch provenance and rejects renderer extras', () => {
|
||||
const sourceConversationId =
|
||||
|
||||
@@ -182,9 +182,9 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const conversationMessageBlocksSchema = z
|
||||
.array(conversationMessageBlockSchema)
|
||||
.max(500)
|
||||
export const conversationMessageBlocksSchema = z.array(
|
||||
conversationMessageBlockSchema
|
||||
)
|
||||
|
||||
export type ConversationToolActivity = z.infer<
|
||||
typeof conversationToolActivitySchema
|
||||
@@ -193,9 +193,6 @@ export type ConversationMessageBlock = z.infer<
|
||||
typeof conversationMessageBlockSchema
|
||||
>
|
||||
|
||||
export const maximumConversationToolActivities = 100
|
||||
export const maximumConversationSubagentActivities = 100
|
||||
|
||||
export const subagentActorSchema = z
|
||||
.object({
|
||||
kind: z.literal('direct-model'),
|
||||
@@ -277,14 +274,8 @@ export const conversationMessageSchema = z
|
||||
.array(conversationContextCompressionMarkerSchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
tools: z
|
||||
.array(conversationToolActivitySchema)
|
||||
.max(maximumConversationToolActivities)
|
||||
.optional(),
|
||||
subagents: z
|
||||
.array(conversationSubagentActivitySchema)
|
||||
.max(maximumConversationSubagentActivities)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).optional(),
|
||||
subagents: z.array(conversationSubagentActivitySchema).optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
|
||||
@@ -191,14 +191,14 @@ describe('model bridge contracts and codec', () => {
|
||||
apiKey: 'must-not-cross'
|
||||
})
|
||||
).toThrow()
|
||||
expect(
|
||||
expect(() =>
|
||||
modelBridgePolicySchema.parse({
|
||||
...policy,
|
||||
maximumOutputTokens: 65_537,
|
||||
maximumModelCalls: 8,
|
||||
maximumTotalOutputTokens: 32_768
|
||||
})
|
||||
).toMatchObject(policy)
|
||||
).toThrow()
|
||||
await expect(
|
||||
createModelBridgeRequestMessage({
|
||||
identity,
|
||||
|
||||
@@ -126,8 +126,6 @@ export const agentPromptModelProfileSchema = z
|
||||
limits: z
|
||||
.object({
|
||||
maximumOutputTokens: z.number().int().min(1).max(1_000_000),
|
||||
maximumModelCalls: z.number().int().min(1).max(10_000),
|
||||
maximumTotalOutputTokens: z.number().int().min(1).max(10_000_000),
|
||||
requestTimeoutMilliseconds: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -158,16 +156,6 @@ export const agentPromptModelProfileSchema = z
|
||||
message: 'Model provider does not match its wire protocol'
|
||||
})
|
||||
}
|
||||
if (
|
||||
profile.limits.maximumOutputTokens >
|
||||
profile.limits.maximumTotalOutputTokens
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['limits', 'maximumOutputTokens'],
|
||||
message: 'Per-call output limit exceeds the prompt total'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type AgentPromptModelProfile = z.infer<
|
||||
typeof agentPromptModelProfileSchema
|
||||
@@ -178,11 +166,6 @@ export const modelBridgePolicySchema = z
|
||||
protocol: modelBridgeModelProtocolSchema,
|
||||
model: modelNameSchema,
|
||||
modelProfileDigest: sha256DigestSchema,
|
||||
// Accepted only so persisted pre-removal bindings can be read and
|
||||
// retired normally. Current code neither emits nor enforces these fields.
|
||||
maximumOutputTokens: z.number().int().min(1).optional(),
|
||||
maximumModelCalls: z.number().int().min(1).optional(),
|
||||
maximumTotalOutputTokens: z.number().int().min(1).optional(),
|
||||
supportsImageInput: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -116,20 +116,20 @@ describe('model call operation contracts', () => {
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts legacy quota fields without requiring them', () => {
|
||||
it('rejects prompt-wide quota fields', () => {
|
||||
const request = {
|
||||
identity,
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
...policy
|
||||
}
|
||||
expect(prepareModelCallSchema.parse(request)).toEqual(request)
|
||||
expect(
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
...request,
|
||||
maximumOutputTokens: 65_537,
|
||||
maximumModelCalls: 100_000,
|
||||
maximumTotalOutputTokens: 1_000_000_000
|
||||
})
|
||||
).toMatchObject(request)
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,12 +60,7 @@ export const prepareModelCallSchema = z
|
||||
.object({
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema,
|
||||
// Legacy ledger fields are accepted during migration but no longer
|
||||
// constrain dispatch.
|
||||
maximumOutputTokens: positiveCountSchema.optional(),
|
||||
maximumModelCalls: positiveCountSchema.optional(),
|
||||
maximumTotalOutputTokens: positiveCountSchema.optional()
|
||||
modelProfileDigest: modelCallDigestSchema
|
||||
})
|
||||
.strict()
|
||||
export type PrepareModelCall = z.infer<typeof prepareModelCallSchema>
|
||||
@@ -164,9 +159,6 @@ export const modelCallRecordSchema = z
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema,
|
||||
maximumOutputTokens: positiveCountSchema.optional(),
|
||||
maximumModelCalls: positiveCountSchema.optional(),
|
||||
maximumTotalOutputTokens: positiveCountSchema.optional(),
|
||||
status: modelCallStatusSchema,
|
||||
dispatchMetadata: modelCallDispatchMetadataSchema.optional(),
|
||||
terminalEvidence: modelCallTerminalEvidenceSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user