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) (#6870)
## Problem
A starter scaffolded with `copilotkit init --framework mastra` and
connected to managed Intelligence starts, accepts a chat message, and
never answers. `POST /api/copilotkit/agent/default/run` still returns
200, so the abort is only visible in the server log:
```
Agent execution failed: Error: Thread c883919e-… not found
```
## Root cause
`@ag-ui/mastra`'s `syncInputStateToWorkingMemory` writes the UI's shared
state into Mastra working memory **before** it streams a turn. That
write is unguarded and never creates the thread, because it assumes the
resource-scoped store, which upserts. Its own comment says so, and its
*remote* branch handles the opposite case explicitly ("requires the
thread to exist… create the thread and retry once").
This starter was the one Mastra agent in the repo that set
`workingMemory.scope: "thread"`. Thread scope routes the same write to
thread metadata, and `@mastra/memory` throws `Thread <id> not found`
when the thread row does not exist. On the first turn of a conversation
it never does, so the run dies before the model is called.
Managed Intelligence made that certain rather than likely:
`handlers/intelligence/run.ts` replaces the client thread id with a
platform-canonical one from `ɵacquireThreadLock`, which the Mastra store
has never seen. That also explains the two different thread ids in the
same failure.
## Evidence
Verified by running, against the starter's exact pins (`@mastra/core`
1.41.0, `@mastra/memory` 1.0.1-alpha.1, `@ag-ui/mastra` 1.1.2):
| Configuration | First-turn state sync |
| --- | --- |
| `scope: "thread"` (as shipped) | throws `Thread <id> not found`, run
aborts |
| `scope: "resource"` | writes, reads back, reaches the agent's system
message |
Resource scope keeps working memory **per conversation** here, because
the bridge derives the resource id from the thread id when no explicit
resource id is configured. Confirmed: a second thread id reads back
`null`, and schema merge semantics still work on turn 2.
## Change
- `examples/integrations/mastra` uses `scope: "resource"`, matching
every other Mastra agent in this repo, with a comment explaining why.
- A new contract test in
`scripts/__tests__/integration-intelligence-migration.test.ts` fails if
any integration starter configures thread-scoped Mastra working memory.
It asserts the mastra starter is in scope, so it cannot pass vacuously,
and it ships with five helper cases including a decoy
(`observationalMemory.scope: "thread"`, which is unrelated and must not
trip it).
This also fixes the Channel host, which drives the same agent.
## Verification
- `vitest run
scripts/__tests__/integration-intelligence-migration.test.ts` — 159
passed, and the new test is red on the unfixed starter (`expected [
'mastra/src/mastra/agents/index.ts' ] to deeply equal []`).
- `parity:check` passes, `oxlint` and `oxfmt --check` clean.
## Left undone, deliberately
The adapter's local branch is still unguarded, so a developer who
chooses thread scope hits the same abort in their own code. The fix
belongs in `@ag-ui/mastra` and mirrors what its remote branch already
does. That needs an ag-ui PR plus a release, so it is not in this
change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Updated the weather agent’s working-memory scope to support shared UI
state during the first turn of a conversation.
* Prevented conversation initialization issues caused by thread-scoped
memory.
* **Tests**
* Added validation to ensure integrations use compatible working-memory
scopes.
* Added coverage for direct, nested, resource-scoped, omitted, and
unrelated configuration cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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