mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
refactor(instance-ai): remove mastra stream fallback
This commit is contained in:
parent
2bc0d84052
commit
7cce9b1621
|
|
@ -455,7 +455,7 @@ pnpm typecheck
|
|||
- [x] Delete Mastra registration logic.
|
||||
- [x] Rewrite all tools to native `Tool`.
|
||||
- [x] Rewrite orchestration tools for native streaming and resume.
|
||||
- [ ] Rewrite stream runner and stream executor for native `StreamChunk`.
|
||||
- [x] Rewrite stream runner and stream executor for native `StreamChunk`.
|
||||
- [x] Rename internal `mastraRunId` to `agentRunId`.
|
||||
- [x] Implement native chunk to Instance AI event mapper.
|
||||
- [x] Implement TypeORM `BuiltMemory`.
|
||||
|
|
@ -474,10 +474,6 @@ pnpm typecheck
|
|||
|
||||
Current remaining cleanup:
|
||||
|
||||
- Remove the Mastra-shaped stream fallback (`mapMastraChunkToEvent`,
|
||||
`ResumableStreamFormat = 'mastra' | 'agent'`) now that runtime execution is
|
||||
native-only.
|
||||
- Rename stale Mastra-only test fixture IDs/tool names and comments.
|
||||
- Update architecture/tooling docs that still describe the old Mastra runtime.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ export default defineConfig(baseConfig, {
|
|||
ignores: ['scripts/**/*.cjs'],
|
||||
}, {
|
||||
rules: {
|
||||
// Mastra tool names are kebab-case identifiers (e.g. 'list-workflows')
|
||||
// which require quotes in object literals — skip naming checks for those
|
||||
// Tool names may be kebab-case identifiers (e.g. 'list-workflows'), which
|
||||
// require quotes in object literals. Skip naming checks for those.
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
/**
|
||||
* Maximum LLM steps (inference rounds) for each agent role.
|
||||
*
|
||||
* Mastra's Agent.stream() defaults to stepCountIs(5) which is too low
|
||||
* for most use cases. Each agent sets its own limit based on task complexity.
|
||||
*
|
||||
* @see https://github.com/mastra-ai/mastra/issues/2930
|
||||
* Native agent iteration limits for each role. Each agent sets its own limit
|
||||
* based on task complexity.
|
||||
*/
|
||||
export const MAX_STEPS = {
|
||||
/** Main orchestrator — coordinates all other agents and handles direct tool calls. */
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export type {
|
|||
} from './storage';
|
||||
export { truncateToTitle, generateTitleForRun } from './memory/title-utils';
|
||||
export { McpClientManager } from './mcp/mcp-client-manager';
|
||||
export { mapAgentChunkToEvent, mapMastraChunkToEvent } from './stream/map-chunk';
|
||||
export { mapAgentChunkToEvent } from './stream/map-chunk';
|
||||
export { isRecord, parseSuspension, asResumable } from './utils/stream-helpers';
|
||||
export { createEvalAgent, extractText, Tool, SONNET_MODEL, HAIKU_MODEL } from './utils/eval-agents';
|
||||
export type { SuspensionInfo, Resumable } from './utils/stream-helpers';
|
||||
|
|
|
|||
|
|
@ -229,6 +229,73 @@ function readableFromChunks(chunks: unknown[]) {
|
|||
});
|
||||
}
|
||||
|
||||
function textChunk(text: string) {
|
||||
return { type: 'text-delta', delta: text };
|
||||
}
|
||||
|
||||
function errorChunk(error: unknown) {
|
||||
return { type: 'error', error };
|
||||
}
|
||||
|
||||
function suspensionChunk(input: {
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
suspendPayload?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
type: 'tool-call-suspended',
|
||||
toolCallId: input.toolCallId,
|
||||
...(input.toolName ? { toolName: input.toolName } : {}),
|
||||
...(input.input ? { input: input.input } : {}),
|
||||
suspendPayload: input.suspendPayload ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallChunk(input: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
input: input.args ?? {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultChunk(input: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
return {
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
result: input.result,
|
||||
...(input.isError ? { isError: true } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
|
@ -260,20 +327,17 @@ describe('executeResumableStream', () => {
|
|||
const result = await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', payload: { text: 'Working...' } },
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'ask-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Need approval',
|
||||
},
|
||||
textChunk('Working...'),
|
||||
suspensionChunk({
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'ask-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Need approval',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
},
|
||||
context: {
|
||||
|
|
@ -290,7 +354,7 @@ describe('executeResumableStream', () => {
|
|||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'suspended',
|
||||
agentRunId: 'mastra-run-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
suspension: {
|
||||
toolCallId: 'tool-call-1',
|
||||
requestId: 'request-1',
|
||||
|
|
@ -319,19 +383,18 @@ describe('executeResumableStream', () => {
|
|||
agent: {},
|
||||
stream: {
|
||||
runId: 'agent-run-1',
|
||||
streamFormat: 'agent',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', delta: 'Working...' },
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
textChunk('Working...'),
|
||||
suspensionChunk({
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'ask-user',
|
||||
input: { prompt: 'Confirm?' },
|
||||
suspendPayload: {
|
||||
toolCallId: 'tool-call-1',
|
||||
requestId: 'request-1',
|
||||
message: 'Need approval',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
},
|
||||
context: {
|
||||
|
|
@ -386,16 +449,8 @@ describe('executeResumableStream', () => {
|
|||
const result = await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', payload: { text: 'Working...' } },
|
||||
{
|
||||
type: 'error',
|
||||
runId: 'mastra-run-1',
|
||||
from: 'AGENT',
|
||||
payload: { error: new Error('Not Found') },
|
||||
},
|
||||
]),
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([textChunk('Working...'), errorChunk(new Error('Not Found'))]),
|
||||
},
|
||||
context: {
|
||||
threadId: 'thread-1',
|
||||
|
|
@ -409,34 +464,30 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
expect(result.status).toBe('errored');
|
||||
expect(result.agentRunId).toBe('mastra-run-1');
|
||||
expect(result.agentRunId).toBe('agent-run-1');
|
||||
});
|
||||
|
||||
it('auto-resumes suspended streams and surfaces queued corrections', async () => {
|
||||
const eventBus = createEventBus();
|
||||
const resumeStream = jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-2',
|
||||
fullStream: fromChunks([{ type: 'text-delta', payload: { text: 'Done.' } }]),
|
||||
text: Promise.resolve('Done.'),
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
|
||||
|
||||
const result = await executeResumableStream({
|
||||
agent: { resumeStream },
|
||||
agent: { resume },
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Please confirm',
|
||||
},
|
||||
suspensionChunk({
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Please confirm',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
text: Promise.resolve('Initial text'),
|
||||
},
|
||||
|
|
@ -456,12 +507,13 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
expect(waitForConfirmation).toHaveBeenCalledWith('request-1');
|
||||
expect(resumeStream).toHaveBeenCalledWith(
|
||||
expect(resume).toHaveBeenCalledWith(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: 'mastra-run-1', toolCallId: 'tool-call-1' },
|
||||
{ runId: 'agent-run-1', toolCallId: 'tool-call-1' },
|
||||
);
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.agentRunId).toBe('mastra-run-2');
|
||||
expect(result.agentRunId).toBe('agent-run-2');
|
||||
await expect(result.text ?? Promise.resolve('')).resolves.toBe('Done.');
|
||||
expect(eventBus.publish).toHaveBeenCalledWith(
|
||||
'thread-1',
|
||||
|
|
@ -485,7 +537,6 @@ describe('executeResumableStream', () => {
|
|||
agent: { resume },
|
||||
stream: {
|
||||
runId: 'agent-run-1',
|
||||
streamFormat: 'agent',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
|
|
@ -535,10 +586,9 @@ describe('executeResumableStream', () => {
|
|||
const finishGate = createDeferred<undefined>();
|
||||
const approval = createDeferred<Record<string, unknown>>();
|
||||
const waitStarted = createDeferred<undefined>();
|
||||
const resumeStream = jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-2',
|
||||
fullStream: fromChunks([{ type: 'text-delta', payload: { text: 'Done.' } }]),
|
||||
text: Promise.resolve('Done.'),
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockImplementation(async () => {
|
||||
waitStarted.resolve(undefined);
|
||||
|
|
@ -546,21 +596,18 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
const execution = executeResumableStream({
|
||||
agent: { resumeStream },
|
||||
agent: { resume },
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: (async function* () {
|
||||
yield {
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Please confirm',
|
||||
},
|
||||
yield suspensionChunk({
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'Please confirm',
|
||||
},
|
||||
};
|
||||
});
|
||||
await finishGate.promise;
|
||||
yield { type: 'finish', finishReason: 'tool-calls' };
|
||||
})(),
|
||||
|
|
@ -583,7 +630,7 @@ describe('executeResumableStream', () => {
|
|||
await waitStarted.promise;
|
||||
|
||||
expect(waitForConfirmation).toHaveBeenCalledWith('request-1');
|
||||
expect(resumeStream).not.toHaveBeenCalled();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
const publishCalls = eventBus.publish.mock.calls as Array<[string, PublishedEvent]>;
|
||||
const confirmationEvent = publishCalls.find(
|
||||
([, event]) => event.type === 'confirmation-request',
|
||||
|
|
@ -597,54 +644,48 @@ describe('executeResumableStream', () => {
|
|||
await expect(execution).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
agentRunId: 'mastra-run-2',
|
||||
agentRunId: 'agent-run-2',
|
||||
}),
|
||||
);
|
||||
expect(resumeStream).toHaveBeenCalledWith(
|
||||
expect(resume).toHaveBeenCalledWith(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: 'mastra-run-1', toolCallId: 'tool-call-1' },
|
||||
{ runId: 'agent-run-1', toolCallId: 'tool-call-1' },
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces only the first actionable suspension in a drain', async () => {
|
||||
const eventBus = createEventBus();
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const resumeStream = jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-2',
|
||||
fullStream: fromChunks([{ type: 'text-delta', payload: { text: 'Done.' } }]),
|
||||
text: Promise.resolve('Done.'),
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
|
||||
const onSuspension = jest.fn((_: SuspensionInfo) => undefined);
|
||||
|
||||
try {
|
||||
await executeResumableStream({
|
||||
agent: { resumeStream },
|
||||
agent: { resume },
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'First confirmation',
|
||||
},
|
||||
suspensionChunk({
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-1',
|
||||
message: 'First confirmation',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tool-call-2',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-2',
|
||||
message: 'Second confirmation',
|
||||
},
|
||||
}),
|
||||
suspensionChunk({
|
||||
toolCallId: 'tool-call-2',
|
||||
toolName: 'pause-for-user',
|
||||
suspendPayload: {
|
||||
requestId: 'request-2',
|
||||
message: 'Second confirmation',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: 'finish', finishReason: 'tool-calls' },
|
||||
]),
|
||||
text: Promise.resolve('Initial text'),
|
||||
|
|
@ -675,9 +716,10 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
expect(waitForConfirmation).toHaveBeenCalledTimes(1);
|
||||
expect(waitForConfirmation).toHaveBeenCalledWith('request-1');
|
||||
expect(resumeStream).toHaveBeenCalledWith(
|
||||
expect(resume).toHaveBeenCalledWith(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: 'mastra-run-1', toolCallId: 'tool-call-1' },
|
||||
{ runId: 'agent-run-1', toolCallId: 'tool-call-1' },
|
||||
);
|
||||
|
||||
const confirmationEvents = (eventBus.publish.mock.calls as Array<[string, PublishedEvent]>)
|
||||
|
|
@ -704,7 +746,7 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'step-start',
|
||||
|
|
@ -718,7 +760,7 @@ describe('executeResumableStream', () => {
|
|||
warnings: [],
|
||||
},
|
||||
},
|
||||
{ type: 'text-delta', payload: { text: 'Let me check.' } },
|
||||
textChunk('Let me check.'),
|
||||
{
|
||||
type: 'step-finish',
|
||||
payload: {
|
||||
|
|
@ -842,7 +884,7 @@ describe('executeResumableStream', () => {
|
|||
stepNumber: 0,
|
||||
messages: [{ role: 'user', content: 'Build the workflow' }],
|
||||
});
|
||||
hooks?.onStreamChunk({ type: 'text-delta', payload: { text: 'Let me write it.' } });
|
||||
hooks?.onStreamChunk(textChunk('Let me write it.'));
|
||||
|
||||
await hooks?.executionOptions.onStepFinish({
|
||||
stepNumber: 0,
|
||||
|
|
@ -851,7 +893,7 @@ describe('executeResumableStream', () => {
|
|||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
args: {
|
||||
path: '/tmp/workflow.ts',
|
||||
content: 'export default workflow',
|
||||
|
|
@ -861,7 +903,7 @@ describe('executeResumableStream', () => {
|
|||
toolResults: [
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
result: 'Wrote 23 bytes',
|
||||
},
|
||||
],
|
||||
|
|
@ -886,7 +928,7 @@ describe('executeResumableStream', () => {
|
|||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
args: {
|
||||
path: '/tmp/workflow.ts',
|
||||
content: 'export default workflow',
|
||||
|
|
@ -907,13 +949,13 @@ describe('executeResumableStream', () => {
|
|||
expect(llmRun?.outputs?.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me write it.\n\n[Calling tools: mastra_workspace_write_file]',
|
||||
content: 'Let me write it.\n\n[Calling tools: workspace_write_file]',
|
||||
},
|
||||
]);
|
||||
expect(llmRun?.outputs?.requested_tools).toEqual([
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
},
|
||||
]);
|
||||
expect(llmRun?.outputs).not.toHaveProperty('tool_results');
|
||||
|
|
@ -944,7 +986,7 @@ describe('executeResumableStream', () => {
|
|||
stepNumber: 0,
|
||||
messages: [{ role: 'user', content: 'Build the workflow' }],
|
||||
});
|
||||
hooks?.onStreamChunk({ type: 'text-delta', payload: { text: 'Let me write it.' } });
|
||||
hooks?.onStreamChunk(textChunk('Let me write it.'));
|
||||
hooks?.onStreamChunk({
|
||||
type: 'step-finish',
|
||||
payload: {
|
||||
|
|
@ -964,7 +1006,7 @@ describe('executeResumableStream', () => {
|
|||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
|
|
@ -996,7 +1038,7 @@ describe('executeResumableStream', () => {
|
|||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
args: {
|
||||
path: '/tmp/workflow.ts',
|
||||
content: 'export default workflow',
|
||||
|
|
@ -1006,7 +1048,7 @@ describe('executeResumableStream', () => {
|
|||
toolResults: [
|
||||
{
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
result: 'Wrote 23 bytes',
|
||||
},
|
||||
],
|
||||
|
|
@ -1026,7 +1068,7 @@ describe('executeResumableStream', () => {
|
|||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'native-1',
|
||||
toolName: 'mastra_workspace_write_file',
|
||||
toolName: 'workspace_write_file',
|
||||
args: {
|
||||
path: '/tmp/workflow.ts',
|
||||
content: 'export default workflow',
|
||||
|
|
@ -1222,26 +1264,18 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-3',
|
||||
runId: 'agent-run-3',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'tool-call',
|
||||
payload: {
|
||||
toolCallId: 'native-tool-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
args: {
|
||||
command: 'echo hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
payload: {
|
||||
toolCallId: 'native-tool-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
result: 'hello',
|
||||
},
|
||||
},
|
||||
toolCallChunk({
|
||||
toolCallId: 'native-tool-1',
|
||||
toolName: 'workspace_execute_command',
|
||||
args: { command: 'echo hello' },
|
||||
}),
|
||||
toolResultChunk({
|
||||
toolCallId: 'native-tool-1',
|
||||
toolName: 'workspace_execute_command',
|
||||
result: 'hello',
|
||||
}),
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]),
|
||||
},
|
||||
|
|
@ -1259,12 +1293,12 @@ describe('executeResumableStream', () => {
|
|||
|
||||
const toolRun = langsmithMock
|
||||
.getCreatedRuns()
|
||||
.find((run) => run.name === 'tool:mastra_workspace_execute_command');
|
||||
.find((run) => run.name === 'tool:workspace_execute_command');
|
||||
expect(toolRun).toBeDefined();
|
||||
expect(toolRun?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
synthetic_tool_trace: true,
|
||||
tool_name: 'mastra_workspace_execute_command',
|
||||
tool_name: 'workspace_execute_command',
|
||||
}),
|
||||
);
|
||||
expect(toolRun?.outputs).toEqual({
|
||||
|
|
@ -1299,22 +1333,16 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
async function* streamWithToolCall() {
|
||||
yield {
|
||||
type: 'tool-call',
|
||||
payload: {
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
args: { command: 'echo hello' },
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'tool-result',
|
||||
payload: {
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
result: 'hello',
|
||||
},
|
||||
};
|
||||
yield toolCallChunk({
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'workspace_execute_command',
|
||||
args: { command: 'echo hello' },
|
||||
});
|
||||
yield toolResultChunk({
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'workspace_execute_command',
|
||||
result: 'hello',
|
||||
});
|
||||
await hooks?.executionOptions.onStepFinish({
|
||||
stepNumber: 0,
|
||||
text: 'Done.',
|
||||
|
|
@ -1322,13 +1350,13 @@ describe('executeResumableStream', () => {
|
|||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
toolName: 'workspace_execute_command',
|
||||
},
|
||||
],
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: 'native-tool-turn-1',
|
||||
toolName: 'mastra_workspace_execute_command',
|
||||
toolName: 'workspace_execute_command',
|
||||
result: 'hello',
|
||||
},
|
||||
],
|
||||
|
|
@ -1355,7 +1383,7 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-turn-1',
|
||||
runId: 'agent-run-turn-1',
|
||||
fullStream: streamWithToolCall(),
|
||||
},
|
||||
context: {
|
||||
|
|
@ -1376,7 +1404,7 @@ describe('executeResumableStream', () => {
|
|||
.find((run) => run.name === 'llm:anthropic/claude-sonnet-4-6');
|
||||
const toolRun = langsmithMock
|
||||
.getCreatedRuns()
|
||||
.find((run) => run.name === 'tool:mastra_workspace_execute_command');
|
||||
.find((run) => run.name === 'tool:workspace_execute_command');
|
||||
|
||||
expect(llmRun?.parent_run_id).toBe(parentRun.id);
|
||||
expect(toolRun?.parent_run_id).toBe(llmRun?.id);
|
||||
|
|
@ -1398,9 +1426,9 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-2',
|
||||
runId: 'agent-run-2',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', payload: { text: 'I found the matching tables.' } },
|
||||
textChunk('I found the matching tables.'),
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]),
|
||||
steps: Promise.resolve([
|
||||
|
|
@ -1489,7 +1517,7 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-5',
|
||||
runId: 'agent-run-5',
|
||||
fullStream: (async function* () {
|
||||
await prepareStep?.({
|
||||
stepNumber: 0,
|
||||
|
|
@ -1499,7 +1527,7 @@ describe('executeResumableStream', () => {
|
|||
},
|
||||
messages: [{ role: 'user', content: 'Build a weather workflow' }],
|
||||
});
|
||||
yield { type: 'text-delta', payload: { text: 'Done.' } };
|
||||
yield textChunk('Done.');
|
||||
await llmStepTraceHooks?.executionOptions.onStepFinish({
|
||||
stepNumber: 0,
|
||||
text: 'Done.',
|
||||
|
|
@ -1601,7 +1629,7 @@ describe('executeResumableStream', () => {
|
|||
await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-usage-v3',
|
||||
runId: 'agent-run-usage-v3',
|
||||
fullStream: fromChunks([
|
||||
{
|
||||
type: 'step-start',
|
||||
|
|
@ -1614,7 +1642,7 @@ describe('executeResumableStream', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
{ type: 'text-delta', payload: { text: 'Done.' } },
|
||||
textChunk('Done.'),
|
||||
{
|
||||
type: 'step-finish',
|
||||
payload: {
|
||||
|
|
@ -1724,7 +1752,7 @@ describe('executeResumableStream', () => {
|
|||
},
|
||||
messages: [{ role: 'user', content: 'Build the weather workflow' }],
|
||||
});
|
||||
hooks?.onStreamChunk({ type: 'text-delta', payload: { text: 'Done.' } });
|
||||
hooks?.onStreamChunk(textChunk('Done.'));
|
||||
|
||||
await hooks?.executionOptions.onStepFinish({
|
||||
stepNumber: 0,
|
||||
|
|
@ -1815,7 +1843,7 @@ describe('executeResumableStream', () => {
|
|||
const result = await executeResumableStream({
|
||||
agent: {},
|
||||
stream: {
|
||||
runId: 'mastra-run-suspended-usage',
|
||||
runId: 'agent-run-suspended-usage',
|
||||
fullStream: (async function* () {
|
||||
await prepareStep?.({
|
||||
stepNumber: 0,
|
||||
|
|
@ -1825,7 +1853,7 @@ describe('executeResumableStream', () => {
|
|||
},
|
||||
messages: [{ role: 'user', content: 'Ask me a question' }],
|
||||
});
|
||||
yield { type: 'text-delta', payload: { text: 'I need one detail first.' } };
|
||||
yield textChunk('I need one detail first.');
|
||||
await llmStepTraceHooks?.executionOptions.onStepFinish({
|
||||
stepNumber: 0,
|
||||
text: 'I need one detail first.',
|
||||
|
|
@ -1868,26 +1896,20 @@ describe('executeResumableStream', () => {
|
|||
},
|
||||
providerMetadata: undefined,
|
||||
});
|
||||
yield {
|
||||
type: 'tool-call',
|
||||
payload: {
|
||||
toolCallId: 'ask-user-1',
|
||||
toolName: 'ask-user',
|
||||
args: {
|
||||
questions: [{ id: 'q1', question: 'Which city?', type: 'text' }],
|
||||
},
|
||||
yield toolCallChunk({
|
||||
toolCallId: 'ask-user-1',
|
||||
toolName: 'ask-user',
|
||||
args: {
|
||||
questions: [{ id: 'q1', question: 'Which city?', type: 'text' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'ask-user-1',
|
||||
toolName: 'ask-user',
|
||||
suspendPayload: {
|
||||
requestId: 'req-ask-user-1',
|
||||
},
|
||||
});
|
||||
yield suspensionChunk({
|
||||
toolCallId: 'ask-user-1',
|
||||
toolName: 'ask-user',
|
||||
suspendPayload: {
|
||||
requestId: 'req-ask-user-1',
|
||||
},
|
||||
};
|
||||
});
|
||||
})(),
|
||||
usage: Promise.resolve({
|
||||
inputTokens: 120,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function createSuspendedRunState(
|
|||
return {
|
||||
runId: 'run_abc',
|
||||
abortController: new AbortController(),
|
||||
agentRunId: 'mastra-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
agent: {},
|
||||
threadId: 'thread-1',
|
||||
user: { id: 'user-1', name: 'Alice' },
|
||||
|
|
|
|||
|
|
@ -56,21 +56,16 @@ describe('streamAgentRun', () => {
|
|||
it('returns errored status when agent stream contains an error chunk', async () => {
|
||||
jest.mocked(executeResumableStream).mockResolvedValue({
|
||||
status: 'errored',
|
||||
agentRunId: 'mastra-run-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
workSummary: emptyWorkSummary,
|
||||
});
|
||||
const eventBus = createEventBus();
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', payload: { text: 'Hello' } },
|
||||
{
|
||||
type: 'error',
|
||||
runId: 'mastra-run-1',
|
||||
from: 'AGENT',
|
||||
payload: { error: new Error('Not Found') },
|
||||
},
|
||||
{ type: 'text-delta', delta: 'Hello' },
|
||||
{ type: 'error', error: new Error('Not Found') },
|
||||
]),
|
||||
}),
|
||||
};
|
||||
|
|
@ -90,20 +85,20 @@ describe('streamAgentRun', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('errored');
|
||||
expect(result.agentRunId).toBe('mastra-run-1');
|
||||
expect(result.agentRunId).toBe('agent-run-1');
|
||||
});
|
||||
|
||||
it('returns completed status for successful streams', async () => {
|
||||
jest.mocked(executeResumableStream).mockResolvedValue({
|
||||
status: 'completed',
|
||||
agentRunId: 'mastra-run-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
workSummary: emptyWorkSummary,
|
||||
});
|
||||
const eventBus = createEventBus();
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-1',
|
||||
fullStream: fromChunks([{ type: 'text-delta', payload: { text: 'All good' } }]),
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([{ type: 'text-delta', delta: 'All good' }]),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -128,7 +123,7 @@ describe('streamAgentRun', () => {
|
|||
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
runId: 'mastra-run-1',
|
||||
runId: 'agent-run-1',
|
||||
fullStream: emptyStream(),
|
||||
}),
|
||||
};
|
||||
|
|
@ -136,7 +131,7 @@ describe('streamAgentRun', () => {
|
|||
|
||||
mockedExecuteResumableStream.mockResolvedValue({
|
||||
status: 'suspended',
|
||||
agentRunId: 'mastra-run-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
workSummary: emptyWorkSummary,
|
||||
suspension: {
|
||||
requestId: 'request-1',
|
||||
|
|
@ -173,7 +168,7 @@ describe('streamAgentRun', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('suspended');
|
||||
expect(result.agentRunId).toBe('mastra-run-1');
|
||||
expect(result.agentRunId).toBe('agent-run-1');
|
||||
expect(result.suspension?.requestId).toBe('request-1');
|
||||
expect(result.confirmationEvent?.type).toBe('confirmation-request');
|
||||
expect(result.confirmationEvent?.payload.requestId).toBe('request-1');
|
||||
|
|
@ -184,10 +179,10 @@ describe('streamAgentRun', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes the full Mastra stream payload through to the resumable executor', async () => {
|
||||
it('passes an already-normalized native stream source through to the resumable executor', async () => {
|
||||
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
|
||||
const streamResult = {
|
||||
runId: 'mastra-run-2',
|
||||
runId: 'agent-run-2',
|
||||
fullStream: emptyStream(),
|
||||
text: Promise.resolve('done'),
|
||||
steps: Promise.resolve([{ text: 'done' }]),
|
||||
|
|
@ -201,7 +196,7 @@ describe('streamAgentRun', () => {
|
|||
|
||||
mockedExecuteResumableStream.mockResolvedValue({
|
||||
status: 'completed',
|
||||
agentRunId: 'mastra-run-2',
|
||||
agentRunId: 'agent-run-2',
|
||||
text: Promise.resolve('done'),
|
||||
workSummary: emptyWorkSummary,
|
||||
});
|
||||
|
|
@ -272,7 +267,6 @@ describe('streamAgentRun', () => {
|
|||
expect(source).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: 'agent-run-1',
|
||||
streamFormat: 'agent',
|
||||
}),
|
||||
);
|
||||
await expect(collectAsyncIterable(source.fullStream)).resolves.toEqual([nativeChunk]);
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@ import type { RunTree } from 'langsmith';
|
|||
|
||||
import type { InstanceAiEventBus } from '../event-bus';
|
||||
import type { Logger } from '../logger';
|
||||
import { mapAgentChunkToEvent, mapMastraChunkToEvent } from '../stream/map-chunk';
|
||||
import { mapAgentChunkToEvent } from '../stream/map-chunk';
|
||||
import { WorkSummaryAccumulator, type WorkSummary } from '../stream/work-summary-accumulator';
|
||||
import { getTraceParentRun, setTraceParentOverride } from '../tracing/langsmith-tracing';
|
||||
import { parseSuspension, resumeStream } from '../utils/stream-helpers';
|
||||
import { parseSuspension, resumeAgentStream } from '../utils/stream-helpers';
|
||||
import type { SuspensionInfo } from '../utils/stream-helpers';
|
||||
|
||||
type ConfirmationRequestEvent = Extract<InstanceAiEvent, { type: 'confirmation-request' }>;
|
||||
export type ResumableStreamFormat = 'mastra' | 'agent';
|
||||
|
||||
export interface ResumableStreamSource {
|
||||
runId?: string;
|
||||
streamFormat?: ResumableStreamFormat;
|
||||
fullStream: AsyncIterable<unknown>;
|
||||
text?: Promise<string>;
|
||||
steps?: Promise<unknown[]>;
|
||||
|
|
@ -168,6 +166,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
||||
return (
|
||||
value !== null &&
|
||||
|
|
@ -257,14 +259,9 @@ async function* readableStreamToAsyncIterable(stream: ReadableStream<unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
export function normalizeStreamSource(
|
||||
result: unknown,
|
||||
options?: { streamFormat?: ResumableStreamFormat },
|
||||
): ResumableStreamSource {
|
||||
export function normalizeStreamSource(result: unknown): ResumableStreamSource {
|
||||
if (isResumableStreamSource(result)) {
|
||||
return options?.streamFormat && !result.streamFormat
|
||||
? { ...result, streamFormat: options.streamFormat }
|
||||
: result;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (isNativeStreamResult(result)) {
|
||||
|
|
@ -272,7 +269,6 @@ export function normalizeStreamSource(
|
|||
|
||||
return {
|
||||
runId: result.runId,
|
||||
streamFormat: 'agent',
|
||||
fullStream: readableStreamToAsyncIterable(eventStream),
|
||||
text: collectNativeStreamText(textStream),
|
||||
};
|
||||
|
|
@ -1131,6 +1127,47 @@ function getChunkPayload(chunk: unknown): Record<string, unknown> | undefined {
|
|||
return isRecord(chunk.payload) ? chunk.payload : chunk;
|
||||
}
|
||||
|
||||
function getNativeToolContent(chunk: unknown, type: 'tool-call' | 'tool-result') {
|
||||
if (!isRecord(chunk) || chunk.type !== 'message' || !isRecord(chunk.message)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = chunk.message;
|
||||
if (message.role !== 'tool' || !isUnknownArray(message.content)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return message.content.find((part) => isRecord(part) && part.type === type);
|
||||
}
|
||||
|
||||
function getNativeToolCallPayload(chunk: unknown): Record<string, unknown> | undefined {
|
||||
const toolCall = getNativeToolContent(chunk, 'tool-call');
|
||||
if (!isRecord(toolCall)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
args: toolCall.input,
|
||||
...(toolCall.providerExecuted === true ? { providerExecuted: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getNativeToolResultPayload(chunk: unknown): Record<string, unknown> | undefined {
|
||||
const toolResult = getNativeToolContent(chunk, 'tool-result');
|
||||
if (!isRecord(toolResult)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: toolResult.toolCallId,
|
||||
toolName: toolResult.toolName,
|
||||
result: toolResult.result,
|
||||
...(toolResult.isError === true ? { isError: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSyntheticToolInputs(
|
||||
toolCallId: string,
|
||||
_toolName: string,
|
||||
|
|
@ -1145,7 +1182,7 @@ function buildSyntheticToolInputs(
|
|||
function shouldCreateSyntheticToolTrace(payload: Record<string, unknown>): boolean {
|
||||
const toolName = typeof payload.toolName === 'string' ? payload.toolName : '';
|
||||
return (
|
||||
toolName.startsWith('mastra_') ||
|
||||
toolName.startsWith('workspace_') ||
|
||||
SYNTHETIC_TOOL_TRACE_NAMES.has(toolName) ||
|
||||
payload.providerExecuted === true ||
|
||||
payload.dynamic === true
|
||||
|
|
@ -1156,11 +1193,7 @@ async function startSyntheticToolTrace(
|
|||
chunk: unknown,
|
||||
records: Map<string, SyntheticToolTraceRecord>,
|
||||
): Promise<void> {
|
||||
if (!isRecord(chunk) || chunk.type !== 'tool-call') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = getChunkPayload(chunk);
|
||||
const payload = getNativeToolCallPayload(chunk);
|
||||
if (!payload || !shouldCreateSyntheticToolTrace(payload)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1182,7 +1215,7 @@ async function startSyntheticToolTrace(
|
|||
tags: dedupeTags([
|
||||
...(parentRun.tags ?? []),
|
||||
'tool',
|
||||
...(toolName.startsWith('mastra_') ? ['native-tool'] : []),
|
||||
...(toolName.startsWith('workspace_') ? ['native-tool'] : []),
|
||||
]),
|
||||
metadata: {
|
||||
...(parentRun.metadata ?? {}),
|
||||
|
|
@ -1207,11 +1240,7 @@ async function finishSyntheticToolTrace(
|
|||
chunk: unknown,
|
||||
records: Map<string, SyntheticToolTraceRecord>,
|
||||
): Promise<void> {
|
||||
if (!isRecord(chunk) || (chunk.type !== 'tool-result' && chunk.type !== 'tool-error')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = getChunkPayload(chunk);
|
||||
const payload = getNativeToolResultPayload(chunk);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1228,8 +1257,18 @@ async function finishSyntheticToolTrace(
|
|||
|
||||
await startSyntheticToolTrace(
|
||||
{
|
||||
type: 'tool-call',
|
||||
payload,
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
records,
|
||||
);
|
||||
|
|
@ -1370,25 +1409,23 @@ function updateStepRecordFromChunk(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const payload = isRecord(chunk.payload) ? chunk.payload : chunk;
|
||||
if ((chunk.type === 'text-delta' || chunk.type === 'text') && typeof payload.text === 'string') {
|
||||
record.textParts.push(payload.text);
|
||||
if (chunk.type === 'text-delta' && typeof chunk.delta === 'string') {
|
||||
record.textParts.push(chunk.delta);
|
||||
recordFirstTokenEvent(record);
|
||||
}
|
||||
|
||||
if (
|
||||
(chunk.type === 'reasoning-delta' || chunk.type === 'reasoning') &&
|
||||
typeof payload.text === 'string'
|
||||
) {
|
||||
record.reasoningParts.push(payload.text);
|
||||
if (chunk.type === 'reasoning-delta' && typeof chunk.delta === 'string') {
|
||||
record.reasoningParts.push(chunk.delta);
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-call' && isRecord(payload)) {
|
||||
record.toolCalls.push(toTraceObject(payload));
|
||||
const toolCallPayload = getNativeToolCallPayload(chunk);
|
||||
if (toolCallPayload) {
|
||||
record.toolCalls.push(toTraceObject(toolCallPayload));
|
||||
}
|
||||
|
||||
if ((chunk.type === 'tool-result' || chunk.type === 'tool-error') && isRecord(payload)) {
|
||||
record.toolResults.push(toTraceObject(payload));
|
||||
const toolResultPayload = getNativeToolResultPayload(chunk);
|
||||
if (toolResultPayload) {
|
||||
record.toolResults.push(toTraceObject(toolResultPayload));
|
||||
}
|
||||
|
||||
return record;
|
||||
|
|
@ -2033,20 +2070,12 @@ export async function executeResumableStream(
|
|||
hasError = true;
|
||||
}
|
||||
|
||||
const event =
|
||||
activeSource.streamFormat === 'agent'
|
||||
? mapAgentChunkToEvent(
|
||||
options.context.runId,
|
||||
options.context.agentId,
|
||||
chunk,
|
||||
currentResponseId,
|
||||
)
|
||||
: mapMastraChunkToEvent(
|
||||
options.context.runId,
|
||||
options.context.agentId,
|
||||
chunk,
|
||||
currentResponseId,
|
||||
);
|
||||
const event = mapAgentChunkToEvent(
|
||||
options.context.runId,
|
||||
options.context.agentId,
|
||||
chunk,
|
||||
currentResponseId,
|
||||
);
|
||||
if (event) {
|
||||
workSummaryAccumulator.observe(event);
|
||||
let shouldPublishEvent = true;
|
||||
|
|
@ -2138,13 +2167,11 @@ export async function executeResumableStream(
|
|||
runId: activeAgentRunId,
|
||||
toolCallId: suspension.toolCallId,
|
||||
};
|
||||
const resumed = await resumeStream(options.agent, resumeData, {
|
||||
const resumed = await resumeAgentStream(options.agent, resumeData, {
|
||||
...resumeOptions,
|
||||
...(options.llmStepTraceHooks?.executionOptions ?? {}),
|
||||
});
|
||||
const resumedSource = normalizeStreamSource(resumed, {
|
||||
streamFormat: activeSource.streamFormat,
|
||||
});
|
||||
const resumedSource = normalizeStreamSource(resumed);
|
||||
|
||||
activeAgentRunId =
|
||||
(typeof resumedSource.runId === 'string' ? resumedSource.runId : '') || activeAgentRunId;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type TraceStatus,
|
||||
} from './resumable-stream-executor';
|
||||
import { getTraceParentRun, withTraceParentContext } from '../tracing/langsmith-tracing';
|
||||
import { resumeStream } from '../utils/stream-helpers';
|
||||
import { resumeAgentStream } from '../utils/stream-helpers';
|
||||
import type { SuspensionInfo } from '../utils/stream-helpers';
|
||||
|
||||
export interface StreamableAgent {
|
||||
|
|
@ -63,7 +63,7 @@ export async function resumeAgentRun(
|
|||
const resumeTraceParent = getTraceParentRun();
|
||||
return await withTraceParentContext(resumeTraceParent, async () => {
|
||||
const llmStepTraceHooks = createLlmStepTraceHooks(resumeTraceParent);
|
||||
const resumed = await resumeStream(agent, resumeData, {
|
||||
const resumed = await resumeAgentStream(agent, resumeData, {
|
||||
...resumeOptions,
|
||||
...(llmStepTraceHooks?.executionOptions ?? {}),
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,7 +23,7 @@ export interface ConsumeWithHitlOptions {
|
|||
* Used to unblock HITL suspensions when a correction arrives mid-confirmation. */
|
||||
waitForCorrection?: () => Promise<void>;
|
||||
llmStepTraceHooks?: LlmStepTraceHooks;
|
||||
/** Max iterations for the agent — passed to resumeStream so resumed streams keep the same limit. */
|
||||
/** Max iterations for the agent; passed to native stream resume so resumed streams keep the same limit. */
|
||||
maxIterations?: number;
|
||||
/** Additional options to preserve when resuming a suspended stream. */
|
||||
resumeOptions?: Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ interface ErrorInfo {
|
|||
technicalDetails?: string;
|
||||
}
|
||||
|
||||
/** Extract structured error info from Mastra's error chunk payload.
|
||||
* Mastra sets `payload.error` to the raw Error object, not a string. */
|
||||
function extractErrorInfo(error: unknown): ErrorInfo {
|
||||
if (typeof error === 'string') return { content: error };
|
||||
|
||||
|
|
@ -88,91 +86,35 @@ function extractErrorInfo(error: unknown): ErrorInfo {
|
|||
return { content: 'Unknown error' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Mastra fullStream chunk to our InstanceAiEvent schema.
|
||||
*
|
||||
* Mastra chunks have the shape: { type, runId, from, payload: { ... } }
|
||||
* The actual data (textDelta, toolCallId, etc.) lives inside chunk.payload.
|
||||
*
|
||||
* Returns null for unrecognized chunk types (step-finish, finish, etc.)
|
||||
*/
|
||||
export function mapMastraChunkToEvent(
|
||||
export function mapAgentChunkToEvent(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
chunk: unknown,
|
||||
responseId?: string,
|
||||
): InstanceAiEvent | null {
|
||||
if (!isRecord(chunk)) return null;
|
||||
if (!isAgentStreamChunk(chunk)) return null;
|
||||
|
||||
const { type } = chunk;
|
||||
const payload = isRecord(chunk.payload) ? chunk.payload : {};
|
||||
const base = { runId, agentId, ...(responseId ? { responseId } : {}) };
|
||||
|
||||
// Mastra payload uses `text` (not `textDelta`) for text-delta chunks
|
||||
const textValue =
|
||||
typeof payload.text === 'string'
|
||||
? payload.text
|
||||
: typeof payload.textDelta === 'string'
|
||||
? payload.textDelta
|
||||
: undefined;
|
||||
|
||||
if (type === 'text-delta' && textValue !== undefined) {
|
||||
if (chunk.type === 'text-delta') {
|
||||
return {
|
||||
type: 'text-delta',
|
||||
...base,
|
||||
payload: { text: textValue },
|
||||
payload: { text: chunk.delta },
|
||||
};
|
||||
}
|
||||
|
||||
if ((type === 'reasoning-delta' || type === 'reasoning') && textValue !== undefined) {
|
||||
if (chunk.type === 'reasoning-delta') {
|
||||
return {
|
||||
type: 'reasoning-delta',
|
||||
...base,
|
||||
payload: { text: textValue },
|
||||
payload: { text: chunk.delta },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'tool-call') {
|
||||
return {
|
||||
type: 'tool-call',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId: typeof payload.toolCallId === 'string' ? payload.toolCallId : '',
|
||||
toolName: typeof payload.toolName === 'string' ? payload.toolName : '',
|
||||
args: isRecord(payload.args) ? payload.args : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'tool-result' || type === 'tool-error') {
|
||||
const toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : '';
|
||||
|
||||
// Mastra signals tool errors via `isError` on tool-result chunks,
|
||||
// not a separate event type. Map to our `tool-error` event.
|
||||
if (payload.isError === true) {
|
||||
return {
|
||||
type: 'tool-error',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId,
|
||||
error: typeof payload.result === 'string' ? payload.result : 'Tool execution failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool-result',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId,
|
||||
result: payload.result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'tool-call-suspended') {
|
||||
const suspendPayload = isRecord(payload.suspendPayload) ? payload.suspendPayload : {};
|
||||
const toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : '';
|
||||
if (chunk.type === 'tool-call-suspended') {
|
||||
const suspendPayload = isRecord(chunk.suspendPayload) ? chunk.suspendPayload : {};
|
||||
const toolCallId = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : '';
|
||||
|
||||
const requestId =
|
||||
typeof suspendPayload.requestId === 'string' && suspendPayload.requestId
|
||||
|
|
@ -187,7 +129,6 @@ export function mapMastraChunkToEvent(
|
|||
? (rawSeverity as (typeof validSeverities)[number])
|
||||
: 'warning';
|
||||
|
||||
// Extract and validate optional credentialRequests for credential setup HITL
|
||||
let credentialRequests: InstanceAiCredentialRequest[] | undefined;
|
||||
if (Array.isArray(suspendPayload.credentialRequests)) {
|
||||
const parsed = suspendPayload.credentialRequests
|
||||
|
|
@ -199,11 +140,9 @@ export function mapMastraChunkToEvent(
|
|||
}
|
||||
}
|
||||
|
||||
// Extract optional projectId for project-scoped actions
|
||||
const projectId =
|
||||
typeof suspendPayload.projectId === 'string' ? suspendPayload.projectId : undefined;
|
||||
|
||||
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision')
|
||||
const rawInputType =
|
||||
typeof suspendPayload.inputType === 'string' ? suspendPayload.inputType : undefined;
|
||||
const validInputTypes = [
|
||||
|
|
@ -217,7 +156,6 @@ export function mapMastraChunkToEvent(
|
|||
? (rawInputType as (typeof validInputTypes)[number])
|
||||
: undefined;
|
||||
|
||||
// Extract optional structured questions (for ask-user tool with questions)
|
||||
let questions: Array<z.infer<typeof questionItemSchema>> | undefined;
|
||||
if (Array.isArray(suspendPayload.questions)) {
|
||||
const parsed = suspendPayload.questions
|
||||
|
|
@ -229,11 +167,9 @@ export function mapMastraChunkToEvent(
|
|||
}
|
||||
}
|
||||
|
||||
// Extract optional intro message
|
||||
const introMessage =
|
||||
typeof suspendPayload.introMessage === 'string' ? suspendPayload.introMessage : undefined;
|
||||
|
||||
// Extract optional task list (for plan-review)
|
||||
let tasks: TaskList | undefined;
|
||||
if (isRecord(suspendPayload.tasks)) {
|
||||
const parsed = taskListSchema.safeParse(suspendPayload.tasks);
|
||||
|
|
@ -242,7 +178,6 @@ export function mapMastraChunkToEvent(
|
|||
}
|
||||
}
|
||||
|
||||
// Extract optional full planned task items (for plan-review panel details)
|
||||
let planItems: PlannedTaskArg[] | undefined;
|
||||
if (Array.isArray(suspendPayload.planItems)) {
|
||||
const parsed = suspendPayload.planItems
|
||||
|
|
@ -254,7 +189,6 @@ export function mapMastraChunkToEvent(
|
|||
}
|
||||
}
|
||||
|
||||
// Extract optional domainAccess metadata (for domain-gated tools like fetch-url)
|
||||
const rawDomainAccess = isRecord(suspendPayload.domainAccess)
|
||||
? suspendPayload.domainAccess
|
||||
: undefined;
|
||||
|
|
@ -265,7 +199,6 @@ export function mapMastraChunkToEvent(
|
|||
? { url: rawDomainAccess.url, host: rawDomainAccess.host }
|
||||
: undefined;
|
||||
|
||||
// Extract optional credentialFlow for credential setup stage
|
||||
const rawCredentialFlow = isRecord(suspendPayload.credentialFlow)
|
||||
? suspendPayload.credentialFlow
|
||||
: undefined;
|
||||
|
|
@ -279,7 +212,6 @@ export function mapMastraChunkToEvent(
|
|||
? { stage: rawStage as 'generic' | 'finalize' }
|
||||
: undefined;
|
||||
|
||||
// Extract and validate optional setupRequests for workflow setup HITL
|
||||
let setupRequests: InstanceAiWorkflowSetupNode[] | undefined;
|
||||
if (Array.isArray(suspendPayload.setupRequests)) {
|
||||
const parsed = suspendPayload.setupRequests
|
||||
|
|
@ -291,11 +223,9 @@ export function mapMastraChunkToEvent(
|
|||
}
|
||||
}
|
||||
|
||||
// Extract optional workflowId for workflow setup tool
|
||||
const workflowId =
|
||||
typeof suspendPayload.workflowId === 'string' ? suspendPayload.workflowId : undefined;
|
||||
|
||||
// Extract optional resourceDecision for gateway permission gating (inputType=resource-decision)
|
||||
let resourceDecision: GatewayConfirmationRequiredPayload | undefined;
|
||||
if (isRecord(suspendPayload.resourceDecision)) {
|
||||
const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse(
|
||||
|
|
@ -312,8 +242,8 @@ export function mapMastraChunkToEvent(
|
|||
payload: {
|
||||
requestId,
|
||||
toolCallId,
|
||||
toolName: typeof payload.toolName === 'string' ? payload.toolName : '',
|
||||
args: isRecord(payload.args) ? payload.args : {},
|
||||
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : '',
|
||||
args: isRecord(chunk.input) ? chunk.input : {},
|
||||
severity,
|
||||
message:
|
||||
typeof suspendPayload.message === 'string'
|
||||
|
|
@ -335,8 +265,47 @@ export function mapMastraChunkToEvent(
|
|||
};
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
const errorInfo = extractErrorInfo(payload.error);
|
||||
if (chunk.type === 'message' && 'role' in chunk.message && chunk.message.role === 'tool') {
|
||||
const toolCall = chunk.message.content.find((part) => part.type === 'tool-call');
|
||||
if (toolCall?.type === 'tool-call') {
|
||||
return {
|
||||
type: 'tool-call',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId: typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : '',
|
||||
toolName: toolCall.toolName,
|
||||
args: isRecord(toolCall.input) ? toolCall.input : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const toolResult = chunk.message.content.find((part) => part.type === 'tool-result');
|
||||
if (toolResult?.type === 'tool-result') {
|
||||
if (toolResult.isError === true) {
|
||||
return {
|
||||
type: 'tool-error',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId: toolResult.toolCallId,
|
||||
error:
|
||||
typeof toolResult.result === 'string' ? toolResult.result : 'Tool execution failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool-result',
|
||||
...base,
|
||||
payload: {
|
||||
toolCallId: toolResult.toolCallId,
|
||||
result: toolResult.result,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.type === 'error') {
|
||||
const errorInfo = extractErrorInfo(chunk.error);
|
||||
return {
|
||||
type: 'error',
|
||||
...base,
|
||||
|
|
@ -349,97 +318,5 @@ export function mapMastraChunkToEvent(
|
|||
};
|
||||
}
|
||||
|
||||
// Other Mastra chunk types (step-finish, finish, etc.) are ignored
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapAgentChunkToEvent(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
chunk: unknown,
|
||||
responseId?: string,
|
||||
): InstanceAiEvent | null {
|
||||
if (!isAgentStreamChunk(chunk)) return null;
|
||||
|
||||
if (chunk.type === 'text-delta') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{ type: 'text-delta', payload: { text: chunk.delta } },
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
|
||||
if (chunk.type === 'reasoning-delta') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{ type: 'reasoning-delta', payload: { text: chunk.delta } },
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-call-suspended') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: isRecord(chunk.input) ? chunk.input : {},
|
||||
suspendPayload: isRecord(chunk.suspendPayload) ? chunk.suspendPayload : {},
|
||||
},
|
||||
},
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
|
||||
if (chunk.type === 'message' && 'role' in chunk.message && chunk.message.role === 'tool') {
|
||||
const toolCall = chunk.message.content.find((part) => part.type === 'tool-call');
|
||||
if (toolCall?.type === 'tool-call') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{
|
||||
type: 'tool-call',
|
||||
payload: {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
args: isRecord(toolCall.input) ? toolCall.input : {},
|
||||
},
|
||||
},
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
|
||||
const toolResult = chunk.message.content.find((part) => part.type === 'tool-result');
|
||||
if (toolResult?.type === 'tool-result') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{
|
||||
type: 'tool-result',
|
||||
payload: {
|
||||
toolCallId: toolResult.toolCallId,
|
||||
result: toolResult.result,
|
||||
isError: toolResult.isError,
|
||||
},
|
||||
},
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.type === 'error') {
|
||||
return mapMastraChunkToEvent(
|
||||
runId,
|
||||
agentId,
|
||||
{ type: 'error', payload: { error: chunk.error } },
|
||||
responseId,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): Record<st
|
|||
};
|
||||
}
|
||||
|
||||
// Convert MCP 'image' → Mastra 'media' (Mastra translates to 'image-data' for the provider)
|
||||
// Convert MCP image content into the native model-output media part.
|
||||
const value = raw.content.map((item) => {
|
||||
if (item.type === 'image') {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ async function handleTypeDefinition(
|
|||
context: InstanceAiContext,
|
||||
input: Extract<FullInput, { action: 'type-definition' }>,
|
||||
) {
|
||||
// Mastra validates against the flattened top-level schema (required for
|
||||
// Native tool validation uses the flattened top-level schema (required for
|
||||
// Anthropic's `type: "object"` constraint), which makes every variant field
|
||||
// optional. Re-assert the variant contract so missing/invalid inputs return
|
||||
// a structured error the model can self-correct from, instead of crashing
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* Write Sandbox File Tool
|
||||
*
|
||||
* Writes a file to the sandbox workspace. Uses command-based I/O so it works
|
||||
* with both Daytona and Local sandboxes (unlike Mastra's built-in write_file
|
||||
* which requires workspace.filesystem — absent on Daytona).
|
||||
* with both Daytona and Local sandboxes, including environments where only
|
||||
* command-based file I/O is available.
|
||||
*/
|
||||
|
||||
import { Tool } from '@n8n/agents';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { isRecord, parseSuspension, asResumable, resumeStream } from '../stream-helpers';
|
||||
import { isRecord, parseSuspension, asResumable, resumeAgentStream } from '../stream-helpers';
|
||||
|
||||
describe('isRecord', () => {
|
||||
it('returns true for plain objects', () => {
|
||||
|
|
@ -122,35 +122,25 @@ describe('parseSuspension', () => {
|
|||
|
||||
describe('asResumable', () => {
|
||||
it('casts agent to Resumable interface', () => {
|
||||
const agent = { resumeStream: jest.fn() };
|
||||
const agent = { resume: jest.fn() };
|
||||
const resumable = asResumable(agent);
|
||||
expect(resumable.resumeStream).toBe(agent.resumeStream);
|
||||
expect(resumable.resume).toBe(agent.resume);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeStream', () => {
|
||||
it('uses Mastra-style resumeStream when available', async () => {
|
||||
const resumed = { runId: 'run-2' };
|
||||
const agent = { resumeStream: jest.fn().mockResolvedValue(resumed) };
|
||||
|
||||
await expect(resumeStream(agent, { approved: true }, { runId: 'run-1' })).resolves.toBe(
|
||||
resumed,
|
||||
);
|
||||
expect(agent.resumeStream).toHaveBeenCalledWith({ approved: true }, { runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('uses native agent resume in stream mode when resumeStream is absent', async () => {
|
||||
describe('resumeAgentStream', () => {
|
||||
it('uses native agent resume in stream mode', async () => {
|
||||
const resumed = { runId: 'run-2' };
|
||||
const agent = { resume: jest.fn().mockResolvedValue(resumed) };
|
||||
|
||||
await expect(resumeStream(agent, { approved: true }, { runId: 'run-1' })).resolves.toBe(
|
||||
await expect(resumeAgentStream(agent, { approved: true }, { runId: 'run-1' })).resolves.toBe(
|
||||
resumed,
|
||||
);
|
||||
expect(agent.resume).toHaveBeenCalledWith('stream', { approved: true }, { runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('throws when the agent cannot resume streams', async () => {
|
||||
await expect(resumeStream({}, { approved: true }, { runId: 'run-1' })).rejects.toThrow(
|
||||
await expect(resumeAgentStream({}, { approved: true }, { runId: 'run-1' })).rejects.toThrow(
|
||||
'Agent does not support stream resume',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,12 +32,7 @@ export function parseSuspension(chunk: unknown): SuspensionInfo | null {
|
|||
return { toolCallId: tcId, requestId: reqId, toolName };
|
||||
}
|
||||
|
||||
/** Type for Mastra's resumeStream method (not exported by the framework). */
|
||||
export interface Resumable {
|
||||
resumeStream?: (
|
||||
data: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
resume?: (
|
||||
method: 'stream',
|
||||
data: Record<string, unknown>,
|
||||
|
|
@ -50,7 +45,7 @@ export function asResumable(agent: unknown): Resumable {
|
|||
return agent as Resumable;
|
||||
}
|
||||
|
||||
export async function resumeStream(
|
||||
export async function resumeAgentStream(
|
||||
agent: unknown,
|
||||
data: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
|
|
@ -61,10 +56,6 @@ export async function resumeStream(
|
|||
|
||||
const resumable = asResumable(agent);
|
||||
|
||||
if (typeof resumable.resumeStream === 'function') {
|
||||
return await resumable.resumeStream(data, options);
|
||||
}
|
||||
|
||||
if (typeof resumable.resume === 'function') {
|
||||
return await resumable.resume('stream', data, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ function makeAgentResult(
|
|||
overrides: Partial<{
|
||||
text: string;
|
||||
toolCalls: unknown[];
|
||||
toolResults: unknown[];
|
||||
finishReason: string;
|
||||
}> = {},
|
||||
) {
|
||||
const text = overrides.text ?? 'done';
|
||||
return {
|
||||
text: 'done',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
finishReason: 'stop',
|
||||
runId: 'agent-run-1',
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
|
||||
toolCalls: overrides.toolCalls ?? [],
|
||||
finishReason: overrides.finishReason ?? 'stop',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,9 +29,9 @@ jest.mock('@n8n/instance-ai', () => ({
|
|||
MAX_STEPS: { BUILDER: 60 },
|
||||
createSubAgent: jest.fn(() => ({
|
||||
generate: jest.fn().mockResolvedValue({
|
||||
text: 'done',
|
||||
runId: 'agent-run-1',
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
finishReason: 'stop',
|
||||
}),
|
||||
})),
|
||||
|
|
@ -132,7 +132,7 @@ describe('SubAgentEvalService', () => {
|
|||
expect(result.error).toMatch(/timed out/i);
|
||||
});
|
||||
|
||||
it('serializes mastra-shaped tool calls and results', async () => {
|
||||
it('serializes native tool calls and results', async () => {
|
||||
adapter.createContext.mockReturnValue({
|
||||
userId: user.id,
|
||||
workflowService: {
|
||||
|
|
@ -143,12 +143,18 @@ describe('SubAgentEvalService', () => {
|
|||
|
||||
const { createSubAgent } = jest.requireMock('@n8n/instance-ai');
|
||||
createSubAgent.mockReturnValue({
|
||||
generate: jest.fn(async () => ({
|
||||
text: 'ok',
|
||||
toolCalls: [{ payload: { toolName: 'nodes', args: { action: 'list' } } }],
|
||||
toolResults: [{ payload: { toolName: 'nodes', result: { success: true, items: [] } } }],
|
||||
finishReason: 'stop',
|
||||
})),
|
||||
generate: jest.fn(async () =>
|
||||
makeAgentResult({
|
||||
text: 'ok',
|
||||
toolCalls: [
|
||||
{
|
||||
tool: 'nodes',
|
||||
input: { action: 'list' },
|
||||
output: { success: true, items: [] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await service.run(user, { role: 'builder', prompt: 'inspect' });
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export type LocalGatewayEvent = LocalGatewayRequestEvent | LocalGatewayDisconnec
|
|||
* 5. resolveRequest() resolves the pending promise → caller gets McpToolCallResult
|
||||
*
|
||||
* Resource-access confirmations (GATEWAY_CONFIRMATION_REQUIRED) are handled at the
|
||||
* tool layer via Mastra's suspend()/resumeData mechanism — not here.
|
||||
* tool layer via native agents suspend/resume data — not here.
|
||||
*/
|
||||
export class LocalGateway {
|
||||
private readonly pendingRequests = new Map<string, PendingRequest>();
|
||||
|
|
@ -126,7 +126,7 @@ export class LocalGateway {
|
|||
|
||||
// Resolve with the result as-is (including isError responses) so the tool
|
||||
// layer (create-tools-from-mcp-server.ts) can inspect GATEWAY_CONFIRMATION_REQUIRED
|
||||
// errors and handle them via Mastra suspend().
|
||||
// errors and handle them via native tool suspension.
|
||||
pending.resolve(result ?? { content: [] });
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ const ADMIN_SETTINGS_KEY = 'instanceAi.settings';
|
|||
|
||||
type UserInstanceAiPreferences = NonNullable<IUserSettings['instanceAi']>;
|
||||
|
||||
/** Credential types we support and their Mastra provider mapping. */
|
||||
const CREDENTIAL_TO_MASTRA_PROVIDER: Record<string, string> = {
|
||||
/** Credential types we support and their model provider mapping. */
|
||||
const CREDENTIAL_TO_MODEL_PROVIDER: Record<string, string> = {
|
||||
openAiApi: 'openai',
|
||||
anthropicApi: 'anthropic',
|
||||
googlePalmApi: 'google',
|
||||
|
|
@ -40,7 +40,7 @@ const CREDENTIAL_TO_MASTRA_PROVIDER: Record<string, string> = {
|
|||
cohereApi: 'cohere',
|
||||
};
|
||||
|
||||
const SUPPORTED_CREDENTIAL_TYPES = Object.keys(CREDENTIAL_TO_MASTRA_PROVIDER);
|
||||
const SUPPORTED_CREDENTIAL_TYPES = Object.keys(CREDENTIAL_TO_MODEL_PROVIDER);
|
||||
|
||||
/** Fields that contain the base URL per credential type. */
|
||||
const URL_FIELD_MAP: Record<string, string> = {
|
||||
|
|
@ -274,7 +274,7 @@ export class InstanceAiSettingsService {
|
|||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
provider: CREDENTIAL_TO_MASTRA_PROVIDER[c.type] ?? 'custom',
|
||||
provider: CREDENTIAL_TO_MODEL_PROVIDER[c.type] ?? 'custom',
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -434,7 +434,7 @@ export class InstanceAiSettingsService {
|
|||
return this.envVarModelConfig();
|
||||
}
|
||||
|
||||
const provider = CREDENTIAL_TO_MASTRA_PROVIDER[credential.type];
|
||||
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credential.type];
|
||||
if (!provider) {
|
||||
return this.envVarModelConfig();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ export class InstanceAiController {
|
|||
}
|
||||
|
||||
// Exclude snapshots for active/suspended runs — they have no matching
|
||||
// assistant message in Mastra memory yet and would misalign the
|
||||
// assistant message in native memory yet and would misalign the
|
||||
// positional snapshot-to-message matching in parseStoredMessages.
|
||||
const threadStatus = this.instanceAiService.getThreadStatus(threadId);
|
||||
const activeRunId = this.instanceAiService.getActiveRunId(threadId);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user