mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(ai-builder): Preserve collected planning context (#29916)
This commit is contained in:
parent
55df7cbd06
commit
5e3aa1a726
|
|
@ -11,6 +11,8 @@ import type { InstanceAiContext } from '../types';
|
|||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CREDENTIALS_TOOL_ID = 'credentials';
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
/** Generic auth types that should be excluded from search results — the AI should prefer dedicated types. */
|
||||
|
|
@ -340,7 +342,7 @@ async function handleTest(context: InstanceAiContext, input: Extract<Input, { ac
|
|||
|
||||
export function createCredentialsTool(context: InstanceAiContext) {
|
||||
return createTool({
|
||||
id: 'credentials',
|
||||
id: CREDENTIALS_TOOL_ID,
|
||||
description:
|
||||
'Manage credentials — list, get, delete, search available types, set up new credentials, and test connections.',
|
||||
inputSchema,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type { InstanceAiContext } from '../types';
|
|||
|
||||
// ── Shared schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
export const DATA_TABLES_TOOL_ID = 'data-tables';
|
||||
|
||||
const columnTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
|
||||
|
||||
const filterSchema = z.object({
|
||||
|
|
@ -597,7 +599,7 @@ export function createDataTablesTool(
|
|||
const inputSchema = sanitizeInputSchema(z.discriminatedUnion('action', [...readOnlyActions]));
|
||||
|
||||
return createTool({
|
||||
id: 'data-tables',
|
||||
id: DATA_TABLES_TOOL_ID,
|
||||
description: 'Manage data tables — list, get schema, and query rows.',
|
||||
inputSchema,
|
||||
execute: async (input: ReadOnlyInput) => {
|
||||
|
|
@ -616,7 +618,7 @@ export function createDataTablesTool(
|
|||
const inputSchema = sanitizeInputSchema(z.discriminatedUnion('action', [...allActions]));
|
||||
|
||||
return createTool({
|
||||
id: 'data-tables',
|
||||
id: DATA_TABLES_TOOL_ID,
|
||||
description: 'Manage data tables — list, query, create, modify columns, and manage rows.',
|
||||
inputSchema,
|
||||
suspendSchema: confirmationSuspendSchema,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ jest.mock('@mastra/core/tools', () => ({
|
|||
|
||||
import type { OrchestrationContext, PlannedTaskGraph, PlannedTaskService } from '../../../types';
|
||||
|
||||
const { __testClearPlannedTaskGraph, __testFormatMessagesForBriefing } =
|
||||
const {
|
||||
__testBuildPlannerBriefingContext,
|
||||
__testClearPlannedTaskGraph,
|
||||
__testFormatMessagesForBriefing,
|
||||
__testGetRecentMessages,
|
||||
__testGetPriorToolObservations,
|
||||
} =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../plan-with-agent.tool') as typeof import('../plan-with-agent.tool');
|
||||
|
||||
|
|
@ -130,4 +136,263 @@ describe('formatMessagesForBriefing', () => {
|
|||
expect(briefing).toMatch(/<current-datetime>[^<]+<\/current-datetime>/);
|
||||
expect(briefing).not.toContain('<user-timezone>');
|
||||
});
|
||||
|
||||
it('renders already-collected answers and discovered resources as dedicated sections', () => {
|
||||
const briefing = __testFormatMessagesForBriefing(
|
||||
[{ role: 'user', content: 'Build a Slack to-do agent' }],
|
||||
undefined,
|
||||
'America/New_York',
|
||||
{
|
||||
collectedAnswers: [
|
||||
'How often should the agent run?: Every morning',
|
||||
'Credential selected for slackApi: Slack account (slackApi)',
|
||||
],
|
||||
discoveredResources: ['Credentials available: Slack account (slackApi)'],
|
||||
},
|
||||
);
|
||||
|
||||
expect(briefing).toContain('## Already-collected answers');
|
||||
expect(briefing).toContain('- How often should the agent run?: Every morning');
|
||||
expect(briefing).toContain('- Credential selected for slackApi: Slack account (slackApi)');
|
||||
expect(briefing).toContain('## Already-discovered resources');
|
||||
expect(briefing).toContain('- Credentials available: Slack account (slackApi)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlannerBriefingContext', () => {
|
||||
it('extracts ask-user answers and credential selections from prior tool results', () => {
|
||||
const context = __testBuildPlannerBriefingContext([
|
||||
{
|
||||
toolName: 'credentials',
|
||||
args: { action: 'list' },
|
||||
result: {
|
||||
credentials: [
|
||||
{ id: 'cred-slack', name: 'Slack account', type: 'slackApi' },
|
||||
{ id: 'cred-anthropic', name: 'Anthropic account', type: 'anthropicApi' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
toolName: 'ask-user',
|
||||
args: {
|
||||
questions: [
|
||||
{
|
||||
id: 'schedule',
|
||||
question: 'How often should the agent run?',
|
||||
type: 'single',
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
answered: true,
|
||||
answers: [
|
||||
{
|
||||
questionId: 'schedule',
|
||||
selectedOptions: ['Every morning'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
toolName: 'credentials',
|
||||
args: { action: 'setup' },
|
||||
result: {
|
||||
success: true,
|
||||
credentials: { slackApi: 'cred-slack' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(context.collectedAnswers).toEqual([
|
||||
'How often should the agent run?: Every morning',
|
||||
'Credential selected for slackApi: Slack account (slackApi)',
|
||||
]);
|
||||
expect(context.discoveredResources).toEqual([
|
||||
'Credentials available: Slack account (slackApi), Anthropic account (anthropicApi)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores unanswered and skipped ask-user answers', () => {
|
||||
const context = __testBuildPlannerBriefingContext([
|
||||
{
|
||||
toolName: 'ask-user',
|
||||
args: {
|
||||
questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }],
|
||||
},
|
||||
result: {
|
||||
answered: false,
|
||||
answers: [
|
||||
{
|
||||
questionId: 'purpose',
|
||||
customText: 'This should not be used',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
toolName: 'ask-user',
|
||||
args: {
|
||||
questions: [
|
||||
{ id: 'schedule', question: 'How often should it run?', type: 'single' },
|
||||
{ id: 'model', question: 'Which model should it use?', type: 'single' },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
answered: true,
|
||||
answers: [
|
||||
{
|
||||
questionId: 'schedule',
|
||||
selectedOptions: ['Every morning'],
|
||||
skipped: true,
|
||||
},
|
||||
{
|
||||
questionId: 'model',
|
||||
selectedOptions: ['Anthropic'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(context.collectedAnswers).toEqual(['Which model should it use?: Anthropic']);
|
||||
expect(context.discoveredResources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriorToolObservations', () => {
|
||||
it('reads tool results across the current message group when available', () => {
|
||||
const askUserCall = {
|
||||
questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }],
|
||||
};
|
||||
const askUserResult = {
|
||||
answered: true,
|
||||
answers: [
|
||||
{ questionId: 'purpose', question: 'What should this do?', customText: 'Email me' },
|
||||
],
|
||||
};
|
||||
const getEventsForRun = jest.fn().mockReturnValue([]);
|
||||
const getEventsForRuns = jest.fn().mockReturnValue([
|
||||
{
|
||||
type: 'tool-call',
|
||||
runId: 'run-prior',
|
||||
agentId: 'orchestrator',
|
||||
payload: {
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'ask-user',
|
||||
args: askUserCall,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
runId: 'run-prior',
|
||||
agentId: 'orchestrator',
|
||||
payload: {
|
||||
toolCallId: 'tool-1',
|
||||
result: askUserResult,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const context = {
|
||||
threadId: 'thread-1',
|
||||
runId: 'run-current',
|
||||
messageGroupId: 'message-group-1',
|
||||
eventBus: {
|
||||
getEventsAfter: jest.fn().mockReturnValue([
|
||||
{
|
||||
id: 1,
|
||||
event: {
|
||||
type: 'run-start',
|
||||
runId: 'run-prior',
|
||||
agentId: 'orchestrator',
|
||||
payload: { messageId: 'message-1', messageGroupId: 'message-group-1' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event: {
|
||||
type: 'run-start',
|
||||
runId: 'run-other',
|
||||
agentId: 'orchestrator',
|
||||
payload: { messageId: 'message-2', messageGroupId: 'message-group-2' },
|
||||
},
|
||||
},
|
||||
]),
|
||||
getEventsForRuns,
|
||||
getEventsForRun,
|
||||
},
|
||||
} as unknown as OrchestrationContext;
|
||||
|
||||
const observations = __testGetPriorToolObservations(context);
|
||||
|
||||
expect(getEventsForRuns).toHaveBeenCalledWith('thread-1', ['run-prior', 'run-current']);
|
||||
expect(getEventsForRun).not.toHaveBeenCalled();
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
toolName: 'ask-user',
|
||||
args: askUserCall,
|
||||
result: askUserResult,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('pairs out-of-order tool results with their later tool calls', () => {
|
||||
const args = { action: 'list' };
|
||||
const result = { credentials: [{ id: 'cred-1', name: 'Slack', type: 'slackApi' }] };
|
||||
const context = {
|
||||
threadId: 'thread-1',
|
||||
runId: 'run-current',
|
||||
eventBus: {
|
||||
getEventsForRun: jest.fn().mockReturnValue([
|
||||
{
|
||||
type: 'tool-result',
|
||||
runId: 'run-current',
|
||||
agentId: 'orchestrator',
|
||||
payload: { toolCallId: 'tool-1', result },
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
runId: 'run-current',
|
||||
agentId: 'orchestrator',
|
||||
payload: { toolCallId: 'tool-1', toolName: 'credentials', args },
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as OrchestrationContext;
|
||||
|
||||
expect(__testGetPriorToolObservations(context)).toEqual([
|
||||
{ toolName: 'credentials', args, result },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no observations when event lookup fails', () => {
|
||||
const context = {
|
||||
threadId: 'thread-1',
|
||||
runId: 'run-current',
|
||||
eventBus: {
|
||||
getEventsForRun: jest.fn(() => {
|
||||
throw new Error('storage unavailable');
|
||||
}),
|
||||
},
|
||||
} as unknown as OrchestrationContext;
|
||||
|
||||
expect(__testGetPriorToolObservations(context)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentMessages', () => {
|
||||
it('does not append the current user message when memory already returned it', async () => {
|
||||
const context = {
|
||||
threadId: 't-1',
|
||||
currentUserMessage: 'Build a Slack to-do agent',
|
||||
memory: {
|
||||
recall: jest.fn().mockResolvedValue({
|
||||
messages: [{ role: 'user', content: 'Build a Slack to-do agent' }],
|
||||
}),
|
||||
},
|
||||
} as unknown as OrchestrationContext;
|
||||
|
||||
const messages = await __testGetRecentMessages(context, 5);
|
||||
|
||||
expect(messages).toEqual([{ role: 'user', content: 'Build a Slack to-do agent' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ ${SUBAGENT_OUTPUT_CONTRACT}
|
|||
- **Never ask about things you can discover** — call \`credentials(action="list")\`, \`data-tables(action="list")\`, \`templates(action="best-practices")\` instead.
|
||||
- **Never ask about implementation details** — trigger types, node choices, schedule times, column names. Pick sensible defaults.
|
||||
- **Never default resource identifiers** the user didn't mention (Slack channels, calendars, spreadsheets, folders, etc.) — leave them for the builder to resolve at build time.
|
||||
- **Trust already-collected briefing context** — if the briefing includes an Already-collected answers or Already-discovered resources section, treat those entries as authoritative. Do not ask again for purpose, trigger, integrations, schedule, model, resource, or credential choices already listed there.
|
||||
- **Do ask when the answer would significantly change the plan** — e.g. the user's goal is ambiguous ("build me a CRM" — for sales? support? recruiting?), or a business rule must come from the user ("what should happen when payment fails?").
|
||||
- **Do ask when a required service has more than one credential of the same type** (e.g. two \`openAiApi\` accounts, three Google Calendar accounts) — which one to use cannot be discovered, only chosen. Record the chosen credential name in \`assumptions\`.
|
||||
- **List your assumptions** on your first \`add-plan-item\` call. The user reviews the plan before execution and can reject/correct.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import { Agent } from '@mastra/core/agent';
|
||||
import type { ToolsInput } from '@mastra/core/agent';
|
||||
import { createTool } from '@mastra/core/tools';
|
||||
import type { InstanceAiEvent } from '@n8n/api-types';
|
||||
import { DateTime } from 'luxon';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -37,6 +38,9 @@ import { createLlmStepTraceHooks } from '../../runtime/resumable-stream-executor
|
|||
import { consumeStreamWithHitl } from '../../stream/consume-with-hitl';
|
||||
import { getTraceParentRun, withTraceParentContext } from '../../tracing/langsmith-tracing';
|
||||
import type { OrchestrationContext } from '../../types';
|
||||
import { CREDENTIALS_TOOL_ID } from '../credentials.tool';
|
||||
import { DATA_TABLES_TOOL_ID } from '../data-tables.tool';
|
||||
import { ASK_USER_TOOL_ID } from '../shared/ask-user.tool';
|
||||
import { createTemplatesTool } from '../templates.tool';
|
||||
|
||||
/** Number of recent thread messages to include as planner context. */
|
||||
|
|
@ -48,15 +52,43 @@ const PLANNER_DOMAIN_TOOL_NAMES = ['nodes', 'credentials', 'data-tables', 'workf
|
|||
/** Research tools added when available. */
|
||||
const PLANNER_RESEARCH_TOOL_NAMES = ['research'];
|
||||
|
||||
const RELEVANT_PRIOR_TOOL_NAMES = new Set([
|
||||
ASK_USER_TOOL_ID,
|
||||
CREDENTIALS_TOOL_ID,
|
||||
DATA_TABLES_TOOL_ID,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message history retrieval
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FormattedMessage {
|
||||
role: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface PlannerBriefingContext {
|
||||
collectedAnswers: string[];
|
||||
discoveredResources: string[];
|
||||
}
|
||||
|
||||
interface ToolObservation {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
interface CredentialBrief {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface DataTableBrief {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Extract plain text from Mastra memory content (string, array of parts, or {format, parts}). */
|
||||
function extractTextFromMemoryContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
|
|
@ -93,6 +125,38 @@ function extractTextParts(parts: unknown[]): string {
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return readArray(value).filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function addUnique(target: string[], seen: Set<string>, value: string | undefined): void {
|
||||
if (!value || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
target.push(value);
|
||||
}
|
||||
|
||||
function summarizeList(values: string[], limit = 10): string {
|
||||
const visible = values.slice(0, limit).join(', ');
|
||||
const remaining = values.length - limit;
|
||||
return remaining > 0 ? `${visible}, and ${remaining} more` : visible;
|
||||
}
|
||||
|
||||
async function getRecentMessages(
|
||||
context: OrchestrationContext,
|
||||
count: number,
|
||||
|
|
@ -120,17 +184,343 @@ async function getRecentMessages(
|
|||
}
|
||||
|
||||
// Always append the current in-flight user message (not yet saved to memory)
|
||||
if (context.currentUserMessage) {
|
||||
if (shouldAppendCurrentUserMessage(messages, context.currentUserMessage)) {
|
||||
messages.push({ role: 'user', content: context.currentUserMessage });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function shouldAppendCurrentUserMessage(
|
||||
messages: FormattedMessage[],
|
||||
currentUserMessage?: string,
|
||||
): currentUserMessage is string {
|
||||
const current = currentUserMessage?.trim();
|
||||
if (!current) return false;
|
||||
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user');
|
||||
return lastUserMessage?.content.trim() !== current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs prior planner-relevant tool calls from the event stream.
|
||||
*
|
||||
* Tool-call and tool-result events are correlated by `toolCallId` so the
|
||||
* planner can receive structured context that is not preserved in text-only
|
||||
* memory recall, such as ask-user answers and credential selections.
|
||||
*/
|
||||
function getPriorToolObservations(context: OrchestrationContext): ToolObservation[] {
|
||||
type MutableToolObservation = Omit<ToolObservation, 'result'> & {
|
||||
result: unknown;
|
||||
hasResult: boolean;
|
||||
};
|
||||
|
||||
const toolCalls = new Map<string, MutableToolObservation>();
|
||||
const pendingResults = new Map<string, unknown>();
|
||||
|
||||
for (const event of getPriorToolEvents(context)) {
|
||||
if (event.type === 'tool-call') {
|
||||
const { toolCallId, toolName, args } = event.payload;
|
||||
if (!RELEVANT_PRIOR_TOOL_NAMES.has(toolName)) continue;
|
||||
|
||||
const pendingResult = pendingResults.get(toolCallId);
|
||||
toolCalls.set(toolCallId, {
|
||||
toolName,
|
||||
args,
|
||||
result: pendingResult,
|
||||
hasResult: pendingResults.has(toolCallId),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'tool-result') {
|
||||
const { toolCallId, result } = event.payload;
|
||||
const existing = toolCalls.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.result = result;
|
||||
existing.hasResult = true;
|
||||
} else {
|
||||
pendingResults.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...toolCalls.values()]
|
||||
.filter((observation) => observation.hasResult)
|
||||
.map(({ toolName, args, result }) => ({ toolName, args, result }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the events that may contain prior tool context for this planner run.
|
||||
*
|
||||
* When the run belongs to a message group, all runs in that group are searched
|
||||
* so follow-up runs can see choices collected earlier in the same assistant
|
||||
* turn. If grouped lookup is unavailable, this falls back to the current run.
|
||||
*/
|
||||
function getPriorToolEvents(context: OrchestrationContext): InstanceAiEvent[] {
|
||||
if (context.messageGroupId) {
|
||||
const runIds = getMessageGroupRunIds(context);
|
||||
if (runIds.length > 0) {
|
||||
try {
|
||||
return context.eventBus.getEventsForRuns(context.threadId, runIds);
|
||||
} catch {
|
||||
// Fall back to the current run below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return context.eventBus.getEventsForRun(context.threadId, context.runId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds run IDs that belong to the current message group from run-start events.
|
||||
*
|
||||
* The event bus can fetch events for many run IDs, but the orchestration
|
||||
* context only carries the current run ID and message group ID. This bridges
|
||||
* those two concepts while keeping the current run as a defensive fallback.
|
||||
*/
|
||||
function getMessageGroupRunIds(context: OrchestrationContext): string[] {
|
||||
const messageGroupId = context.messageGroupId;
|
||||
if (!messageGroupId) return [];
|
||||
|
||||
const runIds = new Set<string>();
|
||||
try {
|
||||
for (const { event } of context.eventBus.getEventsAfter(context.threadId, 0)) {
|
||||
if (event.type === 'run-start' && event.payload.messageGroupId === messageGroupId) {
|
||||
runIds.add(event.runId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [context.runId];
|
||||
}
|
||||
runIds.add(context.runId);
|
||||
|
||||
return [...runIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts raw prior tool observations into planner briefing sections.
|
||||
*
|
||||
* The resulting strings are intentionally short and human-readable because
|
||||
* they are embedded directly into the planner prompt under dedicated headings.
|
||||
*/
|
||||
function buildPlannerBriefingContext(observations: ToolObservation[]): PlannerBriefingContext {
|
||||
const collectedAnswers: string[] = [];
|
||||
const discoveredResources: string[] = [];
|
||||
const seenAnswers = new Set<string>();
|
||||
const seenResources = new Set<string>();
|
||||
const credentialsById = buildCredentialLookup(observations);
|
||||
|
||||
for (const observation of observations) {
|
||||
if (observation.toolName === ASK_USER_TOOL_ID) {
|
||||
for (const answer of extractAskUserAnswerLines(observation)) {
|
||||
addUnique(collectedAnswers, seenAnswers, answer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (observation.toolName === CREDENTIALS_TOOL_ID) {
|
||||
const action = readString(observation.args.action);
|
||||
if (action === 'list') {
|
||||
addUnique(discoveredResources, seenResources, summarizeCredentials(observation.result));
|
||||
}
|
||||
if (action === 'setup') {
|
||||
for (const selection of extractCredentialSelectionLines(observation, credentialsById)) {
|
||||
addUnique(collectedAnswers, seenAnswers, selection);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
observation.toolName === DATA_TABLES_TOOL_ID &&
|
||||
readString(observation.args.action) === 'list'
|
||||
) {
|
||||
addUnique(discoveredResources, seenResources, summarizeDataTables(observation.result));
|
||||
}
|
||||
}
|
||||
|
||||
return { collectedAnswers, discoveredResources };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ID lookup from prior credential list results.
|
||||
*
|
||||
* Credential setup results contain selected IDs, so this lets the briefing
|
||||
* render stable user-facing names and credential types when a prior list result
|
||||
* is available.
|
||||
*/
|
||||
function buildCredentialLookup(observations: ToolObservation[]): Map<string, CredentialBrief> {
|
||||
const credentialsById = new Map<string, CredentialBrief>();
|
||||
|
||||
for (const observation of observations) {
|
||||
if (observation.toolName !== CREDENTIALS_TOOL_ID) continue;
|
||||
for (const credential of extractCredentials(observation.result)) {
|
||||
if (credential.id) credentialsById.set(credential.id, credential);
|
||||
}
|
||||
}
|
||||
|
||||
return credentialsById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts answered ask-user responses as `question: answer` briefing lines.
|
||||
*
|
||||
* Skipped or unanswered prompts are ignored, and question text is recovered
|
||||
* from tool args when the tool result only includes a question ID.
|
||||
*/
|
||||
function extractAskUserAnswerLines(observation: ToolObservation): string[] {
|
||||
const result = readRecord(observation.result);
|
||||
if (!result || result.answered === false) return [];
|
||||
|
||||
const questionsById = extractQuestionTextById(observation.args);
|
||||
const answers = readArray(result.answers);
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const answerValue of answers) {
|
||||
const answer = readRecord(answerValue);
|
||||
if (!answer || answer.skipped === true) continue;
|
||||
|
||||
const questionId = readString(answer.questionId);
|
||||
const question =
|
||||
readString(answer.question) ?? (questionId ? questionsById.get(questionId) : undefined);
|
||||
const selectedOptions = readStringArray(answer.selectedOptions);
|
||||
const customText = readString(answer.customText);
|
||||
const values = [...selectedOptions, ...(customText ? [customText] : [])];
|
||||
|
||||
if (!question || values.length === 0) continue;
|
||||
lines.push(`${question}: ${values.join(', ')}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps ask-user question IDs to display text from the original tool args.
|
||||
*/
|
||||
function extractQuestionTextById(args: Record<string, unknown>): Map<string, string> {
|
||||
const questionsById = new Map<string, string>();
|
||||
|
||||
for (const questionValue of readArray(args.questions)) {
|
||||
const question = readRecord(questionValue);
|
||||
const id = readString(question?.id);
|
||||
const text = readString(question?.question);
|
||||
if (id && text) questionsById.set(id, text);
|
||||
}
|
||||
|
||||
return questionsById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders credential setup selections as briefing lines.
|
||||
*
|
||||
* The setup tool returns a `{ credentialType: credentialId }` map. The optional
|
||||
* credential lookup turns those IDs back into names so the planner can avoid
|
||||
* asking the user to choose the same credential again.
|
||||
*/
|
||||
function extractCredentialSelectionLines(
|
||||
observation: ToolObservation,
|
||||
credentialsById: Map<string, CredentialBrief>,
|
||||
): string[] {
|
||||
const result = readRecord(observation.result);
|
||||
const credentials = readRecord(result?.credentials);
|
||||
if (!credentials) return [];
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [credentialType, credentialIdValue] of Object.entries(credentials)) {
|
||||
const credentialId = readString(credentialIdValue);
|
||||
if (!credentialId) continue;
|
||||
|
||||
const credential = credentialsById.get(credentialId);
|
||||
const label = credential
|
||||
? `${credential.name} (${credential.type})`
|
||||
: `credential ID ${credentialId}`;
|
||||
lines.push(`Credential selected for ${credentialType}: ${label}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes a credentials list result for the briefing.
|
||||
*/
|
||||
function summarizeCredentials(result: unknown): string | undefined {
|
||||
const credentials = extractCredentials(result);
|
||||
if (credentials.length === 0) return undefined;
|
||||
|
||||
return `Credentials available: ${summarizeList(
|
||||
credentials.map((credential) => `${credential.name} (${credential.type})`),
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the minimal credential metadata needed by the planner briefing.
|
||||
*/
|
||||
function extractCredentials(result: unknown): CredentialBrief[] {
|
||||
const record = readRecord(result);
|
||||
return readArray(record?.credentials)
|
||||
.map(readCredentialBrief)
|
||||
.filter((credential): credential is CredentialBrief => credential !== undefined);
|
||||
}
|
||||
|
||||
function readCredentialBrief(value: unknown): CredentialBrief | undefined {
|
||||
const record = readRecord(value);
|
||||
const name = readString(record?.name);
|
||||
const type = readString(record?.type);
|
||||
if (!name || !type) return undefined;
|
||||
const id = readString(record?.id);
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
...(id ? { id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes a data-tables list result for the briefing.
|
||||
*/
|
||||
function summarizeDataTables(result: unknown): string | undefined {
|
||||
const tables = extractDataTables(result);
|
||||
if (tables.length === 0) return undefined;
|
||||
|
||||
return `Data tables available: ${summarizeList(tables.map((table) => table.name))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the minimal data-table metadata needed by the planner briefing.
|
||||
*/
|
||||
function extractDataTables(result: unknown): DataTableBrief[] {
|
||||
const record = readRecord(result);
|
||||
return readArray(record?.tables)
|
||||
.map(readDataTableBrief)
|
||||
.filter((table): table is DataTableBrief => table !== undefined);
|
||||
}
|
||||
|
||||
function readDataTableBrief(value: unknown): DataTableBrief | undefined {
|
||||
const record = readRecord(value);
|
||||
const name = readString(record?.name);
|
||||
if (!name) return undefined;
|
||||
const id = readString(record?.id);
|
||||
|
||||
return {
|
||||
name,
|
||||
...(id ? { id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats conversation, time, and already-collected context into the planner goal.
|
||||
*/
|
||||
function formatMessagesForBriefing(
|
||||
messages: FormattedMessage[],
|
||||
guidance?: string,
|
||||
timeZone?: string,
|
||||
briefingContext?: PlannerBriefingContext,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
|
@ -151,6 +541,20 @@ function formatMessagesForBriefing(
|
|||
}
|
||||
}
|
||||
|
||||
if (briefingContext?.collectedAnswers.length) {
|
||||
parts.push('## Already-collected answers');
|
||||
for (const answer of briefingContext.collectedAnswers) {
|
||||
parts.push(`- ${answer}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (briefingContext?.discoveredResources.length) {
|
||||
parts.push('## Already-discovered resources');
|
||||
for (const resource of briefingContext.discoveredResources) {
|
||||
parts.push(`- ${resource}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (guidance) {
|
||||
parts.push(`\n## Orchestrator guidance\n${guidance}`);
|
||||
}
|
||||
|
|
@ -161,6 +565,9 @@ function formatMessagesForBriefing(
|
|||
}
|
||||
|
||||
export const __testFormatMessagesForBriefing = formatMessagesForBriefing;
|
||||
export const __testGetRecentMessages = getRecentMessages;
|
||||
export const __testGetPriorToolObservations = getPriorToolObservations;
|
||||
export const __testBuildPlannerBriefingContext = buildPlannerBriefingContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: clear draft checklist from taskStorage
|
||||
|
|
@ -268,7 +675,13 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
|
|||
|
||||
// ── Retrieve conversation history ─────────────────────────────
|
||||
const messages = await getRecentMessages(context, MESSAGE_HISTORY_COUNT);
|
||||
const briefing = formatMessagesForBriefing(messages, input.guidance, context.timeZone);
|
||||
const briefingContext = buildPlannerBriefingContext(getPriorToolObservations(context));
|
||||
const briefing = formatMessagesForBriefing(
|
||||
messages,
|
||||
input.guidance,
|
||||
context.timeZone,
|
||||
briefingContext,
|
||||
);
|
||||
|
||||
// ── IDs & events ──────────────────────────────────────────────
|
||||
const subAgentId = `agent-planner-${nanoid(6)}`;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { createTool } from '@mastra/core/tools';
|
|||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ASK_USER_TOOL_ID = 'ask-user';
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string().describe('Unique question identifier'),
|
||||
question: z.string().describe('The question text to display to the user'),
|
||||
|
|
@ -36,7 +38,7 @@ export const askUserResumeSchema = z.object({
|
|||
|
||||
export function createAskUserTool() {
|
||||
return createTool({
|
||||
id: 'ask-user',
|
||||
id: ASK_USER_TOOL_ID,
|
||||
description:
|
||||
'Ask the user one or more structured questions. Each question can be ' +
|
||||
'single-select (pick one), multi-select (pick many), or free-text. ' +
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user