Skip to content

Commit 8912ddb

Browse files
joshspicerCopilotCopilot
authored
graphql query to detect partner agents (#2775)
* Initial plan * Add GraphQL-based partner agent detection with caching - Add GraphQL queries for fetching assignable actors (suggestedActors and assignableUsers APIs) - Add getAssignableActors method to IOctoKitService interface - Implement dynamic partner agent detection in CopilotCloudSessionsProvider - Add caching mechanism to avoid repeated API calls - Update known Copilot agent logins based on spec - Fall back to hardcoded list when API is unavailable Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> * Fix MockOctoKitService to include getAssignableActors method Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> * Use HARDCODED_PARTNER_AGENTS `at` values for partner agents - Create COPILOT_AGENT_METADATA mapping for known Copilot agent logins - Look up `at` values from HARDCODED_PARTNER_AGENTS when available - Preserve the `at` field structure from the hardcoded list Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> * Remove COPILOT_AGENT_METADATA and use HARDCODED_PARTNER_AGENTS directly - Delete COPILOT_AGENT_METADATA mapping - Simplify getAvailablePartnerAgents to check HARDCODED_PARTNER_AGENTS directly - Check if agent ID or name matches assignable actors - Preserve all fields from HARDCODED_PARTNER_AGENTS including `at` values Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> * polish * Update src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix auth --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 7eaf722 commit 8912ddb

File tree

5 files changed

+259
-13
lines changed

5 files changed

+259
-13
lines changed

src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class MockOctoKitService implements IOctoKitService {
4444
getUserOrganizations = async () => this.userOrganizations;
4545
getOrganizationRepositories = async (org: string) => [org === 'testorg' ? 'testrepo' : 'repo'];
4646
getCopilotAgentModels = async () => [];
47+
getAssignableActors = async () => [];
4748

4849
async getCustomAgents(owner: string, repo: string, options: CustomAgentListOptions, authOptions: { createIfNone?: boolean }): Promise<CustomAgentListItem[]> {
4950
if (!(await this.getCurrentAuthedUser())) {

src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
6262
const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore';
6363

6464
// TODO: No API from GH yet.
65-
const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string }[] = [
66-
{ id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot' },
67-
{ id: '2246796', name: 'Claude', at: 'claude[agent]' },
68-
{ id: '2248422', name: 'Codex', at: 'codex[agent]' }
65+
const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string; assignableActorLogin?: string }[] = [
66+
{ id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot', assignableActorLogin: 'copilot-swe-agent' },
67+
{ id: '2246796', name: 'Claude', at: 'claude[agent]', assignableActorLogin: 'anthropic-code-agent' },
68+
{ id: '2248422', name: 'Codex', at: 'codex[agent]', assignableActorLogin: 'openai-code-agent' }
6969
];
7070

7171
/**
@@ -162,6 +162,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
162162
private readonly plainTextRenderer = new PlainTextRenderer();
163163
private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService);
164164

165+
private _partnerAgentsAvailableCache: Map<string, { id: string; name: string; at?: string }[]> | undefined;
166+
165167
// Title
166168
private TITLE = vscode.l10n.t('Delegate to cloud agent');
167169

@@ -277,6 +279,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
277279
this.cachedSessionItems = undefined;
278280
this.activeSessionIds.clear();
279281
this.stopActiveSessionPolling();
282+
this._partnerAgentsAvailableCache = undefined;
280283
this._onDidChangeChatSessionItems.fire();
281284
}
282285

@@ -356,24 +359,68 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
356359
}
357360
}
358361

362+
/**
363+
* Queries for available partner agents by checking if known CCA logins are assignable in the repository.
364+
* TODO: Remove once given a proper API
365+
*/
366+
private async getAvailablePartnerAgents(owner: string, repo: string): Promise<{ id: string; name: string; at?: string }[]> {
367+
const cacheKey = `${owner}/${repo}`;
368+
369+
// Return cached result if available
370+
if (this._partnerAgentsAvailableCache?.has(cacheKey)) {
371+
return this._partnerAgentsAvailableCache.get(cacheKey)!;
372+
}
373+
374+
try {
375+
// Fetch assignable actors for the repository
376+
const assignableActors = await this._octoKitService.getAssignableActors(owner, repo, { createIfNone: false });
377+
378+
// Check which agents from HARDCODED_PARTNER_AGENTS are assignable
379+
const availableAgents: { id: string; name: string; at?: string }[] = [];
380+
381+
for (const agent of HARDCODED_PARTNER_AGENTS) {
382+
const { assignableActorLogin } = agent;
383+
let isAssignable = false;
384+
385+
if (assignableActorLogin !== undefined) {
386+
isAssignable = assignableActors.some(actor => actor.login === assignableActorLogin);
387+
}
388+
if (isAssignable) {
389+
availableAgents.push(agent);
390+
}
391+
}
392+
393+
if (!this._partnerAgentsAvailableCache) {
394+
this._partnerAgentsAvailableCache = new Map();
395+
}
396+
this._partnerAgentsAvailableCache.set(cacheKey, availableAgents);
397+
398+
return availableAgents;
399+
} catch (error) {
400+
this.logService.error(`Error fetching partner agents: ${error}`);
401+
return [];
402+
}
403+
}
404+
359405
async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptions> {
360406
this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions Start');
361407

362408
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
363409
const repoId = await getRepoId(this._gitService);
364410

365411
try {
366-
// Fetch agents (requires repo) and models (global) in parallel
367-
const [customAgents, models] = await Promise.all([
412+
// Fetch agents (requires repo), models (global), and partner agents in parallel
413+
const [customAgents, models, partnerAgents] = await Promise.all([
368414
repoId ? this._octoKitService.getCustomAgents(repoId.org, repoId.repo, { excludeInvalidConfig: true }, { createIfNone: false }) : Promise.resolve([]),
369-
this._octoKitService.getCopilotAgentModels({ createIfNone: false })
415+
this._octoKitService.getCopilotAgentModels({ createIfNone: false }),
416+
repoId ? this.getAvailablePartnerAgents(repoId.org, repoId.repo) : Promise.resolve([])
370417
]);
371418

372419

373420
// Partner agents
374421
const partnerAgentsEnabled = this._configurationService.getConfig(ConfigKey.Advanced.CCAPartnerAgents);
375-
if (partnerAgentsEnabled) {
376-
const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = HARDCODED_PARTNER_AGENTS.map(agent => ({
422+
if (partnerAgentsEnabled && partnerAgents.length > 0) {
423+
const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = partnerAgents.map(agent => ({
377424
id: agent.id,
378425
name: agent.name,
379426
}));

src/platform/github/common/githubAPI.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@ export interface PullRequestComment {
7878
url: string;
7979
}
8080

81+
export interface AssignableActor {
82+
__typename: string;
83+
login: string;
84+
avatarUrl?: string;
85+
url?: string;
86+
}
87+
88+
export interface AssignableActorsResponse {
89+
repository: {
90+
suggestedActors?: {
91+
nodes: AssignableActor[];
92+
pageInfo: {
93+
hasNextPage: boolean;
94+
endCursor: string | null;
95+
};
96+
};
97+
assignableUsers?: {
98+
nodes: AssignableActor[];
99+
pageInfo: {
100+
hasNextPage: boolean;
101+
endCursor: string | null;
102+
};
103+
};
104+
};
105+
}
106+
81107
export async function makeGitHubAPIRequest(
82108
fetcherService: IFetcherService,
83109
logService: ILogService,
@@ -399,3 +425,125 @@ export async function makeGitHubAPIRequestWithPagination(
399425

400426
return sessionInfos;
401427
}
428+
429+
/**
430+
* Fetches assignable actors (users/bots) for a repository using suggestedActors API.
431+
* This is the preferred API as it filters by capability (CAN_BE_ASSIGNED).
432+
*/
433+
export async function getAssignableActorsWithSuggestedActors(
434+
fetcherService: IFetcherService,
435+
logService: ILogService,
436+
telemetry: ITelemetryService,
437+
host: string,
438+
token: string | undefined,
439+
owner: string,
440+
repo: string,
441+
): Promise<AssignableActor[]> {
442+
const query = `
443+
query GetSuggestedActors($owner: String!, $name: String!, $first: Int!, $after: String) {
444+
repository(owner: $owner, name: $name) {
445+
suggestedActors(
446+
first: $first
447+
after: $after
448+
capabilities: [CAN_BE_ASSIGNED]
449+
) {
450+
nodes {
451+
__typename
452+
login
453+
avatarUrl
454+
url
455+
}
456+
pageInfo {
457+
hasNextPage
458+
endCursor
459+
}
460+
}
461+
}
462+
}
463+
`;
464+
465+
const actors: AssignableActor[] = [];
466+
let after: string | null = null;
467+
let hasNextPage = true;
468+
469+
while (hasNextPage) {
470+
const variables = {
471+
owner,
472+
name: repo,
473+
first: 100,
474+
after,
475+
};
476+
477+
const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables);
478+
479+
if (!result?.data?.repository?.suggestedActors) {
480+
break;
481+
}
482+
483+
const data = result.data.repository.suggestedActors;
484+
actors.push(...data.nodes);
485+
hasNextPage = data.pageInfo.hasNextPage;
486+
after = data.pageInfo.endCursor;
487+
}
488+
489+
return actors;
490+
}
491+
492+
/**
493+
* Fetches assignable users for a repository using assignableUsers API.
494+
* This is a fallback for older GitHub Enterprise Server instances that don't support suggestedActors.
495+
*/
496+
export async function getAssignableActorsWithAssignableUsers(
497+
fetcherService: IFetcherService,
498+
logService: ILogService,
499+
telemetry: ITelemetryService,
500+
host: string,
501+
token: string | undefined,
502+
owner: string,
503+
repo: string,
504+
): Promise<AssignableActor[]> {
505+
const query = `
506+
query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) {
507+
repository(owner: $owner, name: $name) {
508+
assignableUsers(first: $first, after: $after) {
509+
nodes {
510+
__typename
511+
login
512+
avatarUrl
513+
url
514+
}
515+
pageInfo {
516+
hasNextPage
517+
endCursor
518+
}
519+
}
520+
}
521+
}
522+
`;
523+
524+
const actors: AssignableActor[] = [];
525+
let after: string | null = null;
526+
let hasNextPage = true;
527+
528+
while (hasNextPage) {
529+
const variables = {
530+
owner,
531+
name: repo,
532+
first: 100,
533+
after,
534+
};
535+
536+
const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables);
537+
538+
if (!result?.data?.repository?.assignableUsers) {
539+
break;
540+
}
541+
542+
const data = result.data.repository.assignableUsers;
543+
actors.push(...data.nodes);
544+
hasNextPage = data.pageInfo.hasNextPage;
545+
after = data.pageInfo.endCursor;
546+
}
547+
548+
return actors;
549+
}

src/platform/github/common/githubService.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ICAPIClientService } from '../../endpoint/common/capiClient';
1111
import { ILogService } from '../../log/common/logService';
1212
import { IFetcherService } from '../../networking/common/fetcherService';
1313
import { ITelemetryService } from '../../telemetry/common/telemetry';
14-
import { addPullRequestCommentGraphQLRequest, closePullRequest, getPullRequestFromGlobalId, makeGitHubAPIRequest, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI';
14+
import { addPullRequestCommentGraphQLRequest, AssignableActor, closePullRequest, getPullRequestFromGlobalId, makeGitHubAPIRequest, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI';
1515

1616
/**
1717
* Options for controlling authentication behavior in OctoKit service methods.
@@ -349,6 +349,16 @@ export interface IOctoKitService {
349349
* @returns An array of available models. The first model is always 'Auto' and should be the default.
350350
*/
351351
getCopilotAgentModels(authOptions: AuthOptions): Promise<CCAModel[]>;
352+
353+
/**
354+
* Gets the list of assignable actors (users/bots) for a repository.
355+
* This is used to check if partner agents like Copilot are available for assignment.
356+
* @param owner The repository owner
357+
* @param repo The repository name
358+
* @param authOptions - Authentication options. By default, uses silent auth and throws {@link PermissiveAuthRequiredError} if not authenticated.
359+
* @returns An array of assignable actors with their login names
360+
*/
361+
getAssignableActors(owner: string, repo: string, authOptions: AuthOptions): Promise<AssignableActor[]>;
352362
}
353363

354364
/**
@@ -360,9 +370,9 @@ export interface IOctoKitService {
360370
export class BaseOctoKitService {
361371
constructor(
362372
protected readonly _capiClientService: ICAPIClientService,
363-
private readonly _fetcherService: IFetcherService,
373+
protected readonly _fetcherService: IFetcherService,
364374
protected readonly _logService: ILogService,
365-
private readonly _telemetryService: ITelemetryService
375+
protected readonly _telemetryService: ITelemetryService
366376
) { }
367377

368378
async getCurrentAuthedUserWithToken(token: string): Promise<IOctoKitUser | undefined> {

src/platform/github/common/octoKitServiceImpl.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ICAPIClientService } from '../../endpoint/common/capiClient';
88
import { ILogService } from '../../log/common/logService';
99
import { IFetcherService } from '../../networking/common/fetcherService';
1010
import { ITelemetryService } from '../../telemetry/common/telemetry';
11-
import { PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI';
11+
import { AssignableActor, getAssignableActorsWithAssignableUsers, getAssignableActorsWithSuggestedActors, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI';
1212
import { BaseOctoKitService, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService';
1313

1414
export class OctoKitService extends BaseOctoKitService implements IOctoKitService {
@@ -374,4 +374,44 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic
374374
return [];
375375
}
376376
}
377+
378+
async getAssignableActors(owner: string, repo: string, authOptions: { createIfNone?: boolean }): Promise<AssignableActor[]> {
379+
const auth = (await this._authService.getGitHubSession('permissive', authOptions.createIfNone ? { createIfNone: true } : { silent: true }));
380+
if (!auth?.accessToken) {
381+
this._logService.trace('No authentication token available for getAssignableActors');
382+
throw new PermissiveAuthRequiredError();
383+
}
384+
385+
try {
386+
// Try suggestedActors first (preferred API)
387+
const actors = await getAssignableActorsWithSuggestedActors(
388+
this._fetcherService,
389+
this._logService,
390+
this._telemetryService,
391+
this._capiClientService.dotcomAPIURL,
392+
auth.accessToken,
393+
owner,
394+
repo
395+
);
396+
397+
if (actors.length > 0) {
398+
return actors;
399+
}
400+
401+
// Fall back to assignableUsers for older GitHub Enterprise Server instances
402+
this._logService.trace('Falling back to assignableUsers API');
403+
return await getAssignableActorsWithAssignableUsers(
404+
this._fetcherService,
405+
this._logService,
406+
this._telemetryService,
407+
this._capiClientService.dotcomAPIURL,
408+
auth.accessToken,
409+
owner,
410+
repo
411+
);
412+
} catch (e) {
413+
this._logService.error(`Error fetching assignable actors: ${e}`);
414+
return [];
415+
}
416+
}
377417
}

0 commit comments

Comments
 (0)