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
62 changes: 59 additions & 3 deletions server/src/adagents-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,59 @@ const ADS_TXT_MAX_REDIRECTS = 5;
// apex→www hosting resolves. Cross-domain hops are refused — see
// docs/governance/property/managed-networks#why-not-http-redirects.
const ADAGENTS_WELL_KNOWN_MAX_REDIRECTS = 3;
const AGENT_CARD_VALIDATION_CONCURRENCY = 4;
const AGENT_CARD_VALIDATION_MAX_WAITERS = 32;
const AGENT_CARD_VALIDATION_QUEUE_TIMEOUT_MS = 10_000;
let activeAgentCardValidations = 0;
interface AgentCardValidationWaiter {
resolve: () => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const agentCardValidationWaiters: AgentCardValidationWaiter[] = [];

export class AgentCardValidationCapacityError extends Error {
constructor(message = 'Agent-card validation capacity is exhausted') {
super(message);
this.name = 'AgentCardValidationCapacityError';
}
}

async function withAgentCardValidationSlot<T>(fn: () => Promise<T>): Promise<T> {
if (activeAgentCardValidations >= AGENT_CARD_VALIDATION_CONCURRENCY) {
if (agentCardValidationWaiters.length >= AGENT_CARD_VALIDATION_MAX_WAITERS) {
throw new AgentCardValidationCapacityError();
}
// A released caller transfers its slot directly to this waiter; the
// waiter must not increment the active count again when it wakes.
await new Promise<void>((resolve, reject) => {
const waiter: AgentCardValidationWaiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = agentCardValidationWaiters.indexOf(waiter);
if (index >= 0) agentCardValidationWaiters.splice(index, 1);
reject(new AgentCardValidationCapacityError('Timed out waiting for agent-card validation capacity'));
}, AGENT_CARD_VALIDATION_QUEUE_TIMEOUT_MS),
};
agentCardValidationWaiters.push(waiter);
});
} else {
activeAgentCardValidations++;
}

try {
return await fn();
} finally {
const next = agentCardValidationWaiters.shift();
if (next) {
clearTimeout(next.timer);
next.resolve();
}
else activeAgentCardValidations--;
}
}

const MCP_PREFLIGHT_INITIALIZE_BODY = {
jsonrpc: '2.0',
method: 'initialize',
Expand Down Expand Up @@ -1559,9 +1612,12 @@ export class AdAgentsManager {
*/
async validateAgentCards(agents: AuthorizedAgent[]): Promise<AgentCardValidationResult[]> {
const results: AgentCardValidationResult[] = [];

// Validate each agent in parallel
const validationPromises = agents.map(agent => this.validateSingleAgentCard(agent.url));

// The public route caps cardinality, and this shared semaphore also caps
// outbound work across concurrent callers and internal call sites.
const validationPromises = agents.map(agent => withAgentCardValidationSlot(
() => this.validateSingleAgentCard(agent.url),
));
const validationResults = await Promise.allSettled(validationPromises);

validationResults.forEach((result, index) => {
Expand Down
92 changes: 40 additions & 52 deletions server/src/addie/bolt-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,17 @@ import type { RequestTools } from './claude-client.js';
import type { SuggestedPrompt } from './types.js';
import { DatabaseThreadContextStore } from './thread-context-store.js';
import { getThreadService, type ThreadContext } from './thread-service.js';
import { isMultiPartyThread, isDirectedAtAddie, isAddressedToAnotherUser, buildThreadStyleHint, buildThreadSummaryForRouter } from './thread-utils.js';
import {
isMultiPartyThread,
isDirectedAtAddie,
isAddressedToAnotherUser,
buildThreadStyleHint,
buildThreadSummaryForRouter,
buildUntrustedSlackHistoryContext,
buildAuthorizedConversationHistory,
buildUntrustedSlackChannelMetadataContext,
isValidWorkingGroupSlug,
} from './thread-utils.js';
import { getThreadReplies, getSlackUser, getChannelInfo, getChannelHistory } from '../slack/client.js';
import { AddieRouter, type RoutingContext, type ExecutionPlan, type ConfidenceTier } from './router.js';
import {
Expand Down Expand Up @@ -544,10 +554,14 @@ async function buildChannelContext(channelId: string): Promise<Partial<ThreadCon
try {
const workingGroup = await workingGroupDb.getWorkingGroupBySlackChannelId(channelId);
if (workingGroup) {
context.viewing_channel_working_group_slug = workingGroup.slug;
context.viewing_channel_working_group_name = workingGroup.name;
context.viewing_channel_working_group_id = workingGroup.id;
logger.debug({ channelId, workingGroupSlug: workingGroup.slug }, 'Channel associated with working group');
if (!isValidWorkingGroupSlug(workingGroup.slug)) {
logger.warn({ channelId, workingGroupId: workingGroup.id }, 'Ignoring invalid working-group slug');
} else {
context.viewing_channel_working_group_slug = workingGroup.slug;
context.viewing_channel_working_group_name = workingGroup.name;
context.viewing_channel_working_group_id = workingGroup.id;
logger.debug({ channelId, workingGroupSlug: workingGroup.slug }, 'Channel associated with working group');
}
}
} catch (error) {
logger.debug({ error, channelId }, 'Could not look up working group for channel');
Expand Down Expand Up @@ -920,16 +934,15 @@ async function buildRequestContext(
if (threadContext?.viewing_channel_name) {
const channelLines: string[] = [];
channelLines.push('## Channel Context');
channelLines.push(`User is viewing **#${threadContext.viewing_channel_name}**`);
if (threadContext.viewing_channel_description) {
channelLines.push(`Channel description: ${threadContext.viewing_channel_description}`);
}
if (threadContext.viewing_channel_topic) {
channelLines.push(`Channel topic: ${threadContext.viewing_channel_topic}`);
}
channelLines.push(buildUntrustedSlackChannelMetadataContext({
channelName: threadContext.viewing_channel_name,
description: threadContext.viewing_channel_description,
topic: threadContext.viewing_channel_topic,
workingGroupName: threadContext.viewing_channel_working_group_name,
}));
// Include working group association if this channel belongs to one
if (threadContext.viewing_channel_working_group_name && threadContext.viewing_channel_working_group_slug) {
channelLines.push(`**Working Group:** ${threadContext.viewing_channel_working_group_name} (slug: "${threadContext.viewing_channel_working_group_slug}")`);
if (isValidWorkingGroupSlug(threadContext.viewing_channel_working_group_slug)) {
channelLines.push(`Server-verified working-group slug: "${threadContext.viewing_channel_working_group_slug}".`);
channelLines.push(`When scheduling meetings for this channel, use working_group_slug="${threadContext.viewing_channel_working_group_slug}" by default.`);
}
// Public channels are visible to all workspace members — never share sensitive data there
Expand Down Expand Up @@ -1657,14 +1670,7 @@ async function handleUserMessage({
// Format previous messages for Claude context
// Only include user and assistant messages (skip system/tool)
// Exclude the current message (we just logged it below, but it's not there yet)
conversationHistory = previousMessages
.filter(msg => msg.role === 'user' || msg.role === 'assistant')
.slice(-MAX_HISTORY_MESSAGES)
.map(msg => ({
user: msg.role === 'assistant' ? 'Addie' : (msg.user_display_name || 'User'),
text: msg.content_sanitized || msg.content,
toolCalls: msg.tool_calls ?? undefined,
}));
conversationHistory = buildAuthorizedConversationHistory(previousMessages, userId, MAX_HISTORY_MESSAGES);

if (conversationHistory.length > 0) {
logger.debug(
Expand Down Expand Up @@ -2325,7 +2331,7 @@ async function handleAppMention({
const header = isInThread
? 'The user is replying in a Slack thread. Here are the previous messages in this thread for context:'
: 'The user mentioned you in a conversation. Here are the recent messages leading up to the mention:';
threadContext = `\n\n## ${contextLabel} Context\n${header}\n${contextMessages.join('\n')}\n\n---\n`;
threadContext = `\n\n${buildUntrustedSlackHistoryContext(contextLabel, header, contextMessages)}\n\n---\n`;

// Calibrate response length to match the thread's conversational register
const styleHint = buildThreadStyleHint(rawMessages, context.botUserId || '');
Expand Down Expand Up @@ -2380,14 +2386,7 @@ async function handleAppMention({
try {
const previousMessages = await threadService.getThreadMessages(thread.thread_id);
if (previousMessages.length > 0) {
conversationHistory = previousMessages
.filter(msg => msg.role === 'user' || msg.role === 'assistant')
.slice(-MAX_HISTORY_MESSAGES)
.map(msg => ({
user: msg.role === 'assistant' ? 'Addie' : (msg.user_display_name || 'User'),
text: msg.content_sanitized || msg.content,
toolCalls: msg.tool_calls ?? undefined,
}));
conversationHistory = buildAuthorizedConversationHistory(previousMessages, userId, MAX_HISTORY_MESSAGES);

if (conversationHistory.length > 0) {
logger.debug(
Expand Down Expand Up @@ -3696,7 +3695,11 @@ async function handleActiveThreadReply({
});

if (contextMessages.length > 0) {
threadContext = `\n\n## Thread Context\nThis is a continuation of a conversation in a Slack thread. Here are the previous messages:\n${contextMessages.join('\n')}\n\n---\n`;
threadContext = `\n\n${buildUntrustedSlackHistoryContext(
'Thread',
'This is a continuation of a conversation in a Slack thread. Here are the previous messages:',
contextMessages,
)}\n\n---\n`;

// Calibrate response length to match the thread's conversational register
const styleHint = buildThreadStyleHint(slackThreadMessages, context.botUserId || '');
Expand Down Expand Up @@ -3738,14 +3741,7 @@ async function handleActiveThreadReply({
try {
const previousMessages = await threadService.getThreadMessages(thread.thread_id);
if (previousMessages.length > 0) {
conversationHistory = previousMessages
.filter(msg => msg.role === 'user' || msg.role === 'assistant')
.slice(-MAX_DB_HISTORY_MESSAGES)
.map(msg => ({
user: msg.role === 'assistant' ? 'Addie' : (msg.user_display_name || 'User'),
text: msg.content_sanitized || msg.content,
toolCalls: msg.tool_calls ?? undefined,
}));
conversationHistory = buildAuthorizedConversationHistory(previousMessages, userId, MAX_DB_HISTORY_MESSAGES);

if (conversationHistory.length > 0) {
logger.debug(
Expand All @@ -3765,10 +3761,9 @@ async function handleActiveThreadReply({
memberContext = updatedMemberContext;
}

// When DB history is available, skip Slack thread context (DB history is more structured
// and already represented as proper user/assistant turns). Use Slack thread context as
// fallback when DB history is unavailable.
let requestContext = (!conversationHistory || conversationHistory.length === 0) && threadContext
// Slack history remains an explicitly untrusted reference block even when
// same-principal DB history is available.
let requestContext = threadContext
? `${memberRequestContext}\n\n${threadContext}`
: memberRequestContext;
// Only warn about missing history when there's no Slack thread context either.
Expand Down Expand Up @@ -5203,14 +5198,7 @@ async function handleReactionAdded({
try {
const previousMessages = await threadService.getThreadMessages(thread.thread_id);
if (previousMessages.length > 0) {
conversationHistory = previousMessages
.filter(msg => msg.role === 'user' || msg.role === 'assistant')
.slice(-MAX_HISTORY_MESSAGES)
.map(msg => ({
user: msg.role === 'assistant' ? 'Addie' : (msg.user_display_name || 'User'),
text: msg.content_sanitized || msg.content,
toolCalls: msg.tool_calls ?? undefined,
}));
conversationHistory = buildAuthorizedConversationHistory(previousMessages, reactingUserId, MAX_HISTORY_MESSAGES);

if (conversationHistory.length > 0) {
logger.debug(
Expand Down
13 changes: 10 additions & 3 deletions server/src/addie/services/feed-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { createLogger } from '../../logger.js';

const logger = createLogger('feed-fetcher');
import { decodeHtmlEntities } from '../../utils/html-entities.js';
import { safeFetch } from '../../utils/url-security.js';
import { readResponseTextWithLimit } from '../../utils/bounded-response.js';
import {
getFeedsToFetch,
getFeedById,
Expand All @@ -28,6 +30,9 @@ const parser = new Parser({
},
});

const FEED_FETCH_TIMEOUT_MS = 30_000;
const FEED_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;

/**
* Validate that content is actually RSS/Atom XML, not HTML
* Some sites disable their RSS feeds and redirect to HTML pages
Expand Down Expand Up @@ -105,20 +110,22 @@ async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> {

// Pre-fetch content to validate it's actually RSS/XML before parsing
// This prevents cryptic XML parsing errors when sites return HTML
const response = await fetch(feed.feed_url, {
const response = await safeFetch(feed.feed_url, {
maxRedirects: 3,
headers: {
'User-Agent': 'AddieBot/1.0 (AgenticAdvertising.org industry monitor)',
Accept: 'application/rss+xml, application/xml, text/xml',
},
signal: AbortSignal.timeout(30000),
signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS),
});

if (!response.ok) {
response.body?.cancel().catch(() => {});
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const contentType = response.headers.get('content-type') || '';
const content = await response.text();
const content = await readResponseTextWithLimit(response, FEED_MAX_RESPONSE_BYTES);

// Validate content is RSS/XML, not HTML (e.g., from a redirect)
const validation = validateRssContent(content, contentType);
Expand Down
Loading
Loading