Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/await-generate-title-serverless-20682.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@mastra/core': patch
---

Fixed thread title generation being dropped on serverless runtimes by accepting an optional `serverless.waitUntil` on `generate()`/`stream()` so title persistence stays alive after the response without blocking the run. (#20682)

```ts
import { waitUntil } from '@vercel/functions';

await agent.generate('Name this conversation', {
serverless: { waitUntil },
memory: {
thread: 'thread-1',
resource: 'user-1',
options: { generateTitle: true },
},
});
```
156 changes: 156 additions & 0 deletions packages/core/src/agent/__tests__/title-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,162 @@ function titleGenerationTests(version: 'v1' | 'v2') {
expect(agentCallCount).toBe(1); // But main agent should still be called
});

it('keeps title persistence alive via waitUntil without blocking generate (#20682)', async () => {
// Title gen stays fire-and-forget so generate() is not slowed for serverless.
// Callers pass platform waitUntil so the isolate stays alive until persistence finishes.
let releaseTitleWrite: (() => void) | undefined;
const titleWriteGate = new Promise<void>(resolve => {
releaseTitleWrite = resolve;
});
let titlePersisted = false;

const mockMemory = new MockMemory();
mockMemory.getMergedThreadConfig = () => ({
generateTitle: true,
});

const originalCreateThread = mockMemory.createThread.bind(mockMemory);
mockMemory.createThread = async args => {
if (args.title) {
await titleWriteGate;
titlePersisted = true;
}
return originalCreateThread(args);
};

let testModel: MockLanguageModelV1 | MockLanguageModelV2;

if (version === 'v1') {
testModel = new MockLanguageModelV1({
doGenerate: async options => {
const messages = options.prompt;
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));

if (isForTitle) {
return {
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 5, completionTokens: 10 },
text: 'Serverless Safe Title',
};
}

return {
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
text: 'Agent Response',
};
},
});
} else {
testModel = new MockLanguageModelV2({
doGenerate: async options => {
const messages = options.prompt;
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));

if (isForTitle) {
return {
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
text: 'Serverless Safe Title',
content: [{ type: 'text', text: 'Serverless Safe Title' }],
warnings: [],
};
}

return {
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
text: 'Agent Response',
content: [{ type: 'text', text: 'Agent Response' }],
warnings: [],
};
},
doStream: async options => {
const messages = options.prompt;
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));
const text = isForTitle ? 'Serverless Safe Title' : 'Agent Response';

return {
rawCall: { rawPrompt: null, rawSettings: {} },
warnings: [],
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: text },
{ type: 'text-end', id: 'text-1' },
{
type: 'finish',
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
},
]),
};
},
});
}

const agent = new Agent({
id: 'waituntil-title-agent',
name: 'WaitUntil Title Agent',
instructions: 'test agent',
model: testModel,
memory: mockMemory,
});

const threadId = `thread-waituntil-title-${version}`;
const pending: Promise<unknown>[] = [];
const waitUntil = (promise: Promise<unknown>) => {
pending.push(promise);
};

if (version === 'v1') {
// Legacy already awaits title generation inline — no waitUntil hook needed.
releaseTitleWrite?.();
await agent.generateLegacy('Name this conversation', {
memory: {
resource: 'user-await',
thread: {
id: threadId,
title: '',
},
},
});
expect(titlePersisted).toBe(true);
} else {
await agent.generate('Name this conversation', {
serverless: { waitUntil },
memory: {
resource: 'user-await',
thread: {
id: threadId,
title: '',
},
},
});

// generate() must resolve without waiting on title persistence.
expect(titlePersisted).toBe(false);
expect(pending.length).toBe(1);

releaseTitleWrite?.();
await Promise.all(pending);
expect(titlePersisted).toBe(true);
}

const thread = await mockMemory.getThreadById({ threadId });
expect(thread?.title).toBe('Serverless Safe Title');
});

