refactor(copilot): use SDK contracts (#106208)

This commit is contained in:
Peter Steinberger
2026-07-13 04:00:17 -07:00
committed by GitHub
parent bd9bedb201
commit fa963eee06
5 changed files with 13 additions and 238 deletions

View File

@@ -31,10 +31,6 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createCopilotByokAuth, resolveCopilotAuth } from "./auth-bridge.js";
import { createCopilotByokProxy } from "./byok-proxy.js";
import {
createInfiniteSessionConfig,
type CopilotInfiniteSessionOptions,
} from "./compaction-bridge.js";
import {
attachCopilotMirrorIdentity,
dualWriteCopilotTranscriptBestEffort,
@@ -104,7 +100,7 @@ type AttemptParamsLike = AgentHarnessAttemptParams & {
cwd?: string;
enableSessionTelemetry?: boolean;
hooksConfig?: CopilotHooksConfig;
infiniteSessionConfig?: CopilotInfiniteSessionOptions;
infiniteSessionConfig?: SessionConfig["infiniteSessions"];
initialReplayState?: AgentHarnessAttemptParams["initialReplayState"] & { sdkSessionId?: string };
messages?: AgentMessage[];
model?: string | { api?: string; id?: string; input?: string[]; provider?: string };
@@ -1331,7 +1327,6 @@ function createSessionConfig(
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
const hooks = createHooksBridge(params.hooksConfig, options.hooksBridgeOptions);
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
return {
model: sdkModelId,
// Permission decisions for SDK built-in tool kinds (shell, write,
@@ -1371,11 +1366,8 @@ function createSessionConfig(
...(typeof params.enableSessionTelemetry === "boolean"
? { enableSessionTelemetry: params.enableSessionTelemetry }
: {}),
// Infinite sessions / background compaction: only attach when the
// host provided an InfiniteSessionConfig. SDK defaults
// (`enabled: true`, background 0.80, buffer 0.95) apply when
// omitted. See compaction-bridge.ts.
...(infiniteSessions ? { infiniteSessions } : {}),
// The SDK owns defaulting and validation for this native config block.
...(params.infiniteSessionConfig ? { infiniteSessions: params.infiniteSessionConfig } : {}),
reasoningEffort: params.reasoningEffort,
tools: sdkTools,
// Restrict the SDK's tool catalog to the bridged tool names returned

View File

@@ -1,59 +0,0 @@
// Copilot tests cover compaction bridge plugin behavior.
import { describe, expect, it } from "vitest";
import { createInfiniteSessionConfig } from "./compaction-bridge.js";
describe("createInfiniteSessionConfig", () => {
it("returns undefined when no options provided", () => {
expect(createInfiniteSessionConfig()).toBeUndefined();
expect(createInfiniteSessionConfig(undefined)).toBeUndefined();
});
it("returns undefined when options is an empty object", () => {
expect(createInfiniteSessionConfig({})).toBeUndefined();
});
it("preserves explicit enabled:false to disable infinite sessions", () => {
expect(createInfiniteSessionConfig({ enabled: false })).toEqual({ enabled: false });
});
it("preserves explicit enabled:true", () => {
expect(createInfiniteSessionConfig({ enabled: true })).toEqual({ enabled: true });
});
it("forwards threshold fields when set", () => {
expect(
createInfiniteSessionConfig({
backgroundCompactionThreshold: 0.7,
bufferExhaustionThreshold: 0.9,
}),
).toEqual({
backgroundCompactionThreshold: 0.7,
bufferExhaustionThreshold: 0.9,
});
});
it("combines enabled and thresholds", () => {
expect(
createInfiniteSessionConfig({
enabled: true,
backgroundCompactionThreshold: 0.5,
bufferExhaustionThreshold: 0.85,
}),
).toEqual({
enabled: true,
backgroundCompactionThreshold: 0.5,
bufferExhaustionThreshold: 0.85,
});
});
it("omits undefined fields without coercing them", () => {
const result = createInfiniteSessionConfig({
enabled: undefined,
backgroundCompactionThreshold: 0.6,
bufferExhaustionThreshold: undefined,
});
expect(result).toEqual({ backgroundCompactionThreshold: 0.6 });
expect(result).not.toHaveProperty("enabled");
expect(result).not.toHaveProperty("bufferExhaustionThreshold");
});
});

View File

@@ -1,51 +0,0 @@
// Copilot plugin module implements compaction bridge behavior.
import type { SessionConfig } from "@github/copilot-sdk";
// Compaction bridge for the GitHub Copilot agent runtime.
//
// Shapes `SessionConfig.infiniteSessions` from a typed options bag so
// attempt.ts can opt the SDK in to background auto-compaction at session
// creation. The SDK manages the actual compaction under the `infiniteSessions`
// config and the session-scoped history compaction RPC.
//
// Host back-pointers (NOT imported here to keep the package boundary
// clean):
// - `src/agents/pi-embedded-runner/compact.types.ts` — canonical
// `CompactEmbeddedPiSessionParams`.
// - `src/agents/pi-embedded-runner/types.ts` — canonical
// `EmbeddedPiCompactResult`.
type SdkInfiniteSessionConfig = NonNullable<SessionConfig["infiniteSessions"]>;
export interface CopilotInfiniteSessionOptions {
enabled?: boolean;
backgroundCompactionThreshold?: number;
bufferExhaustionThreshold?: number;
}
/**
* Shape an `InfiniteSessionConfig` for `SessionConfig.infiniteSessions`.
* Returns `undefined` when no fields were supplied so callers can
* spread conditionally and let the SDK apply its own defaults
* (`enabled: true`, background 0.80, buffer 0.95). Any explicitly-set
* value (including `enabled: false` to disable infinite sessions) is
* preserved.
*/
export function createInfiniteSessionConfig(
options?: CopilotInfiniteSessionOptions,
): SdkInfiniteSessionConfig | undefined {
if (!options) {
return undefined;
}
const result: SdkInfiniteSessionConfig = {};
if (options.enabled !== undefined) {
result.enabled = options.enabled;
}
if (options.backgroundCompactionThreshold !== undefined) {
result.backgroundCompactionThreshold = options.backgroundCompactionThreshold;
}
if (options.bufferExhaustionThreshold !== undefined) {
result.bufferExhaustionThreshold = options.bufferExhaustionThreshold;
}
return Object.keys(result).length > 0 ? result : undefined;
}

View File

@@ -1475,15 +1475,6 @@ describe("convertOpenClawToolToSdkTool", () => {
expect(getError(result as ToolResultObject)).toBe(error.message);
});
it("returns success with empty text when content is missing", async () => {
const sourceTool = makeTool({}, { details: null });
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {});
const result = await runSdkTool(sdkTool, {});
expect(result).toEqual({ resultType: "success", textResultForLlm: "" });
});
it("converts single text content to an exact textResultForLlm", async () => {
const onAgentToolResult = vi.fn();
const sourceResult = {
@@ -1650,7 +1641,6 @@ describe("convertOpenClawToolToSdkTool", () => {
expect(result).toEqual({
binaryResultsForLlm: [
{
base64Data: "base64-data",
data: "base64-data",
mimeType: "image/png",
type: "image",
@@ -1661,30 +1651,6 @@ describe("convertOpenClawToolToSdkTool", () => {
});
});
it("returns a failure result for unsupported content shapes", async () => {
const onAgentToolResult = vi.fn();
const sourceResult = {
content: [{ type: "resource" }],
details: null,
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), { onAgentToolResult });
const result = await runSdkTool(sdkTool, {});
expect(result).toMatchObject({
resultType: "failure",
textResultForLlm: "[copilot-tool-bridge] unsupported AgentToolResult content shape: resource",
});
expect(getError(result as ToolResultObject)).toBe(
"[copilot-tool-bridge] unsupported AgentToolResult content shape: resource",
);
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "tool-a",
result: sourceResult,
isError: true,
});
});
it("returns a failure result when execute throws and preserves the error", async () => {
const error = new Error("tool exploded");
const sourceTool = makeTool({

View File

@@ -1,5 +1,10 @@
// Copilot plugin module implements tool bridge behavior.
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
import {
convertMcpCallToolResult,
type Tool as SdkTool,
type ToolInvocation,
type ToolResultObject,
} from "@github/copilot-sdk";
import type {
AnyAgentTool,
EmbeddedRunAttemptParams,
@@ -27,10 +32,6 @@ type CatalogExecuteParams = Parameters<
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
>[0];
type AgentToolResultLike = {
content?: unknown;
};
/**
* Mutable holder populated by `attempt.ts` *after* `client.createSession()`
* (or `client.resumeSession()`) succeeds, so that the tool bridge — which is
@@ -552,7 +553,7 @@ export function convertOpenClawToolToSdkTool(
);
}
let result: AgentToolResultLike;
let result: Awaited<ReturnType<AnyAgentTool["execute"]>>;
try {
result = await sourceTool.execute(
invocation.toolCallId,
@@ -570,7 +571,9 @@ export function convertOpenClawToolToSdkTool(
);
}
const sdkResult = agentToolResultToSdk(result);
// OpenClaw tools throw for execution failures. Error-shaped details remain
// lifecycle metadata; successful content uses the SDK's MCP converter.
const sdkResult = convertMcpCallToolResult({ content: result.content });
const sanitizedResult = sanitizeToolResult(result);
const resultIsError = sdkResult.resultType === "failure" || isToolResultError(sanitizedResult);
const resultError = resultIsError ? extractToolErrorMessage(sanitizedResult) : undefined;
@@ -694,72 +697,6 @@ function toToolStartArgs(args: unknown): Record<string, unknown> {
: { value: args };
}
function agentToolResultToSdk(result: AgentToolResultLike | undefined): ToolResultObject {
const content = result?.content;
if (content == null) {
return createSuccessResult("");
}
if (!Array.isArray(content)) {
return createUnsupportedContentFailure(typeof content);
}
const textParts: string[] = [];
const binaryResults: Array<Record<string, string>> = [];
for (const block of content) {
if (!block || typeof block !== "object") {
return createUnsupportedContentFailure(typeof block);
}
const kind = readString((block as { type?: unknown }).type);
if (kind === "text") {
const text = readString((block as { text?: unknown }).text, { allowEmpty: true });
if (text === undefined) {
return createUnsupportedContentFailure(kind);
}
textParts.push(text);
continue;
}
if (kind === "image") {
const base64Data = readString((block as { data?: unknown }).data);
const mimeType = readString((block as { mimeType?: unknown }).mimeType);
if (!base64Data || !mimeType) {
return createUnsupportedContentFailure(kind);
}
binaryResults.push({
base64Data,
data: base64Data,
mimeType,
type: "image",
});
continue;
}
return createUnsupportedContentFailure(kind ?? typeof block);
}
return {
...(binaryResults.length > 0
? { binaryResultsForLlm: binaryResults as ToolResultObject["binaryResultsForLlm"] }
: {}),
resultType: "success",
textResultForLlm: textParts.join("\n"),
};
}
function createUnsupportedContentFailure(kind: string): ToolResultObject {
const message = `[copilot-tool-bridge] unsupported AgentToolResult content shape: ${kind}`;
return createFailureResult(message, new Error(message));
}
function createSuccessResult(textResultForLlm: string): ToolResultObject {
return {
resultType: "success",
textResultForLlm,
};
}
function createFailureResult(message: string, error: unknown): ToolResultObject {
// ToolResultObject.error is typed as `string | undefined` in the SDK contract
// (see `node_modules/@github/copilot-sdk/dist/types.d.ts`). Returning an
@@ -871,16 +808,6 @@ function findDuplicateToolNames(sourceTools: AnyAgentTool[]): string[] {
.toSorted();
}
function readString(value: unknown, options: { allowEmpty?: boolean } = {}): string | undefined {
if (typeof value !== "string") {
return undefined;
}
if (options.allowEmpty || value.length > 0) {
return value;
}
return undefined;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}