Skip to content
Merged
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
6 changes: 2 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ jobs:
build:
needs: [prepare-matrix, run-clickhouse-migrations]
timeout-minutes: 60
runs-on: ubuntu-latest
runs-on: blacksmith-8vcpu-ubuntu-2404
environment: ${{ fromJson(needs.prepare-matrix.outputs.env_matrix).environment[0] }}
permissions:
contents: read
Expand Down Expand Up @@ -374,9 +374,7 @@ jobs:
run: pnpm ci

- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
driver-opts: "image=moby/buildkit:v0.13.1"
uses: useblacksmith/setup-docker-builder@a592b831ebb20e68f7cf47329cf2c3c67b8a7655 # v1

- name: Prepare Variables
run: echo "BULL_MQ_PRO_NPM_TOKEN=${{ secrets.BULL_MQ_PRO_NPM_TOKEN }}" >> $GITHUB_ENV
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export class AgentsController {
}

@Post('/verify-credentials')
@ExternalApiAccessible()
@ApiResponse(VerifyManagedCredentialsResponseDto)
@ApiOperation({
summary: 'Verify managed-runtime credentials',
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ describe('Verify Managed Credentials API #novu-v2', () => {
expect(mockProvider.validateCredentials.firstCall.args[0]).to.deep.equal({ apiKey: FAKE_API_KEY });
});

it('accepts API key authentication for CLI connect flows', async () => {
const res = await session.testAgent
.post('/v1/agents/verify-credentials')
.set('authorization', `ApiKey ${session.apiKey}`)
.send({
providerId: AgentRuntimeProviderIdEnum.Anthropic,
apiKey: FAKE_API_KEY,
});

expect(res.status).to.equal(201);
expect(res.body.data?.valid ?? res.body.valid).to.equal(true);
});

it('returns 401 when the provider rejects the API key', async () => {
mockProvider.validateCredentials.rejects(
new AgentRuntimeUnauthorizedError('Invalid API key', AgentRuntimeProviderIdEnum.Anthropic)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { CLAUDE_ANTHROPIC_SKILLS, CLAUDE_BUILTIN_TOOLS, MCP_SERVERS } from '@novu/shared';
import {
CLAUDE_ANTHROPIC_SKILLS,
CLAUDE_BUILTIN_TOOLS,
MANAGED_AGENT_IDENTIFIER_MAX_LENGTH,
MANAGED_AGENT_NAME_MAX_LENGTH,
MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH,
MAX_GENERATED_MCP_SERVERS,
MAX_GENERATED_SKILLS,
MCP_SERVERS,
} from '@novu/shared';
import { z } from 'zod';

/**
Expand All @@ -10,26 +19,24 @@ const TOOL_TYPES = CLAUDE_BUILTIN_TOOLS.map((tool) => tool.type) as [string, ...
const MCP_SERVER_IDS = MCP_SERVERS.map((server) => server.id) as [string, ...string[]];
const SKILL_IDS = CLAUDE_ANTHROPIC_SKILLS.map((skill) => skill.skillId) as [string, ...string[]];

/** Anthropic caps `mcp_servers` length; pick a conservative limit. */
export const MAX_GENERATED_MCP_SERVERS = 5;
export const MAX_GENERATED_SKILLS = 4;
export { MAX_GENERATED_MCP_SERVERS, MAX_GENERATED_SKILLS };

export const managedAgentGenerationSchema = z.object({
name: z
.string()
.min(1)
.max(60)
.max(MANAGED_AGENT_NAME_MAX_LENGTH)
.describe('Human readable agent name. Title case, 2–4 words (e.g., "PR Security Reviewer").'),
identifier: z
.string()
.min(1)
.max(60)
.max(MANAGED_AGENT_IDENTIFIER_MAX_LENGTH)
.regex(/^[a-z0-9-]+$/, 'Identifier must be lowercase kebab-case')
.describe('Stable kebab-case identifier derived from the name (e.g., "pr-security-reviewer").'),
systemPrompt: z
.string()
.min(1)
.max(4000)
.max(MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH)
.describe(
'The full system prompt sent to Claude. Speak in second person to the agent ("You are…"). Describe role, scope, tone, and the workflow it should follow. Reference the available tools/MCPs/skills naturally without hard-coding them.'
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ToolProgressPayload {
toolName?: string;
mcpServerName?: string;
status?: 'running' | 'complete' | 'error';
details?: string;
toolInput?: Record<string, unknown>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class HandlePlanProgress {
const toolName = toolProgress.toolName || existing?.toolName || 'Tool';
const mcpServerName = toolProgress.mcpServerName || existing?.mcpServerName;
const status: PlanTaskStatus = toolProgress.status === 'running' ? 'in_progress' : toolProgress.status;
const details = formatToolInputSummary(toolProgress.toolInput) || existing?.details;
const details = toolProgress.details || formatToolInputSummary(toolProgress.toolInput) || existing?.details;

tasks.set(toolProgress.toolUseId, { toolUseId: toolProgress.toolUseId, toolName, mcpServerName, status, details });

Expand Down Expand Up @@ -136,13 +136,8 @@ export class HandlePlanProgress {
return;
}

const finalStatus: PlanTaskStatus = toolProgress.action === 'fail' ? 'error' : 'complete';
const title = toolProgress.action === 'fail' ? 'Something went wrong' : 'Finished thinking';

const tasks = this.collectTasks(existingActivities);
for (const task of tasks.values()) {
task.status = finalStatus;
}

await this.postOrEditPlan(command, planMessageId, this.toModel(title, tasks, true));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,19 @@ export function extractPendingToolApprovals(response: ThalamusResponse): Pending
}

function formatToolLabel(t: PendingToolApproval): string {
const name = t.mcpServerName ? `${t.toolName} from ${t.mcpServerName}` : t.toolName;
const input = t.input ? `: ${summariseInput(t.input)}` : '';

return `${name}${input}`;
if (t.mcpServerName) {
return `${t.mcpServerName} -> ${t.toolName}${input}`;
}

return `${t.toolName}${input}`;
}

export function buildToolApprovalCard(pendingTools: PendingToolApproval[], turnId: string): Record<string, unknown> {
const tool = pendingTools[0];
const serverLabel = tool.mcpServerName ? ` from ${tool.mcpServerName}` : '';
export function buildToolApprovalCard(tool: PendingToolApproval, turnId: string): Record<string, unknown> {
const toolLabel = formatToolLabel(tool);
const mcpDisplayName = tool.mcpServerName ?? 'MCP';

const inputSummary = tool.input ? summariseInput(tool.input) : '';
const description = inputSummary
? `I'd like to call \`${tool.toolName}\`${serverLabel}:\n\`\`\`\n${inputSummary}\n\`\`\``
: `I'd like to call \`${tool.toolName}\`${serverLabel}.`;

const children: Record<string, unknown>[] = [{ type: 'text', content: description }];

children.push(
{ type: 'divider' },
const children: Record<string, unknown>[] = [
{
type: 'actions',
children: [
Expand All @@ -123,80 +115,47 @@ export function buildToolApprovalCard(pendingTools: PendingToolApproval[], turnI
value: toolLabel,
},
],
}
);

if (pendingTools.length === 1) {
children.push(
{ type: 'divider' },
{
type: 'actions',
children: [
{
type: 'button',
id: buildPersistTrustActionId('approve-tool', tool, turnId),
label: `Approve & always allow ${tool.toolName}`,
style: 'default',
value: toolLabel,
},
{
type: 'button',
id: buildPersistTrustActionId('approve-server', tool, turnId),
label: `Approve & always allow all ${mcpDisplayName} tools`,
style: 'default',
value: toolLabel,
},
],
}
);
}

if (pendingTools.length > 1) {
const allIds = pendingTools.map((t) => t.toolUseId).join(',');
const allLabels = pendingTools.map((t) => formatToolLabel(t)).join('\n');
children.push({
},
{ type: 'divider' },
{
type: 'actions',
children: [
{
type: 'button',
id: `${TOOL_APPROVAL_ACTION_PREFIX}:approve:${allIds}:${turnId}`,
label: `Approve All (${pendingTools.length})`,
style: 'primary',
value: allLabels,
id: buildPersistTrustActionId('approve-tool', tool, turnId),
label: `Approve & Always allow ${tool.toolName}`,
style: 'default',
value: toolLabel,
},
{
type: 'button',
id: `${TOOL_APPROVAL_ACTION_PREFIX}:deny:${allIds}:${turnId}`,
label: `Deny All (${pendingTools.length})`,
style: 'danger',
value: allLabels,
id: buildPersistTrustActionId('approve-server', tool, turnId),
label: `Approve & Always allow all from ${tool.mcpServerName}`,
style: 'default',
value: toolLabel,
},
],
});
}
},
];

return {
type: 'card',
title: 'Tool Approval',
subtitle: toolLabel,
children,
};
}

export function buildToolApprovalVerdictCard(
approved: boolean,
toolCount: number,
toolDescription?: string
): Record<string, unknown> {
export function buildToolApprovalVerdictCard(approved: boolean, toolDescription?: string): Record<string, unknown> {
const emoji = approved ? '✅' : '🚫';
const verb = approved ? 'Approved' : 'Denied';
const suffix = toolCount > 1 ? ` all ${toolCount} tools` : '';
const subtitle = toolDescription || undefined;

return {
type: 'card',
title: 'Tool Approval',
subtitle,
children: [{ type: 'text', content: `${emoji} ${verb}${suffix}` }],
children: [{ type: 'text', content: `${emoji} ${verb}` }],
};
}

Expand Down
Loading
Loading