Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/clear-buses-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mastra/core': patch
---

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.
350 changes: 350 additions & 0 deletions packages/core/src/harness/fork-clone-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';

import { Agent } from '../agent';
import { RequestContext } from '../request-context';
import { SignalProvider } from '../signals/signal-provider';

import { Harness } from './harness';
import type * as Tools from './tools';
Expand Down Expand Up @@ -199,4 +200,353 @@ describe('Harness fork clone metadata wiring', () => {
expect(builtIn.subagent).toBeDefined();
expect(builtIn.ask_user).toBeDefined();
});

it('shared config.agent is reused across modes without forking', async () => {
const memoryFactory = vi.fn().mockResolvedValue({});

const baseAgent = new Agent({
name: 'shared',
instructions: 'shared agent',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
instructions: 'Build things.',
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
instructions: 'Plan things.',
},
],
agent: baseAgent,
});

await harness.init();

// All modes should return the same agent instance — no forking
const buildAgent = harness.getCurrentAgent();
await harness.switchMode({ modeId: 'plan' });
const planAgent = harness.getCurrentAgent();
await harness.switchMode({ modeId: 'build' });
const buildAgentAgain = harness.getCurrentAgent();

expect(buildAgent).toBe(baseAgent);
expect(planAgent).toBe(baseAgent);
expect(buildAgentAgain).toBe(baseAgent);
});

it('agent own instructions are never mutated by harness mode switches', async () => {
const memoryFactory = vi.fn().mockResolvedValue({});
const originalInstructions = 'I am the original agent instructions';

const baseAgent = new Agent({
name: 'shared',
instructions: originalInstructions,
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
instructions: 'Harness-level instructions.',
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
instructions: 'Build things.',
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
instructions: 'Plan things.',
},
],
agent: baseAgent,
});

await harness.init();

// Switch modes multiple times
harness.getCurrentAgent();
await harness.switchMode({ modeId: 'plan' });
harness.getCurrentAgent();
await harness.switchMode({ modeId: 'build' });
harness.getCurrentAgent();

// The agent's own instructions should remain unchanged
const agentInstructions = await baseAgent.getInstructions();
expect(agentInstructions).toBe(originalInstructions);
});

it('mode instructions are resolved at call time via resolveCurrentModeInstructions', async () => {
const memoryFactory = vi.fn().mockResolvedValue({});

const baseAgent = new Agent({
name: 'shared',
instructions: 'agent instructions',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
instructions: 'Harness global.',
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
instructions: 'Build mode.',
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
instructions: 'Plan mode.',
},
],
agent: baseAgent,
});

await harness.init();

const resolve = (harness as unknown as { resolveCurrentModeInstructions(): string | undefined })
.resolveCurrentModeInstructions;

// Default mode is 'build'
expect(resolve.call(harness)).toBe('Harness global.\nBuild mode.');

await harness.switchMode({ modeId: 'plan' });
expect(resolve.call(harness)).toBe('Harness global.\nPlan mode.');

await harness.switchMode({ modeId: 'build' });
expect(resolve.call(harness)).toBe('Harness global.\nBuild mode.');
});

it('mode tools are included in toolsets when using shared config.agent', async () => {
const memoryFactory = vi.fn().mockResolvedValue({});
const modeTool = { description: 'a mode tool', parameters: {} as never, execute: async () => null } as never;

const baseAgent = new Agent({
name: 'shared',
instructions: 'shared agent',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
additionalTools: { buildTool: modeTool },
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
},
],
agent: baseAgent,
});

await harness.init();

const buildToolsets = (
harness as unknown as { buildToolsets(ctx: RequestContext): Promise<Record<string, unknown>> }
).buildToolsets;

// In 'build' mode, mode tools should appear in toolsets
const buildResult = (await buildToolsets.call(harness, new RequestContext())) as {
modeTools?: Record<string, unknown>;
};
expect(buildResult.modeTools).toBeDefined();
expect(buildResult.modeTools!.buildTool).toBe(modeTool);

// Switch to 'plan' mode (no mode tools) — modeTools should be absent
await harness.switchMode({ modeId: 'plan' });
const planResult = (await buildToolsets.call(harness, new RequestContext())) as {
modeTools?: Record<string, unknown>;
};
expect(planResult.modeTools).toBeUndefined();
});

it('signal provider stays connected to same agent across mode switches', async () => {
class TestSignalProvider extends SignalProvider<'test-signals'> {
readonly id = 'test-signals' as const;
getConnectedAgent() {
return this.agent;
}
}

const signalProvider = new TestSignalProvider();
const memoryFactory = vi.fn().mockResolvedValue({});

const baseAgent = new Agent({
name: 'base',
instructions: 'base agent',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
signals: [signalProvider],
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
instructions: 'Build things.',
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
instructions: 'Plan things.',
},
],
agent: baseAgent,
});

await harness.init();

// Signal provider should always point at baseAgent, regardless of mode
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);

await harness.switchMode({ modeId: 'plan' });
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);

await harness.switchMode({ modeId: 'build' });
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);
expect(signalProvider.getConnectedAgent()!.hasOwnMemory()).toBe(true);
});

it('deprecated mode.agent path still works independently per mode', async () => {
const memoryFactory = vi.fn().mockResolvedValue({});

const buildAgent = new Agent({
name: 'build-agent',
instructions: 'build',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const planAgent = new Agent({
name: 'plan-agent',
instructions: 'plan',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
});

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
agent: buildAgent,
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
agent: planAgent,
},
],
});

await harness.init();

// Deprecated mode.agent path — each mode gets its own agent
const currentBuild = harness.getCurrentAgent();
expect(currentBuild).toBe(buildAgent);

await harness.switchMode({ modeId: 'plan' });
const currentPlan = harness.getCurrentAgent();
expect(currentPlan).toBe(planAgent);
expect(currentPlan).not.toBe(currentBuild);
});

it('propagates memory to the base config.agent so signal providers have access', async () => {
class TestSignalProvider extends SignalProvider<'test-signals'> {
readonly id = 'test-signals' as const;
getConnectedAgent() {
return this.agent;
}
}

const signalProvider = new TestSignalProvider();
const memoryFactory = vi.fn().mockResolvedValue({});

const baseAgent = new Agent({
name: 'base',
instructions: 'base agent',
model: { provider: 'openai', name: 'gpt-4o', toolChoice: 'auto' },
signals: [signalProvider],
Comment on lines +509 to +513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

potential_issue: Replace newly added literal model IDs with registered placeholder tokens.

This test adds concrete model IDs (gpt-4o, openai/gpt-4o) instead of the tokenized placeholders required by repo policy.

As per coding guidelines, “When adding model names or IDs to examples, changesets, tests, or comments, use placeholder tokens from docs/src/plugins/remark-model-tokens/models.ts”.

Also applies to: 238-239

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/harness/fork-clone-metadata.test.ts` around lines 215 -
219, The test file packages/core/src/harness/fork-clone-metadata.test.ts uses
concrete model IDs like 'gpt-4o' and 'openai/gpt-4o' instead of registered
placeholder tokens. Replace all literal model IDs in the baseAgent creation
(where model provider is set to 'openai' with name 'gpt-4o') and at the other
affected location (line 238-239) with the appropriate placeholder tokens defined
in docs/src/plugins/remark-model-tokens/models.ts according to the repo's coding
guidelines for model names in tests and examples.

Source: Coding guidelines

});

// Signal provider is connected to the base agent
expect(signalProvider.isConnected).toBe(true);
expect(signalProvider.getConnectedAgent()).toBe(baseAgent);

// Before harness init, base agent has no memory
expect(baseAgent.hasOwnMemory()).toBe(false);

const harness = new Harness({
id: 'test',
resourceId: 'test-resource',
memory: memoryFactory as unknown as never,
modes: [
{
id: 'build',
name: 'Build',
default: true,
defaultModelId: 'openai/gpt-4o',
instructions: 'Build things.',
},
{
id: 'plan',
name: 'Plan',
defaultModelId: 'openai/gpt-4o',
instructions: 'Plan things.',
},
],
agent: baseAgent,
});

await harness.init();

// After init, signal provider's connected agent (the base agent) should have memory
const connectedAgent = signalProvider.getConnectedAgent()!;
expect(connectedAgent).toBe(baseAgent);
expect(connectedAgent.hasOwnMemory()).toBe(true);
});
});
Loading
Loading