fix(ptc): report the per-minute call budget with the limit

The model-visible limit diagnostic only stated the timeout-derived total,
so the number could not be traced back to maxSubCallsPerMinute.
This commit is contained in:
_Kerman
2026-11-03 20:17:01 +08:00
parent 03e654e8a5
commit cea324f719
6 changed files with 28 additions and 16 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ Program-only SDK bindings:
##### Tool-call allowance exhausted
```markdown
Error: code run failed (abort): run_code exceeded its limit of N tool calls; call M was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.
Error: code run failed (abort): run_code exceeded its limit of 2048 tool calls (maxSubCallsPerMinute 1024); call 2049 was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.
```
#### Token effect
+1 -1
View File
@@ -112,7 +112,7 @@ Program-only SDK bindings:
##### 工具调用额度耗尽
```markdown
Error: code run failed (abort): run_code exceeded its limit of N tool calls; call M was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.
Error: code run failed (abort): run_code exceeded its limit of 2048 tool calls (maxSubCallsPerMinute 1024); call 2049 was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.
```
#### Token 影响
+12 -3
View File
@@ -1,6 +1,14 @@
/** Program execution failures reported by PTC. @module */
import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Tool-call allowance for one `run_code` program, with the per-minute budget its limit derives from. */
export interface ToolCallAllowance {
/** Maximum dispatched tool-call attempts for the program; `Number.POSITIVE_INFINITY` without a resolved deadline. */
limit: number
/** Configured attempts granted per minute of resolved runtime timeout. */
maxSubCallsPerMinute: number
}
/**
* Host-only abort reason for an exhausted tool-call allowance; the excess call is never dispatched.
* The executor recognizes this class at settlement and reports CODE_RUN_FAILED to the model.
@@ -8,11 +16,12 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
export class ToolCallLimitExceededError extends Error {
/**
* Describe the rejected call and how to correct the program.
* @param limit - Timeout-derived maximum tool-call attempts for one run_code invocation.
* @param allowance - Exhausted allowance, including the per-minute budget behind its limit.
* @param callNumber - One-based attempt rejected without invoking the tool.
*/
constructor(limit: number, callNumber: number) {
super(`run_code exceeded its limit of ${limit} tool calls; call ${callNumber} was not dispatched. `
constructor(allowance: ToolCallAllowance, callNumber: number) {
super(`run_code exceeded its limit of ${allowance.limit} tool calls `
+ `(maxSubCallsPerMinute ${allowance.maxSubCallsPerMinute}); call ${callNumber} was not dispatched. `
+ 'The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.')
this.name = 'ToolCallLimitExceededError'
}
+9 -6
View File
@@ -24,7 +24,7 @@ import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline'
import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { PtcCalls } from './ptc-calls.ts'
import { CodeRunFailedError, ToolCallLimitExceededError } from './errors.ts'
import { CodeRunFailedError, ToolCallLimitExceededError, type ToolCallAllowance } from './errors.ts'
import type { Config } from './config.ts'
import type { ToolDefinition, ToolExecutionResult, ToolRunContext } from '@deepseek-ai/dsh-tools'
import type {} from './types.ts'
@@ -380,7 +380,7 @@ export function createRunCodeTool(
callSignal.addEventListener('abort', onOuterAbort, { once: true })
let toolCallAttempts = 0
let maxSubCalls: number
let allowance: ToolCallAllowance
// The per-run scheduler uses the registry's staged interface and follows
// the same concurrency rules as the native loop. It also follows the
// native loop's SEQUENCING: every ordered stage (the dispatch-start
@@ -509,8 +509,8 @@ export function createRunCodeTool(
}
// Count before validation and queuing so repeated failed calls also exhaust the allowance.
const n = ++toolCallAttempts
if (n > maxSubCalls) {
const error = new ToolCallLimitExceededError(maxSubCalls, n)
if (n > allowance.limit) {
const error = new ToolCallLimitExceededError(allowance, n)
runController.abort(error)
throw error
}
@@ -694,8 +694,11 @@ export function createRunCodeTool(
...policy !== undefined ? { sandboxPolicy: policy } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
})
maxSubCalls = spec.timeoutMs === null ? Infinity
: Math.floor(spec.timeoutMs / 60_000 * maxSubCallsPerMinute)
allowance = {
limit: spec.timeoutMs === null ? Number.POSITIVE_INFINITY
: Math.floor(spec.timeoutMs / 60_000 * maxSubCallsPerMinute),
maxSubCallsPerMinute,
}
result = await runtime.run(spec)
} finally {
// Abort sub-dispatches and drain every in-flight dispatch before
@@ -683,7 +683,7 @@ describe('the per-program tool-call limit', () => {
runtime.behavior = async (request) => {
const echo = request.bindings[0]!.functions.echo!
for (let n = 1; n <= 2048; n++) await echo({ value: String(n) })
const message = 'run_code exceeded its limit of 2048 tool calls; call 2049 was not dispatched. '
const message = 'run_code exceeded its limit of 2048 tool calls (maxSubCallsPerMinute 1024); call 2049 was not dispatched. '
+ 'The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.'
await expect(echo({ value: 'rejected' })).rejects.toThrow(message)
expect(request.signal?.aborted).toBe(true)
@@ -719,7 +719,7 @@ describe('the per-program tool-call limit', () => {
expect(request.timeoutMs).toBe(resolvedMs)
const echo = request.bindings[0]!.functions.echo!
for (let n = 0; n < limit; n++) await echo({ value: String(n) })
await expect(echo({ value: 'rejected' })).rejects.toThrow(`limit of ${limit} tool calls; call ${limit + 1} was not dispatched`)
await expect(echo({ value: 'rejected' })).rejects.toThrow(`limit of ${limit} tool calls (maxSubCallsPerMinute 4); call ${limit + 1} was not dispatched`)
expect(request.signal?.aborted).toBe(true)
return { logs: [], error: { kind: 'abort', message: String(request.signal?.reason) } }
}
@@ -767,7 +767,7 @@ describe('the per-program tool-call limit', () => {
void functions.active!({}).catch(() => { outcomes.push('active canceled') })
await entered.promise
void functions.queued!({ value: 'queued' }).catch(() => { outcomes.push('queued abandoned') })
await expect(functions.queued!({ value: 'excess' })).rejects.toThrow('limit of 2 tool calls; call 3 was not dispatched')
await expect(functions.queued!({ value: 'excess' })).rejects.toThrow('limit of 2 tool calls (maxSubCallsPerMinute 1); call 3 was not dispatched')
expect(request.signal?.aborted).toBe(true)
return { logs: [], error: { kind: 'abort', message: 'execution canceled' } }
}
@@ -825,7 +825,7 @@ describe('the per-program tool-call limit', () => {
await expect(functions.echo!(undefined)).rejects.toThrow('lossless JSON')
await expect(functions.echo!({})).rejects.toThrow()
await expect(functions.fail!({})).rejects.toThrow('tool failed')
await expect(functions.echo!({ value: 'rejected' })).rejects.toThrow('limit of 3 tool calls; call 4 was not dispatched')
await expect(functions.echo!({ value: 'rejected' })).rejects.toThrow('limit of 3 tool calls (maxSubCallsPerMinute 3); call 4 was not dispatched')
expect(request.signal?.aborted).toBe(true)
return { logs: [], error: { kind: 'abort', message: String(request.signal?.reason) } }
}
@@ -22,7 +22,7 @@
{"type":"job/registered","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"printf 'call\n' >> calls.txt","startedAt":1793676779672,"toolCall":{"callId":"call_limit:ptc:2","rootCallId":"call_limit"}}}
{"type":"job/settled","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"printf 'call\n' >> calls.txt","startedAt":1793676779672,"toolCall":{"callId":"call_limit:ptc:2","rootCallId":"call_limit"},"finishedAt":1793676779794,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_limit","parentCallId":"call_limit","subCallId":"call_limit:ptc:2","name":"bash","arguments":{"command":"printf 'call\n' >> calls.txt","description":"Append one completed call"},"isError":false,"content":[{"type":"text","text":"(no output)"}],"meta":{"cwd":"{{cwd}}"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_limit"},"toolCallId":"call_limit","content":[{"type":"text","text":"Error: code run failed (abort): run_code exceeded its limit of 2 tool calls; call 3 was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_limit"},"toolCallId":"call_limit","content":[{"type":"text","text":"Error: code run failed (abort): run_code exceeded its limit of 2 tool calls (maxSubCallsPerMinute 1); call 3 was not dispatched. The run_code program may contain an infinite loop. Check loop termination or split the work into smaller run_code calls.\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_next","name":"run_code","arguments":"{\"code\":\"await tools.bash({ command: \\\"printf 'next\\\\n' >> calls.txt\\\", description: 'Append from a fresh program' }); return 'next run succeeded';\",\"description\":\"Verify the next program has fresh allowance\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_next","parentCallId":"call_next","subCallId":"call_next:ptc:1","name":"bash","arguments":{"command":"printf 'next\n' >> calls.txt","description":"Append from a fresh program"}}}
{"type":"job/registered","data":{"jobId":"{{id:3}}","owner":"{{session:1}}","kind":"bash","label":"printf 'next\n' >> calls.txt","startedAt":1793676780083,"toolCall":{"callId":"call_next:ptc:1","rootCallId":"call_next"}}}