Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/dull-bars-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@mastra/editor': patch
---

Fixed editor-owned agent instructions failing silently. Agents configured with `editor: { instructions: true }` now throw a clear error instead of running with empty instructions when no published version is available in Studio. This affected agents that were never provisioned, only had a draft version, were deleted, had a published version with no instructions, or hit a storage error while loading. Fixes https://github.com/mastra-ai/mastra/issues/21373

**Before:** the agent ran normally with an empty system prompt.

**After:** resolving or generating with the agent throws until a published version with instructions exists.

```ts
// Agent definition — Studio owns the instructions:
export const agent = new Agent({
id: 'support-agent',
editor: { instructions: true },
model: 'openai/gpt-4o',
});
```

```ts
// Throws until a version is published in Studio:
const agent = client.getAgent('support-agent', { status: 'published' });
await agent.generate('hi');

// Use status: 'draft' to run against the latest draft instead, without publishing:
const draftAgent = client.getAgent('support-agent', { status: 'draft' });
await draftAgent.generate('hi');
```
109 changes: 108 additions & 1 deletion packages/editor/src/namespaces/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';

import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { Mastra } from '@mastra/core';
import { Agent } from '@mastra/core/agent';
import { InMemoryStore } from '@mastra/core/storage';
Expand Down Expand Up @@ -211,3 +211,110 @@ describe('EditorAgentNamespace.update', () => {
expect(await fetched?.getInstructions()).toBe('Stored TWO');
});
});

// Regression tests for https://github.com/mastra-ai/mastra/issues/21373 —
// an agent with `editor: { instructions: true }` cannot provide code instructions
// (the type system forbids it), so if nothing resolves it must fail closed instead
// of silently generating with empty instructions.
describe('EditorAgentNamespace.applyStoredOverrides fails closed when editor exclusively owns instructions', () => {
function makeEditorOwnedAgent() {
return new Agent({
id: 'editor-owned-agent',
name: 'Editor Owned Agent',
editor: { instructions: true, tools: false },
model: 'openai/gpt-4o',
});
}

it('throws when no stored agent record exists yet', async () => {
const storage = new InMemoryStore();
const editor = new MastraEditor();
const codeAgent = makeEditorOwnedAgent();
new Mastra({ storage, editor, agents: { 'editor-owned-agent': codeAgent } });

await expect(editor.agent.applyStoredOverrides(codeAgent, { status: 'published' })).rejects.toThrow(
/delegates instructions to the editor/,
);
});

it('throws when the stored agent is draft-only and status: "published" is requested', async () => {
const storage = new InMemoryStore();
const editor = new MastraEditor();
const codeAgent = makeEditorOwnedAgent();
new Mastra({ storage, editor, agents: { 'editor-owned-agent': codeAgent } });

const agentsStore = await storage.getStore('agents');
await agentsStore?.create({
agent: {
id: 'editor-owned-agent',
name: 'Editor Owned Agent',
instructions: 'DRAFT-ONLY-INSTRUCTIONS',
model: { provider: 'openai', name: 'gpt-4o' },
},
// no activeVersionId set -> draft-only, never published
} as Record<string, unknown>);

// Draft status still resolves normally.
const draftResolved = await editor.agent.applyStoredOverrides(codeAgent, { status: 'draft' });
expect(await draftResolved.getInstructions()).toBe('DRAFT-ONLY-INSTRUCTIONS');

// Published status has nothing to resolve — must fail closed.
await expect(editor.agent.applyStoredOverrides(codeAgent, { status: 'published' })).rejects.toThrow(
/no version has been published/,
);
});

it('throws when a published record exists but carries no instructions', async () => {
const storage = new InMemoryStore();
const editor = new MastraEditor();
const codeAgent = makeEditorOwnedAgent();
new Mastra({ storage, editor, agents: { 'editor-owned-agent': codeAgent } });

const agentsStore = await storage.getStore('agents');
await agentsStore?.create({
agent: {
id: 'editor-owned-agent',
name: 'Editor Owned Agent',
model: { provider: 'openai', name: 'gpt-4o' },
// no `instructions` field at all — a published version can still be missing it.
},
} as Record<string, unknown>);

// Publish the version that `create` implicitly wrote (version 1, with no instructions).
const { versions } = (await agentsStore?.listVersions({ agentId: 'editor-owned-agent' })) ?? { versions: [] };
await agentsStore?.update({ id: 'editor-owned-agent', activeVersionId: versions[0]?.id });

await expect(editor.agent.applyStoredOverrides(codeAgent, { status: 'published' })).rejects.toThrow(
/has no instructions/,
);
});

it('throws when the storage adapter fails to load the stored config', async () => {
const storage = new InMemoryStore();
const editor = new MastraEditor();
const codeAgent = makeEditorOwnedAgent();
new Mastra({ storage, editor, agents: { 'editor-owned-agent': codeAgent } });

vi.spyOn(editor.agent as any, 'getStorageAdapter').mockRejectedValue(new Error('storage unavailable'));

await expect(editor.agent.applyStoredOverrides(codeAgent, { status: 'published' })).rejects.toThrow(
/delegates instructions to the editor/,
);
});

it('does not throw for a code-owned agent (no editor config) in the same unresolved scenarios', async () => {
// Sanity check: the fail-closed behavior is scoped to editor-owned instructions only.
const storage = new InMemoryStore();
const editor = new MastraEditor();
const codeAgent = new Agent({
id: 'code-owned-agent',
name: 'Code Agent',
instructions: 'You are a code-defined agent.',
model: 'openai/gpt-4o',
});
new Mastra({ storage, editor, agents: { 'code-owned-agent': codeAgent } });

const result = await editor.agent.applyStoredOverrides(codeAgent, { status: 'published' });
expect(result).toBe(codeAgent);
});
});
31 changes: 31 additions & 0 deletions packages/editor/src/namespaces/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,19 @@ export class EditorAgentNamespace extends CrudEditorNamespace<
const toolsEditable = toolsConfig === true;
const toolDescriptionsEditable =
typeof toolsConfig === 'object' && toolsConfig !== null && toolsConfig.description === true;
// Instructions are exclusively owned by the editor only when `editor: { instructions: true }`
// is set explicitly — code is then forbidden from providing instructions at all (see
// `EditorOwnsInstructions` in `@mastra/core/agent/types`). When `editor` is omitted, code still
// carries real instructions as a fallback, so there is nothing to fail closed on.
const instructionsOwnedByEditor = editorConfig !== undefined && editorConfig.instructions === true;
const requestedStatus = options && !('versionId' in options) ? (options.status ?? 'draft') : undefined;

const failClosed = (reason: string): never => {
throw new Error(
`Agent "${agent.id}" delegates instructions to the editor ("editor: { instructions: true }") but ${reason}. ` +
`Publish a version in Studio before running this agent${requestedStatus === 'published' ? ", or request status: 'draft' instead" : ''}.`,
);
};

let storedConfig: StorageResolvedAgentType | null = null;
try {
Expand All @@ -516,20 +529,38 @@ export class EditorAgentNamespace extends CrudEditorNamespace<
if (options && 'versionId' in options) {
throw error;
}
if (instructionsOwnedByEditor) {
throw new Error(
`Agent "${agent.id}" delegates instructions to the editor ("editor: { instructions: true }") but the stored configuration could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
);
}
// Editor not registered, storage not available, or agent not found — return unchanged
return agent;
}

if (!storedConfig) {
if (instructionsOwnedByEditor) {
failClosed('no stored agent configuration exists yet');
}
return agent;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// If requesting published status but no version has been published, don't override the code-defined agent
const requestedPublished = options && !('versionId' in options) && options.status === 'published';
if (requestedPublished && !storedConfig.activeVersionId) {
if (instructionsOwnedByEditor) {
failClosed('no version has been published');
}
return agent;
}

// A resolved record can still carry no instructions (e.g. a version published before any
// were written). That leaves an editor-owned agent with its empty code default just like the
// unresolved cases above, so it must fail closed here too.
if (instructionsOwnedByEditor && (storedConfig.instructions === undefined || storedConfig.instructions === null)) {
failClosed('the stored agent configuration has no instructions');
}

// Fork the agent so overrides don't mutate the singleton instance
const fork = agent.__fork();

Expand Down