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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 116 additions & 9 deletions packages/cloud-agents/src/server/router/external-issue-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RoutingContext } from './types';
const MAX_EXTERNAL_ISSUES = 2;
const MAX_EXTERNAL_CONTEXT_CHARS = 8_000;
const EXTERNAL_LOOKUP_TIMEOUT_MS = 8_000;
const MAX_BARE_REFERENCE_REPOS = 5;

interface ExternalIssueReference {
url: string;
Expand Down Expand Up @@ -51,6 +52,104 @@ function parseExternalIssueReferences(text: string): ExternalIssueReference[] {
return references;
}

function referenceFromCanonicalUrl(
label: string,
canonicalUrl: string,
): ExternalIssueReference | null {
try {
const match = matchExternalIssueUrl(new URL(canonicalUrl));

return match ? { url: label, fetchAttempts: match.fetchAttempts } : null;
} catch {
return null;
}
}

function uniqueConfiguredRepositories(context: RoutingContext): string[] {
const repositories = new Set<string>();

for (const environment of context.availableEnvironments) {
for (const name of environment.repositoryNames ?? []) {
repositories.add(name);
}
}

return [...repositories];
}

/**
* Resolves a bare reference the routing precheck extracted (for example
* "#234", "acme/web#234", or "ENG-512") against the deployment's own
* configuration. The model only proposes the reference text; the fetch
* targets always come from this closed candidate set, so a steered or
* hallucinated reference can at most read issues the deployment already
* routes for. An ambiguous issue number fans out across the first
* MAX_BARE_REFERENCE_REPOS configured repositories (environment order) so the
* fetch stays bounded on deployments with many repositories.
*/
function resolveBareIssueReferences(
context: RoutingContext,
externalReference: string | null | undefined,
): ExternalIssueReference[] {
const raw = externalReference?.trim();

if (!raw) {
return [];
}

if (/^[A-Za-z]+-\d+$/.test(raw)) {
const reference = referenceFromCanonicalUrl(
raw,
`https://linear.app/_/issue/${raw}`,
);

return reference ? [reference] : [];
}

const configuredRepositories = uniqueConfiguredRepositories(context);
const githubReference = (fullRepository: string, issueNumber: string) =>
referenceFromCanonicalUrl(
`${fullRepository}#${issueNumber}`,
`https://github.com/${fullRepository}/issues/${issueNumber}`,
);

const qualified = raw.match(
/^(?<owner>[^\s/#]+)\/(?<repository>[^\s/#]+)#(?<issueNumber>\d+)$/,
);

if (qualified?.groups) {
const fullRepository = configuredRepositories.find(
(name) =>
name.toLowerCase() ===
`${qualified.groups!.owner}/${qualified.groups!.repository}`.toLowerCase(),
);
const reference =
fullRepository &&
githubReference(fullRepository, qualified.groups.issueNumber!);

return reference ? [reference] : [];
}

const bareNumber = raw.match(/^#?(?<issueNumber>\d+)$/);

if (bareNumber?.groups && configuredRepositories.length > 0) {
// Environment order decides which repositories make the cut; repos where
// the issue number does not exist drop out at fetch time anyway.
return configuredRepositories
.slice(0, MAX_BARE_REFERENCE_REPOS)
.flatMap((fullRepository) => {
const reference = githubReference(
fullRepository,
bareNumber.groups!.issueNumber!,
);

return reference ? [reference] : [];
});
}

return [];
}

function serializeExternalContext(value: unknown): string {
if (typeof value === 'string') {
return value;
Expand All @@ -64,9 +163,11 @@ function serializeExternalContext(value: unknown): string {
}

async function withExternalLookupTimeout<T>(
operation: Promise<T>,
operation: () => Promise<T>,
deadline: number,
): Promise<T | null> {
// Checked before invoking so an exhausted deadline never dispatches
// another MCP call whose result nothing will await.
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return null;
Expand All @@ -76,7 +177,7 @@ async function withExternalLookupTimeout<T>(

try {
return await Promise.race([
operation,
operation(),
new Promise<null>((resolve) => {
timeout = setTimeout(resolve, remainingMs, null);
}),
Expand All @@ -97,12 +198,13 @@ async function fetchExternalIssueContext(

for (const attempt of reference.fetchAttempts) {
const result = await withExternalLookupTimeout(
callRouterMcpTool({
context,
serverId: attempt.serverId,
toolName: attempt.toolName,
args: attempt.args,
}),
() =>
callRouterMcpTool({
context,
serverId: attempt.serverId,
toolName: attempt.toolName,
args: attempt.args,
}),
deadline,
);

Expand All @@ -120,8 +222,13 @@ async function fetchExternalIssueContext(

export async function gatherExternalIssueContext(
context: RoutingContext,
externalReference?: string | null,
): Promise<{ contextMessages: ModelMessage[]; toolsUsed: string[] }> {
const references = parseExternalIssueReferences(context.taskDescription);
const urlReferences = parseExternalIssueReferences(context.taskDescription);
const references =
urlReferences.length > 0
? urlReferences
: resolveBareIssueReferences(context, externalReference);
const results = await Promise.all(
references.map(async (reference) => ({
reference,
Expand Down
10 changes: 7 additions & 3 deletions packages/cloud-agents/src/server/router/mcp-gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,13 @@ export async function gatherContextFromConfiguredMcps<
}

// The precheck asked for the linked issue, so the fetch deadline is only
// paid when it can change the decision. Fail-open: with nothing fetched,
// the precheck decision stands.
const externalIssueContext = await gatherExternalIssueContext(context);
// paid when it can change the decision. Bare references (no pasted URL)
// resolve against the deployment's configured repositories only. Fail-open:
// with nothing fetched, the precheck decision stands.
const externalIssueContext = await gatherExternalIssueContext(
context,
response.externalReference,
);

if (externalIssueContext.contextMessages.length === 0) {
return { response, toolsUsed: [], phase: 'direct', needsExternalLookup };
Expand Down
Loading