mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 02:52:55 +00:00
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>
245 lines
8.7 KiB
TypeScript
Executable File
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);
|
|
}
|
|
}
|