it('should not generate title for pre-created threads (thread already exists)', async () => {
// Pre-created threads already exist in the DB, so threadExists is true.
// Title generation only fires when the thread is newly created by the agent.
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7140,6 +7140,7 @@ export class Agent<
structuredOutput = false,
overrideScorers,
onTitleGenerated,
waitUntil,
}: AgentExecuteOnFinishOptions) {
const observabilityContext = createObservabilityContext({ currentSpan: agentSpan });

Expand Down Expand Up @@ -7239,7 +7240,10 @@ export class Agent<
const userMessage = this.getMostRecentUserMessage(threadUiMessages);

if (userMessage) {
void this.genTitle(
// Fire-and-forget so generate()/stream() stay fast. On serverless
// runtimes that freeze after the response, pass
// `serverless.waitUntil` so the platform keeps this promise alive (#20682).
const titlePromise = this.genTitle(
userMessage,
requestContext,
observabilityContext,
Expand All @@ -7264,6 +7268,12 @@ export class Agent<
.catch(error => {
this.logger.error('Error persisting generated title:', error);
});

if (typeof waitUntil === 'function') {
waitUntil(titlePromise);
} else {
void titlePromise;
}
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/agent/agent.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ModelMessage, ToolChoice } from '@internal/ai-sdk-v5';
import type { ActorSignal } from '../auth/ee';
import type { WaitUntilFn } from '../channels/wait-until';
import type { MastraScorer, MastraScorers, ScoringSamplingConfig } from '../evals';
import type { SystemMessage } from '../llm';
import type { ProviderOptions } from '../llm/model/provider-options';
Expand Down Expand Up @@ -478,6 +479,20 @@ export type AgentExecutionOptionsBase<OUTPUT> = {
/** Memory configuration for conversation persistence and retrieval */
memory?: AgentMemoryOption;

/**
* Serverless runtime helpers. Use these when the platform freezes the
* isolate after the HTTP response so detached finish-time work (e.g. thread
* title generation) would otherwise be dropped.
*/
serverless?: {
/**
* Platform `waitUntil` (Vercel `@vercel/functions`, Cloudflare
* `ExecutionContext.waitUntil`, etc.). Registers fire-and-forget finish
* work so the isolate stays alive until it settles.
*/
waitUntil?: WaitUntilFn;
};

/** Unique identifier for this execution run */
runId?: string;

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent/durable/durable-parity.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ type ConsumedDuringPreparation =
| '_skipBgTaskWait'
// untilIdle is handled by DurableAgent.streamUntilIdle() before preparation
| 'untilIdle'
// Serverless waitUntil is call-site only for non-durable generate/stream.
// Durable finish already awaits title generation, so this is intentionally unused.
| 'serverless'
// Observability context keys from Partial<ObservabilityContext>
| 'tracing'
| 'loggerVNext'
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { MastraBrowser } from '../browser';
import type { MastraServerCache } from '../cache/base';
import type { AgentChannels } from '../channels/agent-channels';
import type { ChannelConfig } from '../channels/types';
import type { WaitUntilFn } from '../channels/wait-until';
import type { MastraScorer, MastraScorers, ScoringSamplingConfig } from '../evals';
import type { PubSub } from '../events/pubsub';
import type {
Expand Down Expand Up @@ -1168,6 +1169,11 @@ export type AgentExecuteOnFinishOptions = {
structuredOutput?: boolean;
overrideScorers?: MastraScorers | Record<string, { scorer: MastraScorer['name']; sampling?: ScoringSamplingConfig }>;
onTitleGenerated?: (title: string) => void | Promise<void>;
/**
* Optional platform `waitUntil` so detached title generation survives
* serverless freeze-after-response without blocking `generate()`/`stream()`.
*/
waitUntil?: WaitUntilFn;
};

export type AgentMethodType = 'generate' | 'stream' | 'generateLegacy' | 'streamLegacy';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export function createMapResultsStep<OUTPUT = undefined>({
structuredOutput: !!options.structuredOutput?.schema,
overrideScorers: options.scorers,
onTitleGenerated: options.memory?.onTitleGenerated,
waitUntil: options.serverless?.waitUntil,
});
} catch (e) {
capabilities.logger.error('Error saving memory on finish', {
Expand Down
Loading