Skip to content

Commit c853d53

Browse files
devin-ai-integration[bot]Abhi Aiyer
andauthored
fix(harness): eliminate agent forking — use single shared agent across modes (#18008)
## Description Eliminates `__fork()` from the shared-agent path in Harness. Instead of creating per-mode agent clones, the harness reuses `config.agent` directly and never mutates its instructions or tools. **Root cause:** PR #17892 introduced shared harness modes that fork the base agent per-mode. Signal providers connected to the base agent during construction skip re-connection on forks (`isConnected` guard), so they remain pointed at the original memoryless base agent. When GitHubSignals (or any signal provider) tries to deliver notifications, it crashes: ``` [Processor:task-state] computeStateSignal requires Mastra memory with an active resourceId and threadId ``` **Fix — no more forking, no agent mutation:** ```diff # getAgentForMode() — shared agent path - const forkedAgent = this.config.agent.__fork(); - forkedAgent.__updateInstructions(instructions); - forkedAgent.__setTools(modeTools); - this.#legacyAgentMode[mode.id] = forkedAgent; + return this.config.agent; // single instance, no fork, no mutation ``` - `getAgentForMode()`: returns `config.agent` directly — no fork, no instructions/tools mutation - `buildAgentMessageStreamOptions()`: resolves mode instructions at call time via `resolveCurrentModeInstructions()` and passes them as the `instructions` stream option — the agent's own instructions are never touched - `buildToolsets()`: mode-specific tools (`mode.tools` / `mode.additionalTools`) are delivered through toolsets — the agent's own tools (including signal-provider tools) are never replaced - `init()`: propagates runtime services (memory, storage, etc.) once to the shared agent — signal providers connected during construction retain full access - `setBrowser()`: sets browser on the single agent instead of iterating forks - Deprecated `mode.agent` and no-backing-agent construction paths are unchanged ## Related issue(s) Reported in Slack #mastracode by Tyler Barnes. Regression from #17892 (shared harness modes via `__fork()`). ## Type of change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Code refactoring ## Checklist - [x] I have linked the related issue(s) in the description above - [x] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have addressed all Coderabbit comments on this PR Link to Devin session: https://app.devin.ai/sessions/a7472d903dcd4982bbed590b0bde4c1c <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 Harness can run “agent work” in different modes (like different capabilities). A bug made Harness create separate agent copies per mode, but some notification components (signal providers) kept using the original agent that never got the required memory/services—so notifications crashed. This PR stops the per-mode agent cloning and instead reuses one shared agent, wiring mode-specific instructions/tools at runtime so the shared agent has memory and signals work again. ## Summary Fixes a regression where Harness signal providers (e.g. `GitHubSignals`) could crash because they were still connected to a base `Agent` that lacked Mastra runtime services (especially memory) after PR `#17892` introduced per-mode agent forking/cloning. ### Key changes - **Removed agent forking/cloning for the shared-agent (`config.agent`) path** - `getAgentForMode()` returns `config.agent` directly (no per-mode cloning). - The deprecated `mode.agent` path remains supported via its existing per-mode caching behavior (agents can still be independent there). - **Mode instructions are applied dynamically at call time (no mutation of the shared agent)** - `resolveCurrentModeInstructions()` computes harness-level + current mode instructions on demand. - `buildAgentMessageStreamOptions()` passes these as the `instructions` stream option, instead of updating the agent’s own instructions (avoids cross-mode instruction bleed). - **Mode tools are delivered via toolsets (no replacement of the shared agent’s tools)** - `buildToolsets()` supplies mode tool overrides at the toolset level (via `result.modeTools`) for the shared-agent path, rather than baking/replacing tools on the shared agent. - **Runtime services and browser propagation happen once for the shared agent** - `init()` propagates runtime services (memory/storage/workspace/browser/pubsub, etc.) once to the shared `config.agent`. - `setBrowser()` sets the browser on the single shared agent. - **Deprecated/no-backing-agent behavior remains unchanged** - When there is no shared backing agent, the harness continues constructing/caching per-mode agents with combined harness+mode instructions and merged mode tools, as before. ### Tests Extended `packages/core/src/harness/fork-clone-metadata.test.ts` to cover: - The same `config.agent` instance is reused across mode switches (no forking). - The shared agent’s own instruction text is not mutated across mode switches. - Mode-specific instructions are resolved dynamically at call time. - Mode tools appear/disappear correctly in generated toolsets while using the shared-agent path. - `SignalProvider`-based regression coverage: a connected base/shared agent retains access to runtime services (memory) after `harness.init()`, staying properly connected across mode switches. - A regression test confirming the deprecated `mode.agent` path still keeps agents independent per mode. ### Changeset - Added a patch changeset for `@mastra/core` documenting that signal providers no longer lose memory access when Harness previously forked agents per mode. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Abhi Aiyer <abhi@mastra.ai>
1 parent 8b984f4 commit c853d53

3 files changed

Lines changed: 448 additions & 35 deletions

File tree

.changeset/clear-buses-judge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed signal providers losing memory access when harness forks agents per mode. Harness now propagates runtime services (memory, storage, etc.) to the base config agent during init so signal providers connected to it can deliver notifications.

packages/core/src/harness/fork-clone-metadata.test.ts

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
import { Agent } from '../agent';
44
import { RequestContext } from '../request-context';
5+
import { SignalProvider } from '../signals/signal-provider';
56

67
import { Harness } from './harness';
78
import type * as Tools from './tools';
@@ -199,4 +200,353 @@ describe('Harness fork clone metadata wiring', () => {
199200
expect(builtIn.subagent).toBeDefined();
200201
expect(builtIn.ask_user).toBeDefined();
201202
});
203+
204+
it('shared config.agent is reused across modes without forking', async () => {
205+
const memoryFactory = vi.fn().mockResolvedValue({});
206+
207+
const baseAgent = new Agent({
208+
name: 'shared',
209+
instructions: 'shared agent',
210+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
211+
});
212+
213+
const harness = new Harness({
214+
id: 'test',
215+
resourceId: 'test-resource',
216+
memory: memoryFactory as unknown as never,
217+
modes: [
218+
{
219+
id: 'build',
220+
name: 'Build',
221+
default: true,
222+
defaultModelId: 'openai/gpt-4o',
223+
instructions: 'Build things.',
224+
},
225+
{
226+
id: 'plan',
227+
name: 'Plan',
228+
defaultModelId: 'openai/gpt-4o',
229+
instructions: 'Plan things.',
230+
},
231+
],
232+
agent: baseAgent,
233+
});
234+
235+
await harness.init();
236+
237+
// All modes should return the same agent instance — no forking
238+
const buildAgent = harness.getCurrentAgent();
239+
await harness.switchMode({ modeId: 'plan' });
240+
const planAgent = harness.getCurrentAgent();
241+
await harness.switchMode({ modeId: 'build' });
242+
const buildAgentAgain = harness.getCurrentAgent();
243+
244+
expect(buildAgent).toBe(baseAgent);
245+
expect(planAgent).toBe(baseAgent);
246+
expect(buildAgentAgain).toBe(baseAgent);
247+
});
248+
249+
it('agent own instructions are never mutated by harness mode switches', async () => {
250+
const memoryFactory = vi.fn().mockResolvedValue({});
251+
const originalInstructions = 'I am the original agent instructions';
252+
253+
const baseAgent = new Agent({
254+
name: 'shared',
255+
instructions: originalInstructions,
256+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
257+
});
258+
259+
const harness = new Harness({
260+
id: 'test',
261+
resourceId: 'test-resource',
262+
memory: memoryFactory as unknown as never,
263+
instructions: 'Harness-level instructions.',
264+
modes: [
265+
{
266+
id: 'build',
267+
name: 'Build',
268+
default: true,
269+
defaultModelId: 'openai/gpt-4o',
270+
instructions: 'Build things.',
271+
},
272+
{
273+
id: 'plan',
274+
name: 'Plan',
275+
defaultModelId: 'openai/gpt-4o',
276+
instructions: 'Plan things.',
277+
},
278+
],
279+
agent: baseAgent,
280+
});
281+
282+
await harness.init();
283+
284+
// Switch modes multiple times
285+
harness.getCurrentAgent();
286+
await harness.switchMode({ modeId: 'plan' });
287+
harness.getCurrentAgent();
288+
await harness.switchMode({ modeId: 'build' });
289+
harness.getCurrentAgent();
290+
291+
// The agent's own instructions should remain unchanged
292+
const agentInstructions = await baseAgent.getInstructions();
293+
expect(agentInstructions).toBe(originalInstructions);
294+
});
295+
296+
it('mode instructions are resolved at call time via resolveCurrentModeInstructions', async () => {
297+
const memoryFactory = vi.fn().mockResolvedValue({});
298+
299+
const baseAgent = new Agent({
300+
name: 'shared',
301+
instructions: 'agent instructions',
302+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
303+
});
304+
305+
const harness = new Harness({
306+
id: 'test',
307+
resourceId: 'test-resource',
308+
memory: memoryFactory as unknown as never,
309+
instructions: 'Harness global.',
310+
modes: [
311+
{
312+
id: 'build',
313+
name: 'Build',
314+
default: true,
315+
defaultModelId: 'openai/gpt-4o',
316+
instructions: 'Build mode.',
317+
},
318+
{
319+
id: 'plan',
320+
name: 'Plan',
321+
defaultModelId: 'openai/gpt-4o',
322+
instructions: 'Plan mode.',
323+
},
324+
],
325+
agent: baseAgent,
326+
});
327+
328+
await harness.init();
329+
330+
const resolve = (harness as unknown as { resolveCurrentModeInstructions(): string | undefined })
331+
.resolveCurrentModeInstructions;
332+
333+
// Default mode is 'build'
334+
expect(resolve.call(harness)).toBe('Harness global.\nBuild mode.');
335+
336+
await harness.switchMode({ modeId: 'plan' });
337+
expect(resolve.call(harness)).toBe('Harness global.\nPlan mode.');
338+
339+
await harness.switchMode({ modeId: 'build' });
340+
expect(resolve.call(harness)).toBe('Harness global.\nBuild mode.');
341+
});
342+
343+
it('mode tools are included in toolsets when using shared config.agent', async () => {
344+
const memoryFactory = vi.fn().mockResolvedValue({});
345+
const modeTool = { description: 'a mode tool', parameters: {} as never, execute: async () => null } as never;
346+
347+
const baseAgent = new Agent({
348+
name: 'shared',
349+
instructions: 'shared agent',
350+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
351+
});
352+
353+
const harness = new Harness({
354+
id: 'test',
355+
resourceId: 'test-resource',
356+
memory: memoryFactory as unknown as never,
357+
modes: [
358+
{
359+
id: 'build',
360+
name: 'Build',
361+
default: true,
362+
defaultModelId: 'openai/gpt-4o',
363+
additionalTools: { buildTool: modeTool },
364+
},
365+
{
366+
id: 'plan',
367+
name: 'Plan',
368+
defaultModelId: 'openai/gpt-4o',
369+
},
370+
],
371+
agent: baseAgent,
372+
});
373+
374+
await harness.init();
375+
376+
const buildToolsets = (
377+
harness as unknown as { buildToolsets(ctx: RequestContext): Promise<Record<string, unknown>> }
378+
).buildToolsets;
379+
380+
// In 'build' mode, mode tools should appear in toolsets
381+
const buildResult = (await buildToolsets.call(harness, new RequestContext())) as {
382+
modeTools?: Record<string, unknown>;
383+
};
384+
expect(buildResult.modeTools).toBeDefined();
385+
expect(buildResult.modeTools!.buildTool).toBe(modeTool);
386+
387+
// Switch to 'plan' mode (no mode tools) — modeTools should be absent
388+
await harness.switchMode({ modeId: 'plan' });
389+
const planResult = (await buildToolsets.call(harness, new RequestContext())) as {
390+
modeTools?: Record<string, unknown>;
391+
};
392+
expect(planResult.modeTools).toBeUndefined();
393+
});
394+
395+
it('signal provider stays connected to same agent across mode switches', async () => {
396+
class TestSignalProvider extends SignalProvider<'test-signals'> {
397+
readonly id = 'test-signals' as const;
398+
getConnectedAgent() {
399+
return this.agent;
400+
}
401+
}
402+
403+
const signalProvider = new TestSignalProvider();
404+
const memoryFactory = vi.fn().mockResolvedValue({});
405+
406+
const baseAgent = new Agent({
407+
name: 'base',
408+
instructions: 'base agent',
409+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
410+
signals: [signalProvider],
411+
});
412+
413+
const harness = new Harness({
414+
id: 'test',
415+
resourceId: 'test-resource',
416+
memory: memoryFactory as unknown as never,
417+
modes: [
418+
{
419+
id: 'build',
420+
name: 'Build',
421+
default: true,
422+
defaultModelId: 'openai/gpt-4o',
423+
instructions: 'Build things.',
424+
},
425+
{
426+
id: 'plan',
427+
name: 'Plan',
428+
defaultModelId: 'openai/gpt-4o',
429+
instructions: 'Plan things.',
430+
},
431+
],
432+
agent: baseAgent,
433+
});
434+
435+
await harness.init();
436+
437+
// Signal provider should always point at baseAgent, regardless of mode
438+
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
439+
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);
440+
441+
await harness.switchMode({ modeId: 'plan' });
442+
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
443+
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);
444+
445+
await harness.switchMode({ modeId: 'build' });
446+
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
447+
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);
448+
});
449+
450+
it('deprecated mode.agent path still works independently per mode', async () => {
451+
const memoryFactory = vi.fn().mockResolvedValue({});
452+
453+
const buildAgent = new Agent({
454+
name: 'build-agent',
455+
instructions: 'build',
456+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
457+
});
458+
459+
const planAgent = new Agent({
460+
name: 'plan-agent',
461+
instructions: 'plan',
462+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
463+
});
464+
465+
const harness = new Harness({
466+
id: 'test',
467+
resourceId: 'test-resource',
468+
memory: memoryFactory as unknown as never,
469+
modes: [
470+
{
471+
id: 'build',
472+
name: 'Build',
473+
default: true,
474+
defaultModelId: 'openai/gpt-4o',
475+
agent: buildAgent,
476+
},
477+
{
478+
id: 'plan',
479+
name: 'Plan',
480+
defaultModelId: 'openai/gpt-4o',
481+
agent: planAgent,
482+
},
483+
],
484+
});
485+
486+
await harness.init();
487+
488+
// Deprecated mode.agent path — each mode gets its own agent
489+
const currentBuild = harness.getCurrentAgent();
490+
expect(currentBuild).toBe(buildAgent);
491+
492+
await harness.switchMode({ modeId: 'plan' });
493+
const currentPlan = harness.getCurrentAgent();
494+
expect(currentPlan).toBe(planAgent);
495+
expect(currentPlan).not.toBe(currentBuild);
496+
});
497+
498+
it('propagates memory to the base config.agent so signal providers have access', async () => {
499+
class TestSignalProvider extends SignalProvider<'test-signals'> {
500+
readonly id = 'test-signals' as const;
501+
getConnectedAgent() {
502+
return this.agent;
503+
}
504+
}
505+
506+
const signalProvider = new TestSignalProvider();
507+
const memoryFactory = vi.fn().mockResolvedValue({});
508+
509+
const baseAgent = new Agent({
510+
name: 'base',
511+
instructions: 'base agent',
512+
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
513+
signals: [signalProvider],
514+
});
515+
516+
// Signal provider is connected to the base agent
517+
expect(signalProvider.isConnected).toBe(true);
518+
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
519+
520+
// Before harness init, base agent has no memory
521+
expect(baseAgent.hasOwnMemory()).toBe(false);
522+
523+
const harness = new Harness({
524+
id: 'test',
525+
resourceId: 'test-resource',
526+
memory: memoryFactory as unknown as never,
527+
modes: [
528+
{
529+
id: 'build',
530+
name: 'Build',
531+
default: true,
532+
defaultModelId: 'openai/gpt-4o',
533+
instructions: 'Build things.',
534+
},
535+
{
536+
id: 'plan',
537+
name: 'Plan',
538+
defaultModelId: 'openai/gpt-4o',
539+
instructions: 'Plan things.',
540+
},
541+
],
542+
agent: baseAgent,
543+
});
544+
545+
await harness.init();
546+
547+
// After init, signal provider's connected agent (the base agent) should have memory
548+
const connectedAgent = signalProvider.getConnectedAgent()!;
549+
expect(connectedAgent).toBe(baseAgent);
550+
expect(connectedAgent.hasOwnMemory()).toBe(true);
551+
});
202552
});

0 commit comments

Comments
 (0)