fix(ai-agent): use a non-workflow base system prompt for programmatic agent runs (#23394)

`AgentAsyncExecutorService` hardcoded `WORKFLOW_SYSTEM_PROMPTS.BASE`, so
every caller was told "You are executing as part of a workflow
automation" and "your output may be used by downstream workflow nodes".
That is only true for the workflow AI-agent action. The `runAgent` API
(used by apps such as the call recorder) and agent evaluations got the
same framing, which does not describe how they run or where their output
goes.

The executor no longer asserts its own execution context: `executeAgent`
now takes a required `baseSystemPrompt` and each caller supplies its
own.

- Workflow AI-agent action passes `WORKFLOW_SYSTEM_PROMPTS.BASE`
(unchanged behavior)
- `runAgent` and evaluations pass the new `AGENT_RUN_BASE_SYSTEM_PROMPT`

The param is required rather than defaulted so every call site states
its context and no future caller silently inherits the wrong one.

Prompt constants are also split one export per file, with the shared
tool-usage guidance extracted into `TOOL_USAGE_STRATEGY` so both bases
compose it.

No GraphQL schema, SDK, or database changes.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23394?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-28 17:23:31 +05:30
committed by GitHub
parent 3f0236b590
commit 6c66d4c862
11 changed files with 152 additions and 50 deletions
@@ -8,6 +8,8 @@ import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS } from 'src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
@@ -64,6 +66,18 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
modelConfiguration: {},
}) as AgentEntity;
const emptyUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
};
beforeEach(async () => {
toolRegistry = { getToolsByCategories: jest.fn().mockResolvedValue({}) };
roleTargetRepository = { findOne: jest.fn() };
@@ -138,6 +152,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
userPrompt: 'test',
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -158,12 +173,65 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
userPrompt: 'test',
baseSystemPrompt: 'base system prompt',
workspaceId,
});
expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled();
});
it('prefixes the system prompt with the caller-supplied base prompt', async () => {
roleTargetRepository.findOne.mockResolvedValueOnce(null);
await service.executeAgent({
agent: buildAgent(),
userPrompt: 'test',
baseSystemPrompt: 'caller base prompt',
workspaceId,
});
expect(generateTextMock).toHaveBeenCalledWith(
expect.objectContaining({
system: 'caller base prompt\n\ntest prompt',
}),
);
});
it('uses the context-neutral structured output prompt regardless of the caller base prompt', async () => {
roleTargetRepository.findOne.mockResolvedValueOnce(null);
generateTextMock
.mockResolvedValueOnce({
text: 'execution result',
steps: [],
usage: emptyUsage,
} as unknown as Awaited<ReturnType<typeof generateText>>)
.mockResolvedValueOnce({
text: '',
steps: [],
usage: emptyUsage,
output: { summary: 'done' },
} as unknown as Awaited<ReturnType<typeof generateText>>);
await service.executeAgent({
agent: {
...buildAgent(),
responseFormat: {
type: 'json',
schema: { type: 'object', properties: {} },
},
} as AgentEntity,
userPrompt: 'test',
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
workspaceId,
});
expect(generateTextMock).toHaveBeenCalledTimes(2);
expect(generateTextMock.mock.calls[1][0].system).toBe(
STRUCTURED_OUTPUT_SYSTEM_PROMPT,
);
expect(generateTextMock.mock.calls[1][0].system).not.toMatch(/workflow/i);
});
describe('cost folding', () => {
const baseUsage = {
inputTokens: 100,
@@ -191,6 +259,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
const result = await service.executeAgent({
agent: buildAgent(),
userPrompt: 'test',
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -264,6 +333,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
const result = await service.executeAgent({
agent: buildAgent(),
userPrompt: 'test',
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -4,6 +4,7 @@ import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
@@ -95,6 +96,20 @@ describe('AgentRunService', () => {
);
});
it('runs the agent with the programmatic base system prompt', async () => {
await service.run({
workspace,
requestUserWorkspaceId: null,
input,
});
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
expect.objectContaining({
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
}),
);
});
it('returns an error result when the workspace ran out of credits', async () => {
agentAsyncExecutorService.executeAgent.mockResolvedValue({
result: { response: 'partial' },
@@ -33,7 +33,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS } from 'src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars';
@@ -75,9 +75,10 @@ const EMPTY_USAGE: LanguageModelUsage = {
},
};
// Agent execution within workflows uses registry tools plus native model tools.
// Workflow registry tools are intentionally excluded to avoid circular
// dependencies and recursive workflow execution.
// Agent execution uses registry tools plus native model tools. The caller
// supplies the base system prompt describing its execution context (workflow
// step, programmatic run). Workflow registry tools are intentionally excluded
// to avoid circular dependencies and recursive workflow execution.
@Injectable()
export class AgentAsyncExecutorService {
private readonly logger = new Logger(AgentAsyncExecutorService.name);
@@ -113,6 +114,7 @@ export class AgentAsyncExecutorService {
async executeAgent({
agent,
userPrompt,
baseSystemPrompt,
actorContext,
authContext,
workspaceId,
@@ -121,6 +123,7 @@ export class AgentAsyncExecutorService {
}: {
agent: AgentEntity | null;
userPrompt: string;
baseSystemPrompt: string;
actorContext?: ActorMetadata;
authContext?: WorkspaceAuthContext;
workspaceId: string;
@@ -230,7 +233,7 @@ export class AgentAsyncExecutorService {
let hasNoMoreAvailableCredits = false;
const textResponse = await generateText({
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`,
system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}`,
tools,
model: registeredModel.model,
prompt: userPrompt,
@@ -335,7 +338,7 @@ export class AgentAsyncExecutorService {
if (agentSchema) {
const structuredResult = await generateText({
system: WORKFLOW_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
system: STRUCTURED_OUTPUT_SYSTEM_PROMPT,
model: registeredModel.model,
prompt: `Based on the following execution results, generate the structured output according to the schema:
@@ -10,6 +10,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -64,6 +65,7 @@ export class AgentRunService {
await this.agentAsyncExecutorService.executeAgent({
agent,
userPrompt: input.prompt,
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
authContext,
workspaceId: workspace.id,
userWorkspaceId: requestUserWorkspaceId,
@@ -6,6 +6,7 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
@@ -56,6 +57,7 @@ export class RunEvaluationInputJob {
const executionResult = await this.aiAgentExecutorService.executeAgent({
agent,
userPrompt: data.input,
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
workspaceId: data.workspaceId,
userWorkspaceId: null,
});
@@ -0,0 +1,13 @@
// Base system prompt for programmatic agent runs outside workflows (runAgent API, evaluations)
// NOTE: For user-facing chat, use CHAT_SYSTEM_PROMPTS from ai-chat/constants
import { TOOL_USAGE_STRATEGY } from 'src/engine/metadata-modules/ai/ai-agent/constants/tool-usage-strategy.const';
export const AGENT_RUN_BASE_SYSTEM_PROMPT = `You are an AI agent in Twenty CRM, invoked programmatically to complete a request.
${TOOL_USAGE_STRATEGY}
Response:
- Your response is returned to the caller and may be shown directly to a person or processed by software
- Answer the request completely and directly
`;
@@ -1,44 +0,0 @@
// System prompts for Workflow Agents (automated execution only)
// NOTE: For user-facing chat, use CHAT_SYSTEM_PROMPTS from ai-chat/constants
export const WORKFLOW_SYSTEM_PROMPTS = {
// Core workflow execution behavior
BASE: `You are executing as part of a workflow automation in Twenty CRM.
Tool usage strategy:
- Chain multiple tools to solve complex tasks
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) over looping single-item calls
- Use \`upsert_many_*\` instead of \`update_many_*\` when records have different data to set individually, or when some records may not exist yet
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent
Context:
- Your output may be used by downstream workflow nodes
- Be thorough and include all relevant data
- Focus on completing the task efficiently
Permissions:
- Only perform actions your role allows`,
// Structured output generation for workflow data passing
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
};
@@ -0,0 +1,19 @@
export const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `You are a structured output generator. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for the caller to consume
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`;
@@ -0,0 +1,7 @@
export const TOOL_USAGE_STRATEGY = `Tool usage strategy:
- Chain multiple tools to solve complex tasks
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) over looping single-item calls
- Use \`upsert_many_*\` instead of \`update_many_*\` when records have different data to set individually, or when some records may not exist yet
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent`;
@@ -0,0 +1,13 @@
import { TOOL_USAGE_STRATEGY } from 'src/engine/metadata-modules/ai/ai-agent/constants/tool-usage-strategy.const';
export const WORKFLOW_BASE_SYSTEM_PROMPT = `You are executing as part of a workflow automation in Twenty CRM.
${TOOL_USAGE_STRATEGY}
Context:
- Your output may be used by downstream workflow nodes
- Be thorough and include all relevant data
- Focus on completing the task efficiently
Permissions:
- Only perform actions your role allows`;
@@ -7,6 +7,7 @@ import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/inte
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import { WORKFLOW_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/workflow-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -84,6 +85,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
const executionResult = await this.aiAgentExecutionService.executeAgent({
agent,
userPrompt: resolveInput(prompt, context) as string,
baseSystemPrompt: WORKFLOW_BASE_SYSTEM_PROMPT,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,