mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 08:11:07 -10:00
fix(praisonai-ts): preserve restored tool name and invalidate cache on setHistory
Two fixes to the restorable-conversation feature: - Preserve the tool `name` through getHistory/setHistory. toAISDKPrompt sets the tool-result part's toolName from msg.name; without it a restored tool history sent to a non-OpenAI (AI SDK) backend emits an empty toolName and is rejected. Added optional name to AgentMessage, copied it in copyMessage, and carried it in the start() replay. - Clear responseCache in setHistory so a repeat prompt is re-evaluated against the restored history instead of returning the pre-restore prompt-keyed answer. Added regression tests for both; full suite: 1141 passed, 0 failed. Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
77f88b8f23
commit
71197fa808
2 files changed
+81
-4
No files matched your search
@@ -86,6 +86,14 @@ export interface AgentMessage {
|
||||
}>;
|
||||
/** Present on a tool turn; pairs it to the `tool_calls` entry above. */
|
||||
readonly tool_call_id?: string;
|
||||
/**
|
||||
* The tool's name, present on a `tool` turn. Required so the AI SDK adapter
|
||||
* (`toAISDKPrompt`) can set a non-empty `toolName` on the tool-result part —
|
||||
* without it, a restored tool history sent to a non-OpenAI provider is
|
||||
* rejected. The live tool loop already records this (`processToolCalls`); it
|
||||
* must survive the getHistory/setHistory round-trip too.
|
||||
*/
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export interface SimpleAgentConfig {
|
||||
@@ -261,7 +269,7 @@ export class Agent {
|
||||
private dbAdapter?: DbAdapter;
|
||||
private sessionId: string;
|
||||
private runId: string;
|
||||
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
|
||||
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }> = [];
|
||||
private dbInitialized: boolean = false;
|
||||
private historyLimit: number;
|
||||
private autoRestore: boolean;
|
||||
@@ -845,6 +853,9 @@ export class Agent {
|
||||
const replay: any = { role: msg.role, content: msg.content };
|
||||
if (msg.tool_calls) replay.tool_calls = msg.tool_calls;
|
||||
if (msg.tool_call_id) replay.tool_call_id = msg.tool_call_id;
|
||||
// Carry the tool name so a restored tool result keeps a non-empty
|
||||
// toolName on the AI SDK path (toAISDKPrompt reads msg.name).
|
||||
if (msg.name) replay.name = msg.name;
|
||||
messages.push(replay);
|
||||
}
|
||||
|
||||
@@ -1427,7 +1438,7 @@ export class Agent {
|
||||
|
||||
const validRoles = new Set(['system', 'user', 'assistant', 'tool']);
|
||||
const knownToolCallIds = new Set<string>();
|
||||
const restored: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
|
||||
const restored: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }> = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
@@ -1459,13 +1470,17 @@ export class Agent {
|
||||
}
|
||||
|
||||
this.messages = restored;
|
||||
// Replacing the conversation invalidates any cached answers keyed by prompt
|
||||
// alone: a repeat prompt must be re-evaluated against the restored history,
|
||||
// not served the previous conversation's response.
|
||||
this.responseCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-copy a single message so neither getHistory() nor setHistory() shares
|
||||
* the caller's mutable references (tool_calls is an array of objects).
|
||||
*/
|
||||
private copyMessage(m: AgentMessage | { role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }): any {
|
||||
private copyMessage(m: AgentMessage | { role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }): any {
|
||||
const copy: any = { role: m.role, content: m.content };
|
||||
if (m.tool_calls) {
|
||||
copy.tool_calls = m.tool_calls.map((c: any) => ({
|
||||
@@ -1477,6 +1492,11 @@ export class Agent {
|
||||
if (m.tool_call_id) {
|
||||
copy.tool_call_id = m.tool_call_id;
|
||||
}
|
||||
// Preserve the tool name so a restored tool result keeps a non-empty
|
||||
// toolName when converted for the AI SDK backend (adapter.ts toAISDKPrompt).
|
||||
if (m.name) {
|
||||
copy.name = m.name;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function toolConversation(): AgentMessage[] {
|
||||
{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"Paris"}' } },
|
||||
],
|
||||
},
|
||||
{ role: 'tool', content: '20C and sunny', tool_call_id: 'call_1' },
|
||||
{ role: 'tool', content: '20C and sunny', tool_call_id: 'call_1', name: 'getWeather' },
|
||||
{ role: 'assistant', content: 'It is 20C and sunny in Paris.' },
|
||||
];
|
||||
}
|
||||
@@ -114,6 +114,63 @@ describe('getHistory()/setHistory() conversation restore', () => {
|
||||
expect(again.length).toBe(4);
|
||||
});
|
||||
|
||||
it('preserves the tool name so a restored tool result reaches the provider with toolName intact', async () => {
|
||||
let captured: any[] | null = null;
|
||||
jest
|
||||
.spyOn(OpenAIService.prototype as any, 'generateChat')
|
||||
.mockImplementation(async (...args: any[]) => {
|
||||
captured = args[0];
|
||||
return { content: 'ok' };
|
||||
});
|
||||
|
||||
const agent = new Agent({ instructions: 'x', llm: 'openai/gpt-4o-mini', stream: false, verbose: false });
|
||||
agent.setHistory(toolConversation());
|
||||
|
||||
// Round-trip keeps the tool name (adapter.ts toAISDKPrompt reads msg.name).
|
||||
const toolMsg = agent.getHistory().find((m) => m.role === 'tool');
|
||||
expect(toolMsg!.name).toBe('getWeather');
|
||||
|
||||
await agent.start('and tomorrow?');
|
||||
const replayedTool = captured!.find((m) => m.role === 'tool');
|
||||
expect(replayedTool.name).toBe('getWeather');
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('clears the response cache so a repeat prompt is re-evaluated against restored history', async () => {
|
||||
// The no-history path uses generateText; the post-restore path uses
|
||||
// generateChat. Mock both and count total provider calls to prove the
|
||||
// second prompt was NOT served from the prompt-keyed cache.
|
||||
const genText = jest
|
||||
.spyOn(OpenAIService.prototype as any, 'generateText')
|
||||
.mockResolvedValue('fresh');
|
||||
const genChat = jest
|
||||
.spyOn(OpenAIService.prototype as any, 'generateChat')
|
||||
.mockResolvedValue({ content: 'fresh' });
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'x',
|
||||
llm: 'openai/gpt-4o-mini',
|
||||
stream: false,
|
||||
verbose: false,
|
||||
cache: true,
|
||||
});
|
||||
|
||||
const first = await agent.chat('same prompt');
|
||||
expect(first).toBe('fresh');
|
||||
expect(genText.mock.calls.length + genChat.mock.calls.length).toBe(1);
|
||||
|
||||
// Restoring a different conversation must invalidate the prompt-keyed cache;
|
||||
// otherwise the repeat prompt returns the pre-restore answer without hitting
|
||||
// the model against the newly restored history.
|
||||
agent.setHistory([{ role: 'user', content: 'earlier' }, { role: 'assistant', content: 'earlier reply' }]);
|
||||
|
||||
await agent.chat('same prompt');
|
||||
expect(genText.mock.calls.length + genChat.mock.calls.length).toBe(2);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('replays the restored messages to the provider on the next start()', async () => {
|
||||
let captured: any[] | null = null;
|
||||
jest
|
||||
|
||||
Reference in new issue
Block a user