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": { "dependencies": {
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@earendil-works/pi-agent-core": "0.83.0", "@earendil-works/pi-agent-core": "0.84.2",
"@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-ai": "0.84.2",
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"better-sqlite3": "^12.4.6", "better-sqlite3": "^12.4.6",
"commander": "^13.1.0", "commander": "^13.1.0",
+2 -2
View File
@@ -15,8 +15,8 @@
"./src/*": "./src/*.ts" "./src/*": "./src/*.ts"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "0.83.0", "@earendil-works/pi-agent-core": "0.84.2",
"@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-ai": "0.84.2",
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@openchatlab/config": "workspace:*", "@openchatlab/config": "workspace:*",
"@openchatlab/core": "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 () => { it('forwards the stable AI conversation id as the provider session id', async () => {
let capturedOptions: SimpleStreamOptions | undefined 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') 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 // DeepSeek-format APIs require reasoning_content on assistant messages that
// precede tool results; build replay options so toPiHistoryMessages includes // precede tool results; build replay options so toPiHistoryMessages includes
@@ -129,6 +130,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
}, },
afterToolCall: async ({ result }) => afterToolCall: async ({ result }) =>
(result as { isError?: boolean }).isError === true ? { isError: true } : undefined, (result as { isError?: boolean }).isError === true ? { isError: true } : undefined,
shouldStopAfterTurn: async () => hasCompletedFinalAnswerTurn,
prepareNextTurnWithContext: ({ context }) => prepareNextTurnWithContext: ({ context }) =>
hasReachedToolRoundLimit hasReachedToolRoundLimit
? { ? {
@@ -219,9 +221,10 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
}) })
} else if (event.type === 'turn_end') { } else if (event.type === 'turn_end') {
const hadToolCalls = event.toolResults.length > 0 const hadToolCalls = event.toolResults.length > 0
if (hadToolCalls) { const isFinalAnswerTurn = hasReachedToolRoundLimit
if (hadToolCalls && !hasReachedToolRoundLimit) {
toolRounds += 1 toolRounds += 1
if (!hasReachedToolRoundLimit && maxToolRounds > 0 && toolRounds >= maxToolRounds) { if (maxToolRounds > 0 && toolRounds >= maxToolRounds) {
hasReachedToolRoundLimit = true hasReachedToolRoundLimit = true
coreAgent.state.tools = [] coreAgent.state.tools = []
coreAgent.steer({ coreAgent.steer({
@@ -231,6 +234,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
} as PiMessage) } as PiMessage)
} }
} }
if (isFinalAnswerTurn) hasCompletedFinalAnswerTurn = true
onEvent({ type: 'turn_end', round: toolRounds, hadToolCalls }) onEvent({ type: 'turn_end', round: toolRounds, hadToolCalls })
} else if (event.type === 'message_end') { } else if (event.type === 'message_end') {
if (event.message.role === 'assistant') { if (event.message.role === 'assistant') {
+27 -47
View File
@@ -136,11 +136,11 @@ importers:
apps/cli: apps/cli:
dependencies: dependencies:
'@earendil-works/pi-agent-core': '@earendil-works/pi-agent-core':
specifier: 0.83.0 specifier: 0.84.2
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) 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': '@earendil-works/pi-ai':
specifier: 0.83.0 specifier: 0.84.2
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) 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': '@fastify/multipart':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
@@ -390,11 +390,11 @@ importers:
packages/node-runtime: packages/node-runtime:
dependencies: dependencies:
'@earendil-works/pi-agent-core': '@earendil-works/pi-agent-core':
specifier: 0.83.0 specifier: 0.84.2
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) 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': '@earendil-works/pi-ai':
specifier: 0.83.0 specifier: 0.84.2
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) 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': '@huggingface/transformers':
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@@ -808,15 +808,19 @@ packages:
search-insights: search-insights:
optional: true optional: true
'@earendil-works/pi-agent-core@0.83.0': '@earendil-works/pi-agent-core@0.84.2':
resolution: {integrity: sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g==} resolution: {integrity: sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
'@earendil-works/pi-ai@0.83.0': '@earendil-works/pi-ai@0.84.2':
resolution: {integrity: sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ==} resolution: {integrity: sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
hasBin: true 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': '@electron-toolkit/preload@3.0.2':
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==} resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
peerDependencies: peerDependencies:
@@ -1825,14 +1829,6 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'} 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': '@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2447,10 +2443,6 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
'@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
'@peculiar/asn1-schema@2.9.0': '@peculiar/asn1-schema@2.9.0':
resolution: {integrity: sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==} resolution: {integrity: sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==}
@@ -5692,9 +5684,8 @@ packages:
onnxruntime-web@1.26.0-dev.20260416-b7804b056c: onnxruntime-web@1.26.0-dev.20260416-b7804b056c:
resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==}
openai@6.26.0: openai@6.40.0:
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==}
hasBin: true
peerDependencies: peerDependencies:
ws: ^8.18.0 ws: ^8.18.0
zod: ^3.25 || ^4.0 zod: ^3.25 || ^4.0
@@ -7677,9 +7668,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@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: 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 diff: 8.0.4
ignore: 7.0.5 ignore: 7.0.5
typebox: 1.3.7 typebox: 1.3.7
@@ -7692,17 +7684,17 @@ snapshots:
- ws - ws
- zod - 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: dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0 '@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) '@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 '@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3 '@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2(supports-color@7.2.0) http-proxy-agent: 7.0.2(supports-color@7.2.0)
https-proxy-agent: 7.0.6(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 partial-json: 0.1.7
typebox: 1.3.7 typebox: 1.3.7
transitivePeerDependencies: transitivePeerDependencies:
@@ -7713,6 +7705,8 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-telemetry@0.84.3': {}
'@electron-toolkit/preload@3.0.2(electron@35.7.5(supports-color@7.2.0))': '@electron-toolkit/preload@3.0.2(electron@35.7.5(supports-color@7.2.0))':
dependencies: dependencies:
electron: 35.7.5(supports-color@7.2.0) electron: 35.7.5(supports-color@7.2.0)
@@ -8532,18 +8526,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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)': '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76)':
dependencies: dependencies:
'@hono/node-server': 1.19.14(hono@4.12.18) '@hono/node-server': 1.19.14(hono@4.12.18)
@@ -9221,8 +9203,6 @@ snapshots:
'@opentelemetry/api@1.9.0': {} '@opentelemetry/api@1.9.0': {}
'@opentelemetry/semantic-conventions@1.43.0': {}
'@peculiar/asn1-schema@2.9.0': '@peculiar/asn1-schema@2.9.0':
dependencies: dependencies:
'@peculiar/utils': 2.0.3 '@peculiar/utils': 2.0.3
@@ -12563,7 +12543,7 @@ snapshots:
platform: 1.3.6 platform: 1.3.6
protobufjs: 7.5.4 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: optionalDependencies:
ws: 8.19.0 ws: 8.19.0
zod: 3.25.76 zod: 3.25.76