Files
openclaw/extensions/copilot/src/user-input-bridge.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

245 lines
8.7 KiB
TypeScript
Executable File

/**
* User-input bridge for the copilot agent runtime.
*
* STATUS — MVP DORMANT: This module is intentionally NOT registered with
* the SDK in the current harness (see `attempt.ts` / `side-question.ts`).
* The SDK contract is "When `onUserInputRequest` is provided, enables the
* `ask_user` tool allowing the agent to ask questions" (see
* `node_modules/@github/copilot-sdk/dist/types.d.ts` `SessionConfig`);
* by omitting the handler we hide `ask_user` from the model entirely.
* Agents under the MVP must make best-judgment decisions from the
* initial prompt rather than asking clarifying questions mid-turn.
*
* FOLLOW-UP: The scaffolding below stays in tree so the follow-up that
* ports the codex user-input-bridge pattern
* (`extensions/codex/src/app-server/user-input-bridge.ts`) has a stable
* surface to wire — that change will route SDK `UserInputRequest`s
* through `params.onBlockReply` / `onPartialReply` and resolve the
* pending promise from the next inbound channel message, then register
* `createUserInputBridge(delegatingUserInputPolicy(...))` from
* `createSessionConfig`.
*
* BACK-POINTER: The host-side channel/TUI prompt flow lives outside
* this package boundary in `commitments/` and the channel plugins
* (slack/discord/cli/tui). Per proposal §50, this bridge does NOT
* import that flow directly (the package boundary
* `tsconfig.package-boundary.base.json` only allows
* `openclaw/plugin-sdk/*` and `@github/copilot-sdk`). Instead, this
* module:
*
* 1. Defines a small `CopilotUserInputPolicy` contract that the
* core wiring layer implements to forward `UserInputRequest`s to
* the host's channel/TUI prompt path.
* 2. Provides built-in policies for common defaults (deny-all with a
* synthetic answer, auto-first-choice, static-answer).
* 3. Provides a `delegatingUserInputPolicy({ onRequest })` so the
* core wiring layer can plug in a host-side callback that calls
* into `commitments/` and returns the SDK-shaped response.
* 4. Adapts the resulting policy into the SDK's `UserInputHandler`
* shape via `createUserInputBridge(policy)`.
*
* SDK contract note: unlike `PermissionHandler` (which has a
* `no-result` escape hatch), `UserInputHandler` MUST resolve with a
* `UserInputResponse`. The bridge therefore never returns `undefined`
* to the SDK; if a policy returns `undefined` or throws, the default
* fail-closed answer is used so the model sees a real string rather
* than a generic RPC failure.
*
* If the host's prompt contract changes materially, the contract here
* must be revisited in lockstep. The unit tests in
* `user-input-bridge.test.ts` exercise the SDK-shaped response envelope
* so any silent drift in the SDK type is caught at typecheck.
*/
import type { SessionConfig } from "@github/copilot-sdk";
type UserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type SdkUserInputRequest = Parameters<UserInputHandler>[0];
type SdkUserInputResponse = Awaited<ReturnType<UserInputHandler>>;
/** Request shape forwarded to host-implemented user-input policies. */
export interface CopilotUserInputContext {
/** SDK session id that originated the request. */
sessionId: string;
/** Original SDK request payload. */
request: SdkUserInputRequest;
}
/**
* Policy contract. Implementors return an SDK-shaped response (or a
* Promise of one).
*
* Returning `undefined` is treated as "no opinion" and falls through
* to the default fail-closed response (`DENY_ALL_ANSWER`). This keeps
* composition trivial without requiring explicit responses from every
* code path.
*/
export type CopilotUserInputPolicy = (
ctx: CopilotUserInputContext,
) => SdkUserInputResponse | undefined | Promise<SdkUserInputResponse | undefined>;
/**
* Default answer used when no host policy provides one. The string is
* intentionally explicit so the model can detect the missing-prompt
* condition rather than treating it as a real user answer.
*/
export const DENY_ALL_ANSWER =
"[copilot agent runtime: no user-input policy installed; request declined]";
export const denyAllUserInputPolicy: CopilotUserInputPolicy = () => ({
answer: DENY_ALL_ANSWER,
wasFreeform: true,
});
/**
* Auto-pick the first choice if the request offers choices; otherwise
* fall back to `DENY_ALL_ANSWER` as a freeform answer. Useful for
* non-interactive test runs.
*/
export const firstChoicePolicy: CopilotUserInputPolicy = ({ request }) => {
if (request.choices && request.choices.length > 0) {
return { answer: request.choices[0], wasFreeform: false };
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
export interface StaticAnswerPolicyOptions {
/** Answer returned for every request. */
answer: string;
/**
* Whether the answer should be flagged as a freeform response.
* Defaults to `true` (caller did not pick from `choices`).
*/
wasFreeform?: boolean;
}
/** Always return a fixed answer. Useful for deterministic tests. */
export function staticAnswerPolicy(options: StaticAnswerPolicyOptions): CopilotUserInputPolicy {
const wasFreeform = options.wasFreeform ?? true;
return () => ({ answer: options.answer, wasFreeform });
}
export interface DelegatingUserInputPolicyOptions {
/**
* Host-supplied callback. Returning `undefined` falls through to the
* fail-closed default. Throwing falls back to the configured
* `onError` policy if provided; otherwise the throw is converted to
* a `DENY_ALL_ANSWER` response so the SDK never sees an exception
* (which would surface as a generic RPC failure to the model).
*/
onRequest: CopilotUserInputPolicy;
/**
* Optional fallback when `onRequest` throws. If omitted, throws are
* converted to a `DENY_ALL_ANSWER` response with the error message
* appended. If supplied and `onError` also throws, fall through to
* the error-message response.
*/
onError?: CopilotUserInputPolicy;
}
/**
* Wrap a host callback into a policy, catching synchronous throws and
* async rejections so the SDK never sees an exception.
*/
export function delegatingUserInputPolicy(
options: DelegatingUserInputPolicyOptions,
): CopilotUserInputPolicy {
const { onRequest, onError } = options;
return async (ctx) => {
try {
const result = await onRequest(ctx);
if (result !== undefined) {
return result;
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
} catch (error) {
if (onError) {
try {
const fallback = await onError(ctx);
if (fallback !== undefined) {
return fallback;
}
} catch {
// fall through to error-message response
}
}
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
};
}
/**
* Compose policies in order. The first policy to return a non-undefined
* result wins. If all return undefined, a fail-closed `DENY_ALL_ANSWER`
* response is produced. Throws inside any policy short-circuit to the
* error-message response; downstream policies are not consulted after a
* throw.
*/
export function composeUserInputPolicies(
...policies: CopilotUserInputPolicy[]
): CopilotUserInputPolicy {
return async (ctx) => {
for (const policy of policies) {
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
/**
* Adapt a `CopilotUserInputPolicy` to the SDK's `UserInputHandler`
* shape. The returned handler always resolves with a valid
* `UserInputResponse` (never throws, never returns undefined),
* defaulting to `DENY_ALL_ANSWER` when the policy returns undefined or
* throws.
*/
export function createUserInputBridge(
policy: CopilotUserInputPolicy = denyAllUserInputPolicy,
): UserInputHandler {
return async (
request: SdkUserInputRequest,
invocation: { sessionId: string },
): Promise<SdkUserInputResponse> => {
const ctx: CopilotUserInputContext = {
request,
sessionId: invocation.sessionId,
};
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}