fix(ai): enforce the tool round limit

Stop after one final-answer turn and pin Pi 0.84.2 for the public stop hook.
到达工具轮次上限后只允许一轮终答,并锁定提供公开停止钩子的 Pi 0.84.2。
This commit is contained in:
Sanssssssssssssssss authored and digua committed 2026-08-28 22:33:10 +08:00
1 parent e544392a83
commit 02b9ccb59f
5 files changed
+125 -54

No files matched your search

+2 -2
View File
@@ -48,8 +48,8 @@
"dependencies": {
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@earendil-works/pi-agent-core": "0.83.0",
"@earendil-works/pi-ai": "0.83.0",
"@earendil-works/pi-agent-core": "0.84.2",
"@earendil-works/pi-ai": "0.84.2",
"@modelcontextprotocol/sdk": "^1.12.1",
"better-sqlite3": "^12.4.6",
"commander": "^13.1.0",
+2 -2
View File
@@ -15,8 +15,8 @@
"./src/*": "./src/*.ts"
},
"dependencies": {
"@earendil-works/pi-agent-core": "0.83.0",
"@earendil-works/pi-ai": "0.83.0",
"@earendil-works/pi-agent-core": "0.84.2",
"@earendil-works/pi-ai": "0.84.2",
"@huggingface/transformers": "^4.2.0",
"@openchatlab/config": "workspace:*",
"@openchatlab/core": "workspace:*",
@@ -359,6 +359,93 @@ describe('runAgentCore runtime contract', () => {
)
})
it('stops after one final-answer turn when the tool round limit is reached', async () => {
let providerCalls = 0
let handlerCalls = 0
const tool = createTool('lookup', async () => {
handlerCalls += 1
return { content: [{ type: 'text', text: 'tool result' }], details: null }
})
const toolCall = () =>
assistantMessage([{ type: 'toolCall', id: `tool-${providerCalls}`, name: 'lookup', arguments: {} }], {
stopReason: 'toolUse',
})
const result = await runAgentCore(
createOptions({
tools: [tool],
maxToolRounds: 1,
steerMessage: 'Finish now.',
streamFn: createScriptedStream([
() => {
providerCalls += 1
return toolCall()
},
(context) => {
providerCalls += 1
assert.deepEqual(context.tools, [])
return toolCall()
},
() => {
providerCalls += 1
return toolCall()
},
() => {
providerCalls += 1
return assistantMessage([{ type: 'text', text: 'Too late' }])
},
]),
})
)
assert.deepEqual(
{ providerCalls, handlerCalls, toolRounds: result.toolRounds },
{ providerCalls: 2, handlerCalls: 1, toolRounds: 1 }
)
})
it('stops after the first turn when the tool round limit is zero', async () => {
let providerCalls = 0
let handlerCalls = 0
const tool = createTool('lookup', async () => {
handlerCalls += 1
return { content: [{ type: 'text', text: 'tool result' }], details: null }
})
const toolCall = () =>
assistantMessage([{ type: 'toolCall', id: `tool-${providerCalls}`, name: 'lookup', arguments: {} }], {
stopReason: 'toolUse',
})
const result = await runAgentCore(
createOptions({
tools: [tool],
maxToolRounds: 0,
streamFn: createScriptedStream([
(context) => {
providerCalls += 1
assert.deepEqual(context.tools, [])
return toolCall()
},
() => {
providerCalls += 1
return toolCall()
},
]),
})
)
assert.deepEqual(
{ providerCalls, handlerCalls, toolRounds: result.toolRounds },
{ providerCalls: 1, handlerCalls: 0, toolRounds: 0 }
)
assert.deepEqual(
result.finalMessages.map((message) => message.role),
['user', 'assistant', 'toolResult']
)
const finalMessage = result.finalMessages.at(-1)
assert.equal(finalMessage?.role === 'toolResult' && finalMessage.isError, true)
})
it('forwards the stable AI conversation id as the provider session id', async () => {
let capturedOptions: SimpleStreamOptions | undefined
+7 -3
View File
@@ -96,7 +96,8 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
})()
const finalThinkingLevel = resolvedThinkingLevel ?? (piModel.reasoning ? undefined : 'off')
let hasReachedToolRoundLimit = false
let hasReachedToolRoundLimit = maxToolRounds <= 0
let hasCompletedFinalAnswerTurn = false
// DeepSeek-format APIs require reasoning_content on assistant messages that
// precede tool results; build replay options so toPiHistoryMessages includes
@@ -129,6 +130,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
},
afterToolCall: async ({ result }) =>
(result as { isError?: boolean }).isError === true ? { isError: true } : undefined,
shouldStopAfterTurn: async () => hasCompletedFinalAnswerTurn,
prepareNextTurnWithContext: ({ context }) =>
hasReachedToolRoundLimit
? {
@@ -219,9 +221,10 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
})
} else if (event.type === 'turn_end') {
const hadToolCalls = event.toolResults.length > 0
if (hadToolCalls) {
const isFinalAnswerTurn = hasReachedToolRoundLimit
if (hadToolCalls && !hasReachedToolRoundLimit) {
toolRounds += 1
if (!hasReachedToolRoundLimit && maxToolRounds > 0 && toolRounds >= maxToolRounds) {
if (maxToolRounds > 0 && toolRounds >= maxToolRounds) {
hasReachedToolRoundLimit = true
coreAgent.state.tools = []
coreAgent.steer({
@@ -231,6 +234,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
} as PiMessage)
}
}
if (isFinalAnswerTurn) hasCompletedFinalAnswerTurn = true
onEvent({ type: 'turn_end', round: toolRounds, hadToolCalls })
} else if (event.type === 'message_end') {
if (event.message.role === 'assistant') {
+27 -47
View File
@@ -136,11 +136,11 @@ importers:
apps/cli:
dependencies:
'@earendil-works/pi-agent-core':
specifier: 0.83.0
version: 0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
specifier: 0.84.2
version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@earendil-works/pi-ai':
specifier: 0.83.0
version: 0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
specifier: 0.84.2
version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@fastify/multipart':
specifier: ^10.0.0
version: 10.0.0
@@ -390,11 +390,11 @@ importers:
packages/node-runtime:
dependencies:
'@earendil-works/pi-agent-core':
specifier: 0.83.0
version: 0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
specifier: 0.84.2
version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@earendil-works/pi-ai':
specifier: 0.83.0
version: 0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
specifier: 0.84.2
version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@huggingface/transformers':
specifier: ^4.2.0
version: 4.2.0
@@ -808,15 +808,19 @@ packages:
search-insights:
optional: true
'@earendil-works/pi-agent-core@0.83.0':
resolution: {integrity: sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g==}
'@earendil-works/pi-agent-core@0.84.2':
resolution: {integrity: sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==}
engines: {node: '>=22.19.0'}
'@earendil-works/pi-ai@0.83.0':
resolution: {integrity: sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ==}
'@earendil-works/pi-ai@0.84.2':
resolution: {integrity: sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==}
engines: {node: '>=22.19.0'}
hasBin: true
'@earendil-works/pi-telemetry@0.84.3':
resolution: {integrity: sha512-sgEkWoKrvSGaKn+YfLLFZmn+/A7B/w62eLwTD57nI+C9to8ITlFFVbgC2OtwvPnT3NFGHdCd53qhBEMIlptD1g==}
engines: {node: '>=22.19.0'}
'@electron-toolkit/preload@3.0.2':
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
peerDependencies:
@@ -1825,14 +1829,6 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'}
'@mistralai/mistralai@2.2.6':
resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==}
peerDependencies:
'@opentelemetry/api': ^1.9.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -2447,10 +2443,6 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
'@peculiar/asn1-schema@2.9.0':
resolution: {integrity: sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==}
@@ -5692,9 +5684,8 @@ packages:
onnxruntime-web@1.26.0-dev.20260416-b7804b056c:
resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==}
openai@6.26.0:
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
hasBin: true
openai@6.40.0:
resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==}
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
@@ -7677,9 +7668,10 @@ snapshots:
transitivePeerDependencies:
- '@algolia/client-search'
'@earendil-works/pi-agent-core@0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)':
'@earendil-works/pi-agent-core@0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-ai': 0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@earendil-works/pi-ai': 0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)
'@earendil-works/pi-telemetry': 0.84.3
diff: 8.0.4
ignore: 7.0.5
typebox: 1.3.7
@@ -7692,17 +7684,17 @@ snapshots:
- ws
- zod
'@earendil-works/pi-ai@0.83.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)':
'@earendil-works/pi-ai@0.84.2(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)(ws@8.19.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@earendil-works/pi-telemetry': 0.84.3
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76))(supports-color@7.2.0)
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2(supports-color@7.2.0)
https-proxy-agent: 7.0.6(supports-color@7.2.0)
openai: 6.26.0(ws@8.19.0)(zod@3.25.76)
openai: 6.40.0(ws@8.19.0)(zod@3.25.76)
partial-json: 0.1.7
typebox: 1.3.7
transitivePeerDependencies:
@@ -7713,6 +7705,8 @@ snapshots:
- ws
- zod
'@earendil-works/pi-telemetry@0.84.3': {}
'@electron-toolkit/preload@3.0.2(electron@35.7.5(supports-color@7.2.0))':
dependencies:
electron: 35.7.5(supports-color@7.2.0)
@@ -8532,18 +8526,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/semantic-conventions': 1.43.0
ws: 8.19.0
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
optionalDependencies:
'@opentelemetry/api': 1.9.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.18)
@@ -9221,8 +9203,6 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/semantic-conventions@1.43.0': {}
'@peculiar/asn1-schema@2.9.0':
dependencies:
'@peculiar/utils': 2.0.3
@@ -12563,7 +12543,7 @@ snapshots:
platform: 1.3.6
protobufjs: 7.5.4
openai@6.26.0(ws@8.19.0)(zod@3.25.76):
openai@6.40.0(ws@8.19.0)(zod@3.25.76):
optionalDependencies:
ws: 8.19.0
zod: 3.25.76