Skip to content

Commit 493a328

Browse files
taofeeq-deruclaudeMastra Code (anthropic/claude-opus-4-6)devin-ai-integration[bot]Abhi Aiyer
authored
feat(core): run agentic loop on the evented workflow engine (#16796)
## Summary - Migrates the agent's internal agentic loop onto the evented workflow engine. Each LLM call and tool call now runs as a workflow step, sharing suspend/resume and observability with the rest of the workflow system. No user-visible behavior change yet — this is the structural prerequisite for future durability and event-based observability features. - Fixes a nested-workflow bug where `resumeLabel` wasn't propagated up through a parent's suspendedPaths, so resuming a nested workflow by label silently failed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ELI5: This change moves the agent's internal loop to run as a series of steps in the evented workflow engine so each model call and tool call is tracked like a tiny workflow. That makes suspend/resume, observability, and future durability much easier and also fixes a bug that broke resuming nested workflows by label. - What it does - Migrates the agentic loop so each LLM and tool call executes as evented workflow steps (uses createEventedWorkflow / createEventedWorkflow factory). - Agents can run without a user-provided Mastra by creating a lazily-initialized ephemeral Mastra (InMemoryStore + EventEmitterPubSub) and starting ephemeral workers on first use. - Adds a per-run runScope (PrepareStreamRunScope) to thread non-JSON-safe runtime state (MessageList, processorStates, convertedTools, loopOptions) across prepare-stream workflow steps instead of passing those objects through step inputs/outputs. - Introduces runId-scoped internal workflow registration/unregistration to avoid collisions for concurrent/nested runs. - Bug fixes / behavioral corrections - Fixes nested-workflow resume-by-label: resumeLabel is propagated through parent suspendedPaths and nested suspend metadata so resuming a nested workflow by label works reliably. - Preserves and aggregates suspendPayloads (not just resumeLabels) across suspended foreach iterations and when storage persistence is skipped/absent. - Ensures persisted snapshots are retained for pending/paused/suspended states (not only suspended). - Tightens internal workflow lookup to prefer runId-scoped internal workflow instances, preventing wrong closure/registration resolution in nested/concurrent cases. - Implementation details / notable API surface changes - New helper: createEventedWorkflow(...) exported from workflows entrypoint (throws if evented factory not loaded). - Mastra changes: - Defaults to InMemoryStore when storage not provided (with warning) and augments the store. - Internal workflow registry supports runId-scoped keys; added __unregisterInternalWorkflow and updated __registerInternalWorkflow / __hasInternalWorkflow / __getInternalWorkflow signatures. - Evented processor and execution engine: - Nested workflow resolution now passes runId to getNestedWorkflow and resolveWorkflow. - Workflow event processor rebuilt resumeLabels and suspend payload propagation; adjusted foreach aggregation and merge behavior when storage updates are absent. - In-memory workflow storage replaced JSON-serialize deep-copy with a cycle-safe deep clone preserving Dates, Errors, Maps/Sets, prototypes, typed arrays, etc. - Tests / tooling - Added suspend/resume test to validate resuming a suspended nested workflow step by label and updated many tests to run against an evented Mastra (InMemoryStore + EventEmitterPubSub) with worker lifecycle management helpers (createTestMastra, start/stop workers). - Multiple test updates to use real InMemoryStore + evented pubsub instead of mocked storage or missing worker lifecycles. - Minor / UX - Updated background-task generated system prompt to clarify _background behavior and placeholder result guidance. - stream/loop runtime: distinguishes "pre-run" queued signals from normal "pending" signals and exposes drainPendingSignals(runId, scope?) with scope 'pending'|'pre-run'; stream lifecycle registers/unregisters run-scoped internal workflows to prevent leakage on suspend. - Risk / review notes - Structural and high-impact refactor touching core agent loop, workflow engine integration, storage cloning, and Mastra internals—review effort: high in multiple areas (agent, workflows, mastra, storage, evented processor). <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/mastra-ai/mastra/pull/16796?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Abhi Aiyer <abhi@mastra.ai> Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com>
1 parent 6aa6168 commit 493a328

117 files changed

Lines changed: 7320 additions & 4761 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/good-parents-help.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
- Use evented-workflow engine in the internal agentic loop.
6+
- Fix nested workflow resume with resumeLabel in evented-workflow engine.
7+
- Default to an in-memory store when no `storage` is configured on `Mastra`, and warn that it is not durable and a persistent storage adapter should be used in production.

.changeset/silly-insects-do.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/pg': patch
3+
---
4+
5+
Make atomic db updates better

.changeset/some-bees-work.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@mastra/libsql': patch
3+
---
4+
5+
Fixed concurrent writes silently disappearing when using LibSQL with a local (`file:`) database.
6+
7+
LibSQL backs a local database with a single connection. When one operation held an interactive write transaction (for example, persisting workflow snapshots) and another operation wrote at the same time (for example, creating a dataset experiment), the second write could be swept into the open transaction and rolled back — so it appeared to succeed but never persisted. This surfaced as concurrent agent/workflow runs losing unrelated records.
8+
9+
Writes on a LibSQL client are now serialized, so a write issued during an in-flight transaction no longer interleaves with it.

e2e-tests/client-js/_test-utils/src/server-setup.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,16 @@ export function createTestServerSetup(config: TestServerSetupConfig) {
104104
const port = await getPort();
105105
const baseUrl = `http://localhost:${port}`;
106106

107-
// Create storage
107+
// Create storage. LibSQL's `:memory:` URL gives each pooled connection its own
108+
// private in-memory database, so a table created by one connection is invisible
109+
// to the next — which the evented agentic-loop tickles by spreading workflow
110+
// snapshot reads/writes across multiple operations. Use a per-process temp file
111+
// so all pooled connections open the same on-disk database (mirrors the vector
112+
// store treatment already in place below).
113+
const libSqlDbPath = `file:${join(tmpdir(), `mastra-libsql-${randomUUID()}.db`)}`;
108114
const libSqlStore = new LibSQLStore({
109115
id: storageId,
110-
url: ':memory:',
116+
url: libSqlDbPath,
111117
});
112118

113119
const inMemoryStore = new InMemoryStore({

e2e-tests/monorepo/monorepo.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ describe.for([['pnpm'] as const])(`%s monorepo`, ([pkgManager]) => {
126126
// Ignore punycode warning
127127
return;
128128
}
129+
if (errMsg && errMsg.includes('falling back to an in-memory store')) {
130+
// Ignore in-memory storage fallback warning (no storage configured in fixture)
131+
return;
132+
}
129133
reject(new Error('failed to start dev: ' + errMsg));
130134
});
131135
proc!.stdout?.on('data', data => {
@@ -181,6 +185,10 @@ describe.for([['pnpm'] as const])(`%s monorepo`, ([pkgManager]) => {
181185
// Ignore punycode warning
182186
return;
183187
}
188+
if (errMsg && errMsg.includes('falling back to an in-memory store')) {
189+
// Ignore in-memory storage fallback warning (no storage configured in fixture)
190+
return;
191+
}
184192

185193
reject(new Error('failed to start: ' + errMsg));
186194
});

integrations/brightdata/src/__tests__/client.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ describe('getBrightDataClient', () => {
1616
delete process.env.BRIGHTDATA_SERP_ZONE;
1717
delete process.env.BRIGHTDATA_WEB_UNLOCKER_ZONE;
1818
fetchMock.mockImplementation(async (_url, init: RequestInit) => {
19-
const body =
20-
typeof init.body === 'string' ? (JSON.parse(init.body) as { format?: string }) : {};
19+
const body = typeof init.body === 'string' ? (JSON.parse(init.body) as { format?: string }) : {};
2120

2221
return body.format === 'json' ? Response.json({}) : new Response('ok');
2322
});

integrations/brightdata/src/__tests__/fetch.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,7 @@ describe('createBrightDataFetchTool', () => {
5858

5959
const tool = createBrightDataFetchTool({ apiKey: 'test-key' });
6060

61-
await expect(tool.execute!({ url: 'https://example.com' }, {} as any)).rejects.toThrow(
62-
'Network unreachable',
63-
);
61+
await expect(tool.execute!({ url: 'https://example.com' }, {} as any)).rejects.toThrow('Network unreachable');
6462
});
6563

6664
it('should make one Bright Data request after a successful execute', async () => {

integrations/brightdata/src/__tests__/search.test.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,37 +127,29 @@ describe('createBrightDataSearchTool', () => {
127127
const tool = createBrightDataSearchTool({ apiKey: 'test-key' });
128128
const result = (await tool.execute!({ query: 'test' }, {} as any)) as any;
129129

130-
expect(result.results).toEqual([
131-
{ link: 'https://ok.example', title: 'Has both', description: 'ok' },
132-
]);
130+
expect(result.results).toEqual([{ link: 'https://ok.example', title: 'Has both', description: 'ok' }]);
133131
});
134132

135133
it('should let errors propagate', async () => {
136134
fetchMock.mockRejectedValue(new Error('API rate limit exceeded'));
137135

138136
const tool = createBrightDataSearchTool({ apiKey: 'test-key' });
139137

140-
await expect(tool.execute!({ query: 'test' }, {} as any)).rejects.toThrow(
141-
'API rate limit exceeded',
142-
);
138+
await expect(tool.execute!({ query: 'test' }, {} as any)).rejects.toThrow('API rate limit exceeded');
143139
});
144140

145141
it('should parse string responses (SDK returns JSON-encoded text)', async () => {
146142
fetchMock.mockResolvedValue(
147143
Response.json({
148-
organic: [
149-
{ link: 'https://from.string', title: 'Stringified', description: 'ok' },
150-
],
144+
organic: [{ link: 'https://from.string', title: 'Stringified', description: 'ok' }],
151145
current_page: 3,
152146
}),
153147
);
154148

155149
const tool = createBrightDataSearchTool({ apiKey: 'test-key' });
156150
const result = (await tool.execute!({ query: 'test' }, {} as any)) as any;
157151

158-
expect(result.results).toEqual([
159-
{ link: 'https://from.string', title: 'Stringified', description: 'ok' },
160-
]);
152+
expect(result.results).toEqual([{ link: 'https://from.string', title: 'Stringified', description: 'ok' }]);
161153
expect(result.currentPage).toBe(3);
162154
});
163155

integrations/brightdata/src/client.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,7 @@ function toRequestBody(url: string, zone: string, options: RequestOptions = {}):
135135
export function getBrightDataClient(config?: BrightDataClientOptions): BrightDataClient {
136136
const apiKey = config?.apiKey ?? process.env.BRIGHTDATA_API_TOKEN;
137137
if (!apiKey) {
138-
throw new Error(
139-
'Bright Data API token is required. Pass { apiKey } or set BRIGHTDATA_API_TOKEN env var.',
140-
);
138+
throw new Error('Bright Data API token is required. Pass { apiKey } or set BRIGHTDATA_API_TOKEN env var.');
141139
}
142140

143141
const timeout = config?.timeout;
@@ -152,9 +150,7 @@ export function getBrightDataClient(config?: BrightDataClientOptions): BrightDat
152150
throw new Error('language must be a two-letter code (e.g. "en", "es")');
153151
}
154152

155-
const normalizedOptions = options.language
156-
? { ...options, language: options.language.toLowerCase() }
157-
: options;
153+
const normalizedOptions = options.language ? { ...options, language: options.language.toLowerCase() } : options;
158154

159155
const url = buildGoogleSearchUrl(query, normalizedOptions);
160156
return requestBrightData(

mastracode/src/headless-integration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,8 @@ describe('headless mode — thread control', () => {
843843
const thread = await harness.createThread({ title: 'target-thread' });
844844
const updatedAtBefore = thread.updatedAt.getTime();
845845

846+
await new Promise(resolve => setTimeout(resolve, 300));
847+
846848
const exitCode = await runHeadless(harness, {
847849
prompt: 'Hello',
848850
format: 'default',
@@ -853,6 +855,9 @@ describe('headless mode — thread control', () => {
853855

854856
expect(exitCode).toBe(0);
855857

858+
// Allow fire-and-forget persistTokenUsage to flush
859+
await new Promise(resolve => setTimeout(resolve, 300));
860+
856861
// Verify the targeted thread was actually used (updatedAt advanced)
857862
const threads = await harness.listThreads();
858863
const targeted = threads.find(t => t.id === thread.id);
@@ -869,6 +874,8 @@ describe('headless mode — thread control', () => {
869874
const thread = await harness.createThread({ title: 'my-feature' });
870875
const updatedAtBefore = thread.updatedAt.getTime();
871876

877+
await new Promise(resolve => setTimeout(resolve, 300));
878+
872879
const exitCode = await runHeadless(harness, {
873880
prompt: 'Hello',
874881
format: 'default',
@@ -879,6 +886,9 @@ describe('headless mode — thread control', () => {
879886

880887
expect(exitCode).toBe(0);
881888

889+
// Allow fire-and-forget persistTokenUsage to flush
890+
await new Promise(resolve => setTimeout(resolve, 300));
891+
882892
// Verify the titled thread was actually used
883893
const threads = await harness.listThreads();
884894
const targeted = threads.find(t => t.id === thread.id);

0 commit comments

Comments
 (0)