mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(mastra): stop thread-scoped working memory aborting the first turn (closes OSS-1122)
A starter scaffolded with `--framework mastra` accepted a chat message and never answered. The run aborted server-side with `Thread <id> not found`. `@ag-ui/mastra` writes the UI's shared state into Mastra working memory before it streams a turn (`syncInputStateToWorkingMemory`). That write is unguarded and never creates the thread, because it assumes the resource-scoped store, which upserts. The starter was the one Mastra agent in this repo that set `scope: "thread"`, which routes the same write to thread metadata and requires the thread row to exist. On the first turn of a conversation it does not, so `@mastra/memory` throws and the run dies before the model is called. Managed Intelligence made that certain rather than likely: the Intelligence run handler swaps the client thread id for a platform-canonical one, which the Mastra store has never seen. That is also why two different thread ids appear in the same failure. Verified against @mastra/core 1.41.0, @mastra/memory 1.0.1-alpha.1 and @ag-ui/mastra 1.1.2: thread scope throws on a fresh thread, resource scope writes, reads back, reaches the agent's system message, and stays per conversation because the bridge derives the resource id from the thread id. Every other Mastra agent here omits `scope`, so this aligns the starter with them. The gate is a new contract test in the parity workflow's suite. Left upstream: the adapter's local branch is still unguarded, so a developer who chooses thread scope hits the same abort. Its remote branch already creates the thread and retries. Worth a follow-up in ag-ui. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4377,3 +4377,158 @@ function runAgentCoreDeployHarness(
|
||||
fs.rmSync(harnessDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastra working-memory scope contract.
|
||||
*
|
||||
* `@ag-ui/mastra` writes the client's shared state into Mastra working memory
|
||||
* BEFORE it streams a turn (`syncInputStateToWorkingMemory`). That write is
|
||||
* unguarded and never creates the thread, because it assumes the resource-scoped
|
||||
* store, which upserts. Under `scope: "thread"` the same write goes to thread
|
||||
* metadata instead, and `@mastra/memory` throws `Thread <id> not found` when the
|
||||
* thread row does not exist yet. On the first turn of a thread it never does, so
|
||||
* the run aborts before the model is called and the chat never answers.
|
||||
*
|
||||
* Managed Intelligence makes that certain rather than likely: the Intelligence
|
||||
* run handler replaces the client thread id with a platform-canonical one, which
|
||||
* the Mastra store has never seen.
|
||||
*
|
||||
* Resource scope is the correct setting for these starters and keeps working
|
||||
* memory per conversation, because the bridge derives the resource id from the
|
||||
* thread id when no explicit resource id is configured.
|
||||
*/
|
||||
const MASTRA_MEMORY_GLOB_DIRECTORIES = ["src/mastra"] as const;
|
||||
|
||||
/** Returns every `.ts` file under one directory, recursively. */
|
||||
function typeScriptFilesUnder(directory: string): string[] {
|
||||
if (!fs.existsSync(directory)) return [];
|
||||
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return typeScriptFilesUnder(entryPath);
|
||||
return entry.isFile() && entryPath.endsWith(".ts") ? [entryPath] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns every Mastra source file across the integration starters. */
|
||||
function mastraMemorySurfaces(): string[] {
|
||||
return fs
|
||||
.readdirSync(integrationsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.flatMap((entry) =>
|
||||
MASTRA_MEMORY_GLOB_DIRECTORIES.flatMap((relative) =>
|
||||
typeScriptFilesUnder(path.join(integrationsDir, entry.name, relative)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether one source configures thread-scoped Mastra working memory.
|
||||
*
|
||||
* @param contents - TypeScript source to inspect.
|
||||
* @returns True when any `workingMemory` object sets `scope: "thread"`.
|
||||
*/
|
||||
function declaresThreadScopedWorkingMemory(contents: string): boolean {
|
||||
const sourceFile = parseManagedSource(contents);
|
||||
let found = false;
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (found) return;
|
||||
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
propertyNameText(node.name) === "workingMemory"
|
||||
) {
|
||||
const initializer = unwrapExpression(node.initializer);
|
||||
if (
|
||||
ts.isObjectLiteralExpression(initializer) &&
|
||||
objectPropertyIsString(initializer, "scope", "thread")
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
test("integration starters never configure thread-scoped Mastra working memory", () => {
|
||||
const surfaces = mastraMemorySurfaces();
|
||||
|
||||
// Guards against a vacuous pass: the mastra starter must be in scope.
|
||||
expect(
|
||||
surfaces.some((surface) =>
|
||||
surface.includes(path.join("mastra", "src", "mastra")),
|
||||
),
|
||||
"the mastra starter's Mastra sources must be scanned",
|
||||
).toBe(true);
|
||||
|
||||
const offenders = surfaces.filter((surface) =>
|
||||
declaresThreadScopedWorkingMemory(fs.readFileSync(surface, "utf8")),
|
||||
);
|
||||
|
||||
expect(
|
||||
offenders.map((surface) => path.relative(integrationsDir, surface)),
|
||||
"thread-scoped working memory aborts the first turn of every new thread",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
configuration: "thread scope",
|
||||
contents: `
|
||||
new Memory({
|
||||
options: { workingMemory: { enabled: true, scope: "thread" } },
|
||||
});
|
||||
`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
configuration: "resource scope",
|
||||
contents: `
|
||||
new Memory({
|
||||
options: { workingMemory: { enabled: true, scope: "resource" } },
|
||||
});
|
||||
`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
configuration: "an omitted scope",
|
||||
contents: `
|
||||
new Memory({ options: { workingMemory: { enabled: true } } });
|
||||
`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
configuration: "thread scope nested below other options",
|
||||
contents: `
|
||||
new Agent({
|
||||
memory: new Memory({
|
||||
options: {
|
||||
workingMemory: { enabled: true, schema: AgentState, scope: "thread" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
configuration: "an unrelated thread-scoped option",
|
||||
contents: `
|
||||
new Memory({
|
||||
options: { semanticRecall: { scope: "thread" } },
|
||||
});
|
||||
`,
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
"the Mastra working-memory scope helper detects $configuration",
|
||||
({ contents, expected }) => {
|
||||
expect(declaresThreadScopedWorkingMemory(contents)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user