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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export interface ServerWorkflowTaskBase extends ServerWorkflowStepBase {

export interface ServerWorkflowTaskGuideline extends ServerWorkflowTaskBase {
taskType: ServerTaskTypeEnum.Guideline;
executionType: ServerStepExecutionTypeEnum.Manual;
// AI modes only for a user-input guidance; simple-completion is always Manual (parser-enforced).
executionType: ServerStepExecutionTypeEnum;
completionType: 'simple' | 'user-input';
inputType?: 'free-text';
automaticCompletion: false;
Expand Down
20 changes: 20 additions & 0 deletions packages/workflow-executor/src/executors/base-step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import { SystemMessage } from '@forestadmin/ai-proxy';

import {
AiAssistUnavailableError,
AiInvokeTimeoutError,
InvalidAiRequestError,
MalformedToolCallError,
Expand Down Expand Up @@ -365,6 +366,25 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
return (await this.invokeWithTools<T>(messages, [tool])).args;
}

// Tags any AI-call failure as AiAssistUnavailableError so callers can degrade to Manual;
// an already-tagged error passes through (no double-wrapping when calls nest).
protected async withAiAssist<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (error) {
if (error instanceof AiAssistUnavailableError) throw error;
throw new AiAssistUnavailableError(error);
}
}

protected logAiDegrade(reason: unknown): void {
this.context.logger(
'Warn',
`${this.context.stepDefinition.type}: AI unavailable, degrading to manual`,
{ ...this.logCtx, error: extractErrorMessage(reason) },
);
}

// Overridden by executors that carry type-specific log identifiers (e.g. McpStepExecutor).
protected getExtraLogContext(): Record<string, unknown> {
return {};
Expand Down
134 changes: 131 additions & 3 deletions packages/workflow-executor/src/executors/guidance-step-executor.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,81 @@
import type { StepExecutionResult } from '../types/execution-context';
import type { GuidanceStepExecutionData } from '../types/step-execution-data';
import type { GuidanceStepDefinition } from '../types/validated/step-definition';
import type { RecordStepStatus } from '../types/validated/step-outcome';

import { StepStateError } from '../errors';
import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy';
import { z } from 'zod';

import { AiAssistUnavailableError, StepStateError } from '../errors';
import BaseStepExecutor from './base-step-executor';
import patchBodySchemas from '../http/pending-data-validators';
import { StepExecutionMode } from '../types/validated/step-definition';

const GUIDANCE_RESPONSE_SYSTEM_PROMPT = `You are completing a free-text response step in a business workflow on behalf of the operator.
Write the response the operator would type, using the step instructions and the workflow context (trigger record, previous steps).
- Answer the instructions directly; do not narrate what you are doing or address the operator.
- Keep a reasonable length: a few sentences unless the instructions clearly require more.
- Use only facts available in the context — never invent names, dates, amounts or identifiers.
- Plain text only (no markdown headings or code blocks).`;

export default class GuidanceStepExecutor extends BaseStepExecutor<GuidanceStepDefinition> {
protected async doExecute(): Promise<StepExecutionResult> {
const { incomingPendingData } = this.context;
const { incomingPendingData, stepDefinition } = this.context;

// Submit path (front POST) — identical in all modes, never calls the AI.
if (incomingPendingData) return this.saveSubmission(incomingPendingData);

if (!incomingPendingData) {
if (stepDefinition.executionType === StepExecutionMode.Manual) {
return this.buildOutcomeResult({ status: 'awaiting-input' });
}

// Never re-run the AI on re-dispatch of the same (runId, stepIndex): replay the stored result.
const existing = await this.findGuidanceExecution();
if (existing?.executionResult) return this.buildOutcomeResult({ status: 'success' });
if (existing?.pendingData) return this.buildOutcomeResult({ status: 'awaiting-input' });

let draft: string;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

try {
draft = await this.askAiForResponse();
} catch (error) {
if (!(error instanceof AiAssistUnavailableError)) throw error;
this.logAiDegrade(error.reason);

return this.degradeToManual();
}

// An empty draft is not a submittable answer (even in Full AI) → degrade to manual input.
if (!draft.trim()) {
this.context.logger(
'Warn',
`${this.context.stepDefinition.type}: AI returned an empty response, degrading to manual`,
this.logCtx,
);

return this.degradeToManual();
}

if (stepDefinition.executionType === StepExecutionMode.FullyAutomated) {
await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'guidance',
stepIndex: this.context.stepIndex,
executionResult: { userInput: draft, generatedByAi: true },
});

return this.buildOutcomeResult({ status: 'success' });
}

await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'guidance',
stepIndex: this.context.stepIndex,
pendingData: { userInput: draft, aiGenerated: true },
});

return this.buildOutcomeResult({ status: 'awaiting-input' });
}

private async saveSubmission(incomingPendingData: unknown): Promise<StepExecutionResult> {
const parsed = patchBodySchemas.guidance.safeParse(incomingPendingData);

if (!parsed.success) {
Expand All @@ -33,6 +95,72 @@ export default class GuidanceStepExecutor extends BaseStepExecutor<GuidanceStepD
return this.buildOutcomeResult({ status: 'success' });
}

private async findGuidanceExecution(): Promise<GuidanceStepExecutionData | undefined> {
const executions = await this.context.runStore.getStepExecutions(this.context.runId);

return executions.find(
(e): e is GuidanceStepExecutionData =>
e.type === 'guidance' && e.stepIndex === this.context.stepIndex,
);
}

// Persists an empty pendingData so the front opens an empty field (no badge) and the AI is not
// retried on re-dispatch.
private async degradeToManual(): Promise<StepExecutionResult> {
await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'guidance',
stepIndex: this.context.stepIndex,
pendingData: {},
});

return this.buildOutcomeResult({ status: 'awaiting-input' });
}

private async askAiForResponse(): Promise<string> {
const { stepDefinition: step } = this.context;

const tool = new DynamicStructuredTool({
name: 'submit_guidance_response',
description: 'Submit the free-text response for this workflow step.',
schema: z.object({
response: z.string().describe('The response text. Plain text, reasonable length.'),
}),
func: undefined,
});

const contextMessage = this.buildContextMessage();
const previousStepsMessages = await this.buildPreviousStepsMessages();
const messages = [
contextMessage,
...previousStepsMessages,
new SystemMessage(GUIDANCE_RESPONSE_SYSTEM_PROMPT),
new HumanMessage(
`**Step instructions**: ${
step.prompt ?? step.title ?? 'Provide the response for this step.'
}`,
),
];

this.context.logger('Debug', 'AI guidance-response: context', {
...this.logCtx,
request: step.prompt ?? null,
workflowContext: [contextMessage, ...previousStepsMessages].map(message => message.content),
});

// Wrap only the AI call: buildPreviousStepsMessages above reads the run store, and a store
// failure must surface, not be mistagged as an AI failure and degraded.
const { response } = await this.withAiAssist(() =>
this.invokeWithTool<{ response?: string }>(messages, tool),
);

this.context.logger('Debug', 'AI guidance-response: text returned by the AI', {
...this.logCtx,
response: response ?? '',
});

return response ?? '';
}

protected buildOutcomeResult(outcome: {
status: RecordStepStatus;
error?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
RelatedRecordNotFoundError,
RelationNotFoundError,
StepStateError,
extractErrorMessage,
} from '../errors';
import RecordStepExecutor from './record-step-executor';
import { StepExecutionMode } from '../types/validated/step-definition';
Expand Down Expand Up @@ -174,14 +173,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
}
}

private logAiDegrade(reason: unknown): void {
this.context.logger(
'Warn',
'load-related-record: AI unavailable, degrading to manual selection',
{ ...this.logCtx, error: extractErrorMessage(reason) },
);
}

// The re-run is AI-free (first eligible relation, no-AI list), so it can't re-trigger the failure.
private async degradeToManualAwaitInput(): Promise<StepExecutionResult> {
const target = await this.resolveTarget(false);
Expand Down Expand Up @@ -581,17 +572,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
return { relatedData, bestIndex, confident, suggestedFields, relatedSchema };
}

// Tags failures raised in an AI call as AiAssistUnavailableError; passing an already-tagged error
// through avoids double-wrapping when calls nest.
private async withAiAssist<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (error) {
if (error instanceof AiAssistUnavailableError) throw error;
throw new AiAssistUnavailableError(error);
}
}

private async fetchRelatedData(
target: Pick<RelationTarget, 'selectedRecordRef' | 'name'>,
relatedSchema: CollectionSchema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ export default class StepExecutionFormatters {
}

private static formatGuidance(execution: GuidanceStepExecutionData): string | null {
if (!execution.executionResult?.userInput) return null;
const { userInput, generatedByAi } = execution.executionResult ?? {};
if (!userInput) return null;

return ` The user provided the following input: "${execution.executionResult.userInput}"`;
// Don't attribute a Full AI response to the operator — a later AI step reads this summary.
return generatedByAi
? ` The AI generated the following response: "${userInput}"`
: ` The user provided the following input: "${userInput}"`;
}

private static formatLoadRelatedRecord(
Expand Down
6 changes: 4 additions & 2 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,10 @@ export interface LoadRelatedRecordStepExecutionData

export interface GuidanceStepExecutionData extends BaseStepExecutionData {
type: 'guidance';
pendingData?: { userInput?: string };
executionResult?: { userInput?: string };
// aiGenerated → front shows the "AI" badge until first edit. Empty {} marks an AI degrade: field
// opens empty, no badge, AI not retried on re-dispatch.
pendingData?: { userInput?: string; aiGenerated?: boolean };
executionResult?: { userInput?: string; generatedByAi?: boolean };
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

export type ConfirmableStepExecutionData =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ export type McpStepDefinition = z.infer<typeof McpStepDefinitionSchema>;
export const GuidanceStepDefinitionSchema = z.object({
...sharedFields,
type: z.literal(StepType.Guidance),
executionType: z.literal(Manual).default(Manual).catch(Manual),
// No `.catch`; default Manual (not AWC) — the orchestrator owns the legacy default, so the
// executor fallback only fires on a missing field, where "never call AI" is the safe direction.
executionType: z.enum([Manual, AutomatedWithConfirmation, FullyAutomated]).default(Manual),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
});
export type GuidanceStepDefinition = z.infer<typeof GuidanceStepDefinitionSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ describe('toStepDefinition', () => {
});
});

it.each([
ServerStepExecutionTypeEnum.Manual,
ServerStepExecutionTypeEnum.AutomatedWithConfirmation,
ServerStepExecutionTypeEnum.FullyAutomated,
])('maps a guideline task with executionType=%s through verbatim', executionType => {
const task = makeTask({
taskType: ServerTaskTypeEnum.Guideline,
prompt: 'guide',
executionType,
});

expect(toStepDefinition(task)).toMatchObject({ type: StepType.Guidance, executionType });
});

it('defaults a guideline task without executionType to manual', () => {
const task = makeTask({ taskType: ServerTaskTypeEnum.Guideline, prompt: 'guide' });
delete (task as { executionType?: unknown }).executionType;

expect(toStepDefinition(task)).toMatchObject({ executionType: StepExecutionMode.Manual });
});

it('should preserve executionType=fully-automated', () => {
const task = makeTask({
taskType: ServerTaskTypeEnum.GetData,
Expand Down
Loading
Loading