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
8 changes: 4 additions & 4 deletions .github/workflows/daily-rig-decomposition-bench.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/daily-rig-decomposition-bench.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ engine:
id: copilot
copilot-sdk: true
strict: true
timeout-minutes: 45
timeout-minutes: 55
checkout: false
skills:
- githubnext/rig/skills/rig@e7d6ad85cd93946a8c09ebbdc1280ca62abd9de5
Expand Down
75 changes: 67 additions & 8 deletions skills/rig/rig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,39 @@ function resolveDefaultCopilotUri(): string {
return process.env["COPILOT_SDK_URI"] ?? "localhost:7777";
}

function resolveDefaultCopilotConnection(): NonNullable<CopilotClientOptions["connection"]> {
const connectionToken = process.env["COPILOT_CONNECTION_TOKEN"];
return connectionToken
? RuntimeConnection.forUri(resolveDefaultCopilotUri(), { connectionToken })
: RuntimeConnection.forUri(resolveDefaultCopilotUri());
}

type CopilotMultiProvider = {
model: string;
providers: unknown[];
models: unknown[];
};

function resolveCopilotMultiProvider(): CopilotMultiProvider | undefined {
const raw = process.env["GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON"];
if (!raw) {
return undefined;
}
try {
const value = JSON.parse(raw) as Partial<CopilotMultiProvider>;
return typeof value.model === "string" && Array.isArray(value.providers) && Array.isArray(value.models)
? { model: value.model, providers: value.providers, models: value.models }
: undefined;
} catch {
return undefined;
}
}

function copilotSendTimeout(): number {
const timeout = Number(process.env["COPILOT_SDK_SEND_TIMEOUT_MS"]);
return Number.isFinite(timeout) && timeout > 0 ? timeout : 24 * 60 * 60 * 1000;
}

type DefaultEngineKind = "copilot" | "anthropic" | "codex" | "gemini";

type DefaultEngineOptions = {
Expand Down Expand Up @@ -570,16 +603,18 @@ function defaultAgentFactory(options: DefaultEngineOptions = {}): AgentFactory {

export function copilotEngine(options: CopilotEngineOptions = {}): AgentFactory {
const { server, connection, ...clientOptions } = options;
const multiProvider = resolveCopilotMultiProvider();
return async (agentOptions) => {
debugCopilotCreate({ model: agentOptions.model, transport: connection ? "custom" : server ? "stdio" : "uri" });
const client = new CopilotClient({
...clientOptions,
connection: connection ?? (server ? RuntimeConnection.forStdio() : RuntimeConnection.forUri(resolveDefaultCopilotUri())),
connection: connection ?? (server ? RuntimeConnection.forStdio() : resolveDefaultCopilotConnection()),
});
const session = await client.createSession({
model: agentOptions.model,
model: multiProvider?.model ?? agentOptions.model,
streaming: false,
onPermissionRequest: approveAll,
...(multiProvider ? { providers: multiProvider.providers, models: multiProvider.models } as any : {}),
...(agentOptions.systemMessage !== undefined && { systemMessage: agentOptions.systemMessage as any }),
...(agentOptions.tools !== undefined && { tools: agentOptions.tools as any }),
});
Expand All @@ -590,12 +625,18 @@ export function copilotEngine(options: CopilotEngineOptions = {}): AgentFactory
return {
async ask(prompt, askOptions = {}) {
debugCopilotAsk({ prompt, structured: askOptions.outputSchema !== undefined });
const response = await (session.sendAndWait as any)(
{
prompt,
...(askOptions.signal ? { signal: askOptions.signal } : {}),
...(askOptions.outputSchema !== undefined ? { outputSchema: askOptions.outputSchema } : {}),
},
throwIfAborted(askOptions.signal);
const response = await abortable(
(session.sendAndWait as any)(
{
prompt,
...(askOptions.signal ? { signal: askOptions.signal } : {}),
...(askOptions.outputSchema !== undefined ? { outputSchema: askOptions.outputSchema } : {}),
},
copilotSendTimeout(),
),
askOptions.signal,
() => (session as any).abort?.(),
);
const text = responseText(response);
debugCopilotResponse({ response: text });
Expand Down Expand Up @@ -3283,6 +3324,24 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}

function abortable<T>(promise: Promise<T>, signal?: AbortSignal, onAbort?: () => void): Promise<T> {
throwIfAborted(signal);
if (!signal) {
return promise;
}
return new Promise<T>((resolve, reject) => {
const abort = () => {
try {
onAbort?.();
} finally {
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
}
};
signal.addEventListener("abort", abort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}

function timeoutSignal(parent?: AbortSignal, timeout?: number): AbortSignal | undefined {
if (!timeout) {
return parent;
Expand Down
48 changes: 48 additions & 0 deletions src/engines/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ beforeEach(() => {
mocks.forStdio.mockImplementation(() => ({ kind: "stdio" }));
mocks.copilotClientCtor.mockClear();
delete process.env["COPILOT_SDK_URI"];
delete process.env["COPILOT_CONNECTION_TOKEN"];
delete process.env["GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON"];
delete process.env["COPILOT_SDK_SEND_TIMEOUT_MS"];
vi.restoreAllMocks();
});

Expand All @@ -58,6 +61,51 @@ it("uses COPILOT_SDK_URI when set", async () => {
expect(mocks.copilotClientCtor).toHaveBeenCalledWith({ connection: { kind: "uri", url: "http://127.0.0.1:4141" } });
});

it("uses agentic workflow SDK connection and provider settings", async () => {
process.env["COPILOT_CONNECTION_TOKEN"] = "connection-token";
process.env["GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON"] = JSON.stringify({
model: "claude-sonnet-4.6",
providers: [{ name: "copilot" }],
models: [{ id: "claude-sonnet-4.6" }],
});

await copilotEngine()({ model: "small" });

expect(mocks.forUri).toHaveBeenCalledWith("localhost:7777", { connectionToken: "connection-token" });
expect(mocks.createSession).toHaveBeenCalledWith({
model: "claude-sonnet-4.6",
streaming: false,
onPermissionRequest: mocks.approveAll,
providers: [{ name: "copilot" }],
models: [{ id: "claude-sonnet-4.6" }],
});
});

it("uses the configured SDK send timeout", async () => {
process.env["COPILOT_SDK_SEND_TIMEOUT_MS"] = "120000";
const sendAndWait = vi.fn().mockResolvedValue({ data: { content: "ok" } });
mocks.createSession.mockResolvedValue({ sendAndWait, disconnect: vi.fn() });
const implementation = await copilotEngine()({ model: "small" });

await implementation.ask("hello");

expect(sendAndWait).toHaveBeenCalledWith({ prompt: "hello" }, 120000);
});

it("aborts an in-flight SDK request when its signal aborts", async () => {
const abort = vi.fn();
const sendAndWait = vi.fn(() => new Promise(() => {}));
mocks.createSession.mockResolvedValue({ sendAndWait, abort, disconnect: vi.fn() });
const implementation = await copilotEngine()({ model: "small" });
const controller = new AbortController();
const request = implementation.ask("hello", { signal: controller.signal });

controller.abort(new Error("cancelled"));

await expect(request).rejects.toThrow("cancelled");
expect(abort).toHaveBeenCalledOnce();
});

it("preserves explicit client options", async () => {
const connection = { kind: "uri", url: "127.0.0.1:8765" } as const;

Expand Down