Skip to content

Commit 02087e1

Browse files
abhiaiyer91devin-ai-integration[bot]Abhi AiyerTylerBarnesMastra Code (openai/gpt-5.5)
authored
fix(core): make the native Agent goal mechanism robust (tools, waiting, budget, judge-failure) (#18016)
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: Tyler Barnes <tylerdbarnes@gmail.com> Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent ce319aa commit 02087e1

44 files changed

Lines changed: 4839 additions & 1637 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@mastra/core': minor
3+
'mastracode': patch
4+
---
5+
6+
Made the native Agent goal mechanism robust, restoring behavior that regressed when goal handling moved into core.
7+
8+
- **Tool-capable judges.** Scorer judge configs accept optional `tools`, and the default goal scorer can use them to verify the agent's work against reality instead of grading prose alone. MastraCode wires its read-only workspace tools (`view`, `search_content`, `find_files`, `file_stat`, `lsp_inspect`) into `goal.tools`.
9+
- **Judge memory restored.** Scorer judge configs accept optional `memory`; goal scoring uses the original MastraCode per-goal judge thread shape and prompt format so repeated evaluations retain prior facts, feedback, and user checkpoints through judge memory.
10+
- **Tri-state waiting.** The default goal scorer emits `done`/`continue`/`waiting`; a `waiting` decision (only when the goal text explicitly asks to stop for the user) stops the auto-loop but keeps the objective `active` so the next agent turn is still judged — no `/goal resume` needed.
11+
- **Budget-exhaustion pause.** Reaching `maxRuns` without completing now parks the objective as `paused` with a clear reason, resumable by raising `maxRuns` and reactivating, instead of silently leaving it `active`.
12+
- **Judge-failure pause (no infinite loop).** Any failure while evaluating the goal — including judge-model/tools resolution, not just the scorer run — pauses the objective and stops the loop, surfacing the cause, rather than re-running the model against a broken judge every turn.
13+
- **Structured-output retry.** `tryGenerateWithJsonFallback` now retries with `jsonPromptInjection` when the judge resolves without a parseable object (not only on a thrown error), matching the streaming path.
14+
- **Signal-based feedback.** Goal judge feedback is now injected as a `goal-judge` system-reminder signal instead of an assistant-authored "Completion Check Results" transcript message, so reloads and subsequent model context match the original MastraCode goal loop. Continuation, waiting, paused, and done decisions all persist structured evaluation metadata for replay.
15+
- **TUI activity/replay fixes.** Goal evaluation chunks close the current assistant message before rendering the judge UI, stream judge activity with useful tool targets, stream partial judge reason text while scoring, replay persisted judge results as judge display components instead of raw Goal reminder text, and correctly persist Esc/Ctrl+C pauses while the judge is running.
16+
17+
The goal evaluation chunk now carries `pausedReason`, `judgeFailed`, `waitingForUser`, `pending` (emitted before scoring starts so consumers can show a loading indicator), and judge `activity` entries including streamed `reason` updates.

mastracode/src/__tests__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ vi.mock('../agents/tools.js', () => ({
187187

188188
vi.mock('../agents/workspace.js', () => ({
189189
getDynamicWorkspace: getDynamicWorkspaceMock,
190+
getGoalJudgeTools: vi.fn(),
190191
}));
191192

192193
vi.mock('../auth/storage.js', () => ({
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { RequestContext } from '@mastra/core/request-context';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
vi.mock('../onboarding/settings.js', () => ({
8+
loadSettings: () => ({}),
9+
}));
10+
vi.mock('../../onboarding/settings.js', () => ({
11+
loadSettings: () => ({}),
12+
}));
13+
14+
afterEach(() => {
15+
vi.resetModules();
16+
});
17+
18+
function createRequestContext(projectPath: string) {
19+
const requestContext = new RequestContext();
20+
requestContext.set('harness', {
21+
modeId: 'build',
22+
getState: () => ({
23+
projectPath,
24+
sandboxAllowedPaths: [],
25+
}),
26+
});
27+
return requestContext;
28+
}
29+
30+
const READONLY = ['view', 'search_content', 'find_files', 'file_stat', 'lsp_inspect'];
31+
const MUTATING = ['write_file', 'string_replace_lsp', 'delete_file', 'mkdir', 'ast_smart_edit', 'execute_command'];
32+
33+
describe('getGoalJudgeTools', () => {
34+
it('returns only the read-only verification subset of workspace tools', async () => {
35+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mastracode-goal-judge-tools-'));
36+
try {
37+
const { getGoalJudgeTools } = await import('../workspace.js');
38+
const tools = await getGoalJudgeTools({ requestContext: createRequestContext(tempDir) as any });
39+
40+
expect(tools).toBeDefined();
41+
const names = Object.keys(tools!);
42+
43+
// Every read-only tool is present.
44+
for (const name of READONLY) {
45+
expect(names).toContain(name);
46+
}
47+
// No mutating / command-execution tool leaks into the judge toolset.
48+
for (const name of MUTATING) {
49+
expect(names).not.toContain(name);
50+
}
51+
} finally {
52+
await fs.rm(tempDir, { recursive: true, force: true });
53+
}
54+
});
55+
56+
it('returns undefined when no project path can be resolved (keeps judge text-only)', async () => {
57+
const { getGoalJudgeTools } = await import('../workspace.js');
58+
// Empty harness state → getDynamicWorkspace throws → resolver returns undefined.
59+
const requestContext = new RequestContext();
60+
requestContext.set('harness', { modeId: 'build', getState: () => ({}) });
61+
const tools = await getGoalJudgeTools({ requestContext: requestContext as any });
62+
expect(tools).toBeUndefined();
63+
});
64+
});

mastracode/src/agents/workspace.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@ import fs, { existsSync } from 'node:fs';
22
import os from 'node:os';
33
import path, { dirname, join } from 'node:path';
44
import { fileURLToPath } from 'node:url';
5+
import type { ToolsInput } from '@mastra/core/agent';
56
import type { HarnessRequestContext } from '@mastra/core/harness';
67
import type { Mastra } from '@mastra/core/mastra';
78
import type { RequestContext } from '@mastra/core/request-context';
8-
import { Workspace, LocalFilesystem, LocalSandbox } from '@mastra/core/workspace';
9+
import { Workspace, LocalFilesystem, LocalSandbox, createWorkspaceTools } from '@mastra/core/workspace';
910
import type { LSPConfig, WorkspaceToolsConfig } from '@mastra/core/workspace';
1011
import { DEFAULT_CONFIG_DIR } from '../constants.js';
1112
import { loadSettings } from '../onboarding/settings.js';
1213
import type { MastraCodeState } from '../schema';
13-
import { TOOL_NAME_OVERRIDES } from '../tool-names.js';
14+
import { MC_TOOLS, TOOL_NAME_OVERRIDES } from '../tool-names.js';
1415

1516
// =============================================================================
1617
// Sandbox Environment
@@ -194,3 +195,48 @@ export function getDynamicWorkspace({ requestContext, mastra }: { requestContext
194195
lsp: lspConfig,
195196
});
196197
}
198+
199+
/**
200+
* Read-only workspace tools the goal judge is allowed to call to verify the
201+
* agent's work. Mirrors the original (pre-native-goal) MastraCode judge, which
202+
* could inspect the workspace before deciding `done`/`continue`/`waiting`
203+
* instead of grading the assistant's prose alone. Strictly read-only: no write,
204+
* edit, delete, mkdir, or command-execution tools.
205+
*/
206+
const GOAL_JUDGE_READONLY_TOOLS: readonly string[] = [
207+
MC_TOOLS.VIEW,
208+
MC_TOOLS.SEARCH_CONTENT,
209+
MC_TOOLS.FIND_FILES,
210+
MC_TOOLS.FILE_STAT,
211+
MC_TOOLS.LSP_INSPECT,
212+
];
213+
214+
/**
215+
* Resolver for the agent's `goal.tools` config. Builds the request's workspace
216+
* (same per-request resolution as the agent's own tools) and returns only the
217+
* read-only verification subset, remapped to mastracode's tool names (`view`,
218+
* `search_content`, etc.). Returns `undefined` when no workspace can be resolved
219+
* (e.g. no project path), keeping the default judge text-only rather than
220+
* throwing inside the goal step.
221+
*/
222+
export async function getGoalJudgeTools({
223+
requestContext,
224+
mastra,
225+
}: {
226+
requestContext: RequestContext;
227+
mastra?: Mastra;
228+
}): Promise<ToolsInput | undefined> {
229+
let workspace: Workspace<LocalFilesystem, LocalSandbox>;
230+
try {
231+
workspace = getDynamicWorkspace({ requestContext, mastra });
232+
} catch {
233+
return undefined;
234+
}
235+
236+
const allTools = await createWorkspaceTools(workspace, { requestContext, workspace });
237+
const readonly: ToolsInput = {};
238+
for (const name of GOAL_JUDGE_READONLY_TOOLS) {
239+
if (allTools[name]) readonly[name] = allTools[name];
240+
}
241+
return Object.keys(readonly).length > 0 ? readonly : undefined;
242+
}

mastracode/src/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ vi.mock('./agents/subagents/execute.js', () => ({ executeSubagent: {} }));
5656
vi.mock('./agents/subagents/explore.js', () => ({ exploreSubagent: {} }));
5757
vi.mock('./agents/subagents/plan.js', () => ({ planSubagent: {} }));
5858
vi.mock('./agents/tools.js', () => ({ createDynamicTools: vi.fn(), createToolHooks: vi.fn() }));
59-
vi.mock('./agents/workspace.js', () => ({ getDynamicWorkspace: vi.fn() }));
59+
vi.mock('./agents/workspace.js', () => ({ getDynamicWorkspace: vi.fn(), getGoalJudgeTools: vi.fn() }));
6060

6161
vi.mock('./auth/storage.js', () => ({
6262
AuthStorage: class {

mastracode/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { getStaticallyLoadedInstructionPaths } from './agents/prompts/agent-inst
5353
import { attachOMThreadStatePersistence, restoreOMThreadStateForCurrentThread } from './agents/thread-caveman-state.js';
5454
import { createDynamicTools, createToolHooks } from './agents/tools.js';
5555

56-
import { getDynamicWorkspace } from './agents/workspace.js';
56+
import { getDynamicWorkspace, getGoalJudgeTools } from './agents/workspace.js';
5757
import { AuthStorage } from './auth/storage.js';
5858
import { DEFAULT_CONFIG_DIR, validateConfigDirName } from './constants.js';
5959
import { createOutcomeScorer, createEfficiencyScorer } from './evals/scorers/index.js';
@@ -465,6 +465,12 @@ export async function createMastraCode(config?: MastraCodeConfig) {
465465
judge: ctx => getGoalJudgeModel(ctx, config?.settingsPath),
466466
maxRuns: globalSettings.models.goalMaxTurns ?? 50,
467467
prompt: DEFAULT_GOAL_JUDGE_PROMPT,
468+
// Read-only workspace tools the default goal judge may call to verify the
469+
// agent's work against the actual filesystem (view, search_content,
470+
// find_files, file_stat, lsp_inspect) rather than grading prose alone —
471+
// restoring the original MastraCode judge's verification ability. Resolved
472+
// per-request from the active workspace (mirrors `judge`).
473+
tools: getGoalJudgeTools,
468474
},
469475
inputProcessors: [
470476
new AgentsMDInjector({

mastracode/src/tui/__tests__/goal-manager.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,16 @@ describe('GoalManager adapter', () => {
220220
const state = createState(agent);
221221
const manager = new GoalManager();
222222
await manager.setGoal(state, 'finish the task', '__GATEWAY_OPENAI_MODEL__');
223-
manager.pause();
223+
manager.pause('Judge evaluation was interrupted.');
224224

225225
await manager.saveToThread(state);
226226

227227
expect(agent.updateObjectiveOptions).toHaveBeenCalledWith(
228-
expect.objectContaining({ threadId: 'parent-thread', status: 'paused' }),
228+
expect.objectContaining({
229+
threadId: 'parent-thread',
230+
status: 'paused',
231+
pausedReason: 'Judge evaluation was interrupted.',
232+
}),
229233
);
230234
expect(state.harness.setThreadSetting).toHaveBeenCalledWith({ key: 'goal', value: undefined });
231235
});

mastracode/src/tui/__tests__/mastra-tui-queueing.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,59 @@ describe('MastraTUI queueing', () => {
556556
expect(ctx.updateStatusLine).toHaveBeenCalledTimes(6);
557557
});
558558

559+
it('adds goal activity to the active judge display while pending', () => {
560+
const state = createQueueState({
561+
goalManager: {
562+
applyEvaluation: vi.fn(),
563+
getGoal: vi.fn(() => ({
564+
id: 'goal-activity',
565+
status: 'active',
566+
judgeModelId: '__GATEWAY_OPENAI_MODEL__',
567+
turnsUsed: 1,
568+
maxTurns: 20,
569+
})),
570+
} as any,
571+
});
572+
const ctx = createQueueContext(state);
573+
574+
handleGoalEvaluation(ctx, createGoalPayload({ pending: true }));
575+
handleGoalEvaluation(
576+
ctx,
577+
createGoalPayload({
578+
pending: true,
579+
activity: [{ type: 'tool-call', name: 'read', message: 'read' }],
580+
} as any),
581+
);
582+
583+
expect((state.activeGoalJudge?.component as any).activity).toEqual(['read']);
584+
expect(state.goalManager.applyEvaluation).not.toHaveBeenCalled();
585+
expect(state.ui.requestRender).toHaveBeenCalled();
586+
});
587+
588+
it('ignores late goal chunks after the user has aborted and paused the goal', () => {
589+
const applyEvaluation = vi.fn();
590+
const state = createQueueState({
591+
userInitiatedAbort: true,
592+
goalManager: {
593+
applyEvaluation,
594+
getGoal: vi.fn(() => ({
595+
id: 'paused-goal',
596+
status: 'paused',
597+
judgeModelId: '__GATEWAY_OPENAI_MODEL__',
598+
turnsUsed: 5,
599+
maxTurns: 500,
600+
})),
601+
} as any,
602+
});
603+
const ctx = createQueueContext(state);
604+
605+
handleGoalEvaluation(ctx, createGoalPayload({ iteration: 5, status: 'active' }));
606+
607+
expect(applyEvaluation).not.toHaveBeenCalled();
608+
expect(state.activeGoalJudge).toBeUndefined();
609+
expect(state.ui.requestRender).not.toHaveBeenCalled();
610+
});
611+
559612
it('switches to plan mode when a plan-started goal completes with status=done', () => {
560613
const switchMode = vi.fn().mockResolvedValue({ accepted: true });
561614
const applyEvaluation = vi.fn();
@@ -637,6 +690,7 @@ describe('MastraTUI queueing', () => {
637690

638691
expect(switchMode).not.toHaveBeenCalled();
639692
expect(state.planStartedGoalId).toBe('plan-goal-123');
693+
expect(state.activeGoalJudge).toBeUndefined();
640694
});
641695

642696
it('does not switch to plan mode when goal evaluation reports status=paused', () => {

mastracode/src/tui/__tests__/render-messages.test.ts

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

55
import { AssistantMessageComponent } from '../components/assistant-message.js';
66
import { isChatBoundarySpacer } from '../components/chat-boundary-spacer.js';
7+
import { JudgeDisplayComponent } from '../components/judge-display.js';
78
import { NotificationSummaryComponent } from '../components/notification-summary.js';
89
import { NotificationComponent } from '../components/notification.js';
910
import { ReactiveSignalComponent } from '../components/reactive-signal.js';
@@ -374,6 +375,47 @@ describe('addUserMessage', () => {
374375
expect(rendered).toContain('Continue & handle <tags>');
375376
});
376377

378+
it('renders persisted goal-judge evaluations as judge display components', () => {
379+
const state = createState();
380+
381+
addUserMessage(
382+
state,
383+
createReminderMessage(
384+
{
385+
type: 'system_reminder',
386+
reminderType: 'goal-judge',
387+
message: '[Goal attempt 2/20] The goal is not yet complete. Judge feedback: Need another fact.',
388+
goalEvaluation: {
389+
objective: 'List whale facts',
390+
iteration: 2,
391+
maxRuns: 20,
392+
passed: false,
393+
status: 'active',
394+
results: [],
395+
reason: 'Need another fact.',
396+
duration: 0,
397+
timedOut: false,
398+
maxRunsReached: false,
399+
suppressFeedback: false,
400+
},
401+
} as Extract<HarnessMessage['content'][number], { type: 'system_reminder' }>,
402+
'goal-judge-1',
403+
),
404+
);
405+
406+
expect(state.chatContainer.children).toHaveLength(1);
407+
expect(state.chatContainer.children[0]).toBeInstanceOf(JudgeDisplayComponent);
408+
expect(state.allSystemReminderComponents).toHaveLength(0);
409+
expect(state.messageComponentsById.get('goal-judge-1')).toBe(state.chatContainer.children[0]);
410+
const rendered = (state.chatContainer.children[0] as JudgeDisplayComponent)
411+
.render(80)
412+
.join('\n')
413+
.replace(/\x1b\[[0-9;]*m/g, '');
414+
expect(rendered).toContain('continue');
415+
expect(rendered).toContain('(2/20)');
416+
expect(rendered).toContain('Need another fact.');
417+
});
418+
377419
it('renders canonical initial goal reminders as system reminders', () => {
378420
const state = createState();
379421

mastracode/src/tui/__tests__/setup-keyboard-shortcuts.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ describe('setupKeyboardShortcuts', () => {
393393
expect(abortController.signal.aborted).toBe(true);
394394
expect(component.setInterrupted).toHaveBeenCalledTimes(1);
395395
expect(state.userInitiatedAbort).toBe(true);
396-
expect(state.harness.abort).not.toHaveBeenCalled();
396+
expect(state.harness.abort).toHaveBeenCalledTimes(1);
397397
expect(editor.setText).not.toHaveBeenCalled();
398398
expect(state.ui.requestRender).toHaveBeenCalled();
399399
});
@@ -435,6 +435,29 @@ describe('setupKeyboardShortcuts', () => {
435435
expect(editor.setText).not.toHaveBeenCalled();
436436
});
437437

438+
it('aborts the harness and persists a paused goal when clearing during goal judge evaluation', () => {
439+
const { state, actions } = createState(true);
440+
const abortController = { abort: vi.fn() };
441+
const component = { setInterrupted: vi.fn() };
442+
state.activeGoalJudge = { modelId: 'openai/gpt-5.5', abortController, component };
443+
444+
setupKeyboardShortcuts(state, {
445+
stop: vi.fn(),
446+
doubleCtrlCMs: 500,
447+
queueFollowUpMessage: vi.fn(),
448+
});
449+
450+
actions.get('clear')?.();
451+
452+
expect(abortController.abort).toHaveBeenCalledTimes(1);
453+
expect(component.setInterrupted).toHaveBeenCalledTimes(1);
454+
expect(state.harness.abort).toHaveBeenCalledTimes(1);
455+
expect(state.goalManager.pause).toHaveBeenCalledWith('Judge evaluation was interrupted.');
456+
expect(state.goalManager.saveToThread).toHaveBeenCalledWith(state);
457+
expect(state.activeGoalJudge).toBeUndefined();
458+
expect(state.userInitiatedAbort).toBe(true);
459+
});
460+
438461
it('aborts and clears an active plan approval parked in a tool suspension', () => {
439462
// Regression: Ctrl+C while a submit_plan approval box is up must abort the
440463
// parked suspension (not hang). The editor-level handleInput override lets

0 commit comments

Comments
 (0)