Files
openclaw/extensions/copilot/src/attempt.ts
2026-07-02 16:05:49 -07:00

1746 lines
66 KiB
TypeScript

// Copilot plugin module implements attempt behavior.
import fsp from "node:fs/promises";
import type { MessageOptions, SessionConfig, Tool as SdkTool } from "@github/copilot-sdk";
import type {
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
AgentMessage,
SandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
detectAndLoadAgentHarnessPromptImages,
getModelProviderRequestTransport,
resolveAgentHarnessBeforePromptBuildResult,
resolveAttemptFsWorkspaceOnly,
resolveAttemptSpawnWorkspaceDir,
resolveCompactionTimeoutMs,
resolveSandboxContext as defaultResolveSandboxContext,
resolveSessionAgentIds,
resolveUserPath,
runAgentHarnessAfterToolCallHook,
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
awaitAgentEndSideEffects,
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
clearActiveEmbeddedRun,
setActiveEmbeddedRun,
} 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,
} from "./dual-write-transcripts.js";
import {
attachEventBridge,
type AssistantMessage,
type AssistantUsageSnapshot,
type OnAssistantDeltaPayload,
type SessionLike,
} from "./event-bridge.js";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
createPermissionBridge,
rejectAllPolicy,
type CopilotPermissionPolicy,
} from "./permission-bridge.js";
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
import {
classifyResumeFailure,
computeReplayMetadata,
copilotToolMetasHavePotentialSideEffects,
decideReplayAction,
} from "./replay-shim.js";
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
import { createCopilotToolBridge } from "./tool-bridge.js";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
type CopilotAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
export type CopilotSessionConfig = Pick<
SessionConfig,
| "availableTools"
| "enableSessionTelemetry"
| "gitHubToken"
| "hooks"
| "instructionDirectories"
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "onUserInputRequest"
| "provider"
| "reasoningEffort"
| "systemMessage"
| "tools"
| "workingDirectory"
>;
// NOTE(plugin-sdk-widening): AttemptParamsLike can be removed once
// openclaw/plugin-sdk/agent-harness-runtime declares auth, messages,
// onAssistantDelta, and initialReplayState.sdkSessionId fields. Tracked by
// project openclaw-copilot-harness; reviewer-attempt-bridge note.
type AttemptParamsLike = AgentHarnessAttemptParams & {
auth?: {
gitHubToken?: string;
profileId?: string;
profileVersion?: string;
useLoggedInUser?: boolean;
};
copilotHome?: string;
cwd?: string;
enableSessionTelemetry?: boolean;
hooksConfig?: CopilotHooksConfig;
infiniteSessionConfig?: CopilotInfiniteSessionOptions;
initialReplayState?: AgentHarnessAttemptParams["initialReplayState"] & { sdkSessionId?: string };
messages?: AgentMessage[];
model?: string | { api?: string; id?: string; input?: string[]; provider?: string };
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
permissionPolicy?: CopilotPermissionPolicy;
profileVersion?: string;
reasoningEffort?: "low" | "medium" | "high" | "xhigh";
// User-visible prompt body (when distinct from `prompt`, which may
// include runtime-expanded context). Used when synthesizing the
// current-turn user message for the OpenClaw audit transcript so
// dashboard/CLI history shows what the user actually typed, not the
// internal expansion. Symmetric to `EmbeddedRunAttemptParams.transcriptPrompt`.
transcriptPrompt?: string;
};
type ModelRef = {
api?: string;
id: string;
provider: string;
baseUrl?: string;
azureApiVersion?: string;
headers?: Record<string, string | null | undefined>;
authHeader?: boolean;
requestAuthMode?: string;
requestProxy?: unknown;
requestTls?: unknown;
requestAllowPrivateNetwork?: unknown;
contextTokens?: number;
contextWindow?: number;
maxTokens?: number;
};
type ModelRefInputObject = {
api?: unknown;
id?: unknown;
provider?: unknown;
baseUrl?: unknown;
azureApiVersion?: unknown;
params?: { azureApiVersion?: unknown };
headers?: ModelRef["headers"];
authHeader?: boolean;
request?: {
auth?: { mode?: unknown };
proxy?: unknown;
tls?: unknown;
allowPrivateNetwork?: unknown;
};
contextTokens?: number;
contextWindow?: number;
maxTokens?: number;
};
export type { AttemptParamsLike as CopilotPoolAcquireInput, ModelRef };
export { SUPPORTED_PROVIDERS };
export type ResolveSandboxContextFn = typeof defaultResolveSandboxContext;
export interface CopilotAttemptDeps {
pool: CopilotClientPool;
now?: () => number;
createToolBridge?: typeof createCopilotToolBridge;
/**
* Optional override for sandbox-context resolution. The default delegates to
* `openclaw/plugin-sdk/agent-harness-runtime#resolveSandboxContext`, which is
* the same path PI uses. Tests inject a stub here to avoid the real
* resolver's side effects (container provisioning, registry writes).
*/
resolveSandboxContextOverride?: ResolveSandboxContextFn;
/**
* Called once with the SDK session id and pooled client immediately
* after the SDK session is created (or resumed) successfully. The
* harness uses this to track the openclawSessionId -> sdkSessionId
* mapping needed for `reset(params)` (see harness.ts). Exceptions
* thrown from this callback are swallowed so they cannot break the
* attempt.
*/
onSessionEstablished?: (info: {
compactionSessionConfig?: CopilotSessionConfig;
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => void;
/**
* Called before an attempt retains its live SDK session to observe background
* compaction. The harness must prevent that session ID from being resumed
* until cleanup completes.
*/
onDeferredCompaction?: (info: {
abort: () => void;
cleanup: Promise<"aborted" | "completed" | "deadline">;
sdkSessionId: string;
}) => void;
}
async function runCopilotAgentEndHook(
params: AttemptParamsLike,
hookParams: CopilotAgentEndHookParams,
): Promise<void> {
if (!params.messageChannel && !params.messageProvider) {
await awaitAgentEndSideEffects(hookParams);
return;
}
runAgentEndSideEffects(hookParams);
}
async function finalizeCopilotAttempt(
params: AttemptParamsLike,
result: AgentHarnessAttemptResult,
ctx: CopilotAgentEndHookParams["ctx"],
attemptStartedAt: number,
now: () => number,
): Promise<AgentHarnessAttemptResult> {
await runCopilotAgentEndHook(params, {
event: {
messages: result.messagesSnapshot,
success: !result.aborted && !result.promptError && !result.timedOut,
...(result.promptError
? { error: toError(result.promptError).message }
: result.timedOut
? { error: "Copilot SDK turn timed out." }
: {}),
durationMs: now() - attemptStartedAt,
},
ctx,
});
return result;
}
async function awaitDeferredCleanupCompletionOrAbort(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
}): Promise<"aborted" | "completed"> {
const awaitCompletion = async () => {
if (params.awaitSessionIdle) {
await params.bridge.awaitSessionIdle();
}
await params.bridge.awaitCompactionCompletion();
};
if (!params.abortSignal) {
await awaitCompletion();
return "completed";
}
if (params.abortSignal.aborted) {
return "aborted";
}
let resolveAbort: () => void = () => undefined;
const aborted = new Promise<"aborted">((resolve) => {
resolveAbort = () => resolve("aborted");
});
params.abortSignal.addEventListener("abort", resolveAbort, { once: true });
try {
return await Promise.race([awaitCompletion().then(() => "completed" as const), aborted]);
} finally {
params.abortSignal.removeEventListener("abort", resolveAbort);
}
}
function deferBackgroundCompactionCleanup(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
cleanupByokProxy?: () => Promise<void>;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
}): Promise<"aborted" | "completed" | "deadline"> {
// The SDK can compact after its turn result or a timeout. Keep the bridge
// attached so after_compaction uses the originating run context.
return (async () => {
let outcome: "aborted" | "completed" | "deadline" = "deadline";
try {
outcome = await awaitDeferredCleanupBeforeDeadline({
abortSignal: params.abortSignal,
awaitSessionIdle: params.awaitSessionIdle,
bridge: params.bridge,
timeoutMs: params.timeoutMs,
});
} catch {
// Event callbacks are best-effort; cleanup still releases the retained session.
} finally {
if (outcome !== "completed") {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.finalizeNativeSubagents?.();
params.bridge.detach();
try {
await params.session.disconnect();
} catch {
// The attempt has already returned its timeout result.
}
params.cleanupToolBridge?.();
await params.cleanupByokProxy?.();
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
} catch {
// The timeout path intentionally discards this SDK session either way.
}
}
try {
await params.pool.release(params.handle);
} catch {
// The pool will dispose this client later if its release cannot complete.
}
}
return outcome;
})();
}
async function cancelBackgroundCompactionBeforeTeardown(session: SessionLike): Promise<void> {
const cancelBackgroundCompaction = session.rpc?.history?.cancelBackgroundCompaction;
if (!cancelBackgroundCompaction) {
return;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS);
});
try {
await Promise.race([
Promise.resolve()
.then(() => cancelBackgroundCompaction())
.catch(() => undefined),
deadline,
]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
async function awaitDeferredCleanupBeforeDeadline(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
timeoutMs: number;
}): Promise<"aborted" | "completed" | "deadline"> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<"deadline">((resolve) => {
timeoutId = setTimeout(() => resolve("deadline"), params.timeoutMs);
});
try {
return await Promise.race([awaitDeferredCleanupCompletionOrAbort(params), deadline]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
export async function runCopilotAttempt(
params: AgentHarnessAttemptParams,
deps: CopilotAttemptDeps,
): Promise<AgentHarnessAttemptResult> {
const now = deps.now ?? Date.now;
const attemptStartedAt = now();
const input = params as AttemptParamsLike;
const createToolBridge = deps.createToolBridge ?? createCopilotToolBridge;
const messages = getMessagesSnapshotInput(input);
const modelRef = resolveModelRef(input);
const resolvedWorkspaceForSandbox =
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
const sandboxSessionKey =
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
readString((input as { sessionKey?: unknown }).sessionKey) ??
readString(input.sessionId);
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
config: input.config,
agentId: readString(params.agentId),
});
const hookContextWindowFields = {
...(input.contextWindowInfo?.tokens
? { contextTokenBudget: input.contextWindowInfo.tokens }
: input.contextTokenBudget
? { contextTokenBudget: input.contextTokenBudget }
: {}),
...(input.contextWindowInfo?.source
? { contextWindowSource: input.contextWindowInfo.source }
: {}),
...(input.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: input.contextWindowInfo.referenceTokens }
: {}),
};
const hookContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
workspaceDir: resolvedWorkspaceForSandbox,
modelProviderId: modelRef.provider,
modelId: modelRef.id,
trigger: input.trigger,
...(input.config ? { config: input.config } : {}),
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
if (params.abortSignal?.aborted) {
return finishAttempt(
createResult(input, {
aborted: true,
externalAbort: true,
messagesSnapshot: messages,
now,
promptError: undefined,
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
try {
resolveCopilotProvider({
model: modelRef,
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
});
} catch (error) {
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError("model_not_supported", toError(error).message, error),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
let abortRequested = false;
let aborted = false;
let externalAbort = false;
let settled = false;
let sentTurnStarted = false;
let timedOutDuringCompaction = false;
let timedOut = false;
let promptError: Error | undefined;
let sdkSessionId: string | undefined;
let sessionIdUsed = input.sessionId;
let disconnectError: Error | undefined;
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
agentId: sessionAgentId,
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
let releaseError: Error | undefined;
let downgradedFromResume = false;
let resumeFailureRecovered = false;
// True when a wrapped tool fired `sessions_yield`. Propagated into
// the final attempt result so the parent runner can mark liveness
// as paused and stop_reason as `end_turn`, matching the in-tree PI
// (`src/agents/pi-embedded-runner/run/attempt.ts:1107-1113`) and
// codex (`extensions/codex/src/app-server/run-attempt.ts:539,1739`)
// behavior. See `EmbeddedRunAttemptResult.yieldDetected` at
// `src/agents/pi-embedded-runner/run/types.ts:139`.
let yieldDetected = false;
const markExternalAbort = () => {
abortRequested = true;
externalAbort = true;
aborted = true;
};
const abortActiveSession = () => {
markExternalAbort();
if (settled || !sentTurnStarted || !session) {
return;
}
void session.abort().catch(() => undefined);
};
const onAbort = () => {
abortActiveSession();
};
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
// resolve the sandbox context using the same session-key derivation, then
// compute the workspace dir the SDK should see vs the original workspace
// spawned subagents should inherit. When sandbox is disabled (the default),
// `resolveSandboxContext` returns `null` and behavior is unchanged from the
// pre-fix path.
const resolveSandbox = deps.resolveSandboxContextOverride ?? defaultResolveSandboxContext;
let sandbox: SandboxContext | null = null;
let effectiveWorkspaceDir = resolvedWorkspaceForSandbox;
if (resolvedWorkspaceForSandbox) {
try {
sandbox = await resolveSandbox({
config: input.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspaceForSandbox,
});
effectiveWorkspaceDir = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspaceForSandbox
: sandbox.workspaceDir
: resolvedWorkspaceForSandbox;
// Only ensure the workspace exists when sandbox redirected us to a
// newly-resolved path. The original workspace is owned by the
// orchestrator (PI's runner pre-creates it before entering the
// attempt); duplicating the mkdir here would also break long-standing
// tests that pass placeholder workspaceDir values.
if (
sandbox?.enabled &&
effectiveWorkspaceDir &&
effectiveWorkspaceDir !== resolvedWorkspaceForSandbox
) {
await fsp.mkdir(effectiveWorkspaceDir, { recursive: true });
}
} catch (error: unknown) {
settled = true;
params.abortSignal?.removeEventListener("abort", onAbort);
if (abortRequested || params.abortSignal?.aborted) {
return finishAttempt(
createResult(input, {
aborted: true,
externalAbort: true,
messagesSnapshot: messages,
now,
promptError: undefined,
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"sandbox_resolution_failure",
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
error,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
}
hookContext.workspaceDir = effectiveWorkspaceDir;
const requestedCwd = readResolvedAttemptPath(input.cwd);
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspaceForSandbox) {
settled = true;
params.abortSignal?.removeEventListener("abort", onAbort);
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"sandbox_cwd_override_unsupported",
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
const effectiveCwd = sandbox?.enabled
? effectiveWorkspaceDir
: (requestedCwd ?? effectiveWorkspaceDir);
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
config: input.config,
sessionAgentId,
});
const sandboxAwareSpawnWorkspaceDir = resolvedWorkspaceForSandbox
? resolveAttemptSpawnWorkspaceDir({
sandbox,
resolvedWorkspace: resolvedWorkspaceForSandbox,
})
: undefined;
const poolAcquire = resolvePoolAcquire(input);
let byokProxy: Awaited<ReturnType<typeof createCopilotByokProxy>>;
try {
byokProxy = await createCopilotByokProxy(poolAcquire.provider);
} catch (error) {
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError("model_not_supported", toError(error).message, error),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
const cleanupByokProxy = byokProxy?.close;
const sessionProvider = byokProxy?.provider ?? poolAcquire.provider;
// Mutable session holder shared with the tool bridge so onYield
// (raised inside wrapped-tool execution) can route to the live SDK
// session's abort once it exists. The bridge is constructed before
// createSession/resumeSession resolves, so the holder is the only
// safe way to defer the binding without creating a circular dep.
// See tool-bridge.ts CopilotSessionHolder.
const sessionRef: { current: SessionLike | undefined } = { current: undefined };
try {
let sdkTools: SdkTool[];
try {
const toolBridge = await createToolBridge({
allowModelTools: poolAcquire.provider.mode === "byok",
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: readString(params.agentId) ?? "copilot",
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
// Sandbox parity (`src/agents/pi-embedded-runner/run/attempt.ts:1438-1450`):
// bridged tools see the *effective* workspace (sandbox copy when not `rw`),
// while spawned subagents inherit the *original* workspace.
workspaceDir: effectiveWorkspaceDir,
cwd: effectiveCwd,
sandbox,
spawnWorkspaceDir: sandboxAwareSpawnWorkspaceDir,
abortSignal: params.abortSignal,
// Forward the full attempt params so the wrapped-tool
// enforcement layer receives the same context PI does
// (identity, owner-only allowlist, auth-profile store,
// channel/routing, model context, run hooks). See
// tool-bridge.ts buildOpenClawCodingToolsOptions().
attemptParams: input,
sessionRef,
onYieldDetected: () => {
yieldDetected = true;
},
onToolCompleted: ({ args, error, result, startedAt, toolCallId, toolName }) =>
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),
startedAt,
}),
});
cleanupToolBridge = toolBridge.cleanup;
sdkTools = toolBridge.sdkTools;
} catch (error: unknown) {
const result = createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"tool_bridge_failure",
`[copilot-attempt] tool-bridge construction failed: ${toError(error).message}`,
error,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
return finishAttempt(result);
}
handle = await deps.pool.acquire(poolAcquire.key, poolAcquire.options);
const client = handle.client;
// Load OpenClaw workspace bootstrap files (SOUL.md, IDENTITY.md,
// HEARTBEAT.md, ...) before constructing the SDK SessionConfig so
// persona/identity/heartbeat reach the model via
// `SessionConfig.systemMessage` (append mode). Mirrors codex's
// `buildCodexWorkspaceBootstrapContext` call in run-attempt.ts.
// Failures here are non-fatal: workspace-bootstrap returns
// `instructions: undefined` and the session proceeds without the
// OpenClaw bootstrap block (SDK still loads AGENTS.md natively).
const workspaceBootstrap = await resolveCopilotWorkspaceBootstrapContext({
attempt: input,
// Pair with `createSessionConfig`'s `workingDirectory:
// effectiveWorkspaceDir` (round-8 [P1]) so bootstrap context
// paths rendered into `SessionConfig.systemMessage` reflect
// the sandbox copy when a `ro` / `none` sandbox redirected
// the workspace. Without this remap the model would see
// host-workspace paths while its native loader and bridged
// tools all operate in the sandbox copy. Mirrors PI's
// `remapInjectedContextFilesToWorkspace` call at
// `src/agents/pi-embedded-runner/run/attempt.ts:1595`.
effectiveWorkspaceDir,
warn: (message) => console.warn(message),
});
const originalDeveloperInstructions =
createSystemMessageContent(input, workspaceBootstrap.instructions) ?? "";
const promptBuild = isRawCopilotModelRun(input)
? {
prompt: input.prompt,
developerInstructions: originalDeveloperInstructions,
}
: await resolveAgentHarnessBeforePromptBuildResult({
prompt: input.prompt,
developerInstructions: originalDeveloperInstructions,
messages,
ctx: hookContext,
bootstrapContextRunKind: input.bootstrapContextRunKind,
...("beforeAgentStartResult" in input
? { beforeAgentStartResult: input.beforeAgentStartResult }
: {}),
});
const attemptInput =
promptBuild.prompt === input.prompt ? input : { ...input, prompt: promptBuild.prompt };
let promptImagesCount = 0;
const emitLlmInput = (prompt: string, additionalContext?: string) => {
runAgentHarnessLlmInputHook({
event: {
runId: input.runId,
sessionId: input.sessionId,
provider: modelRef.provider,
model: modelRef.id,
...(promptBuild.developerInstructions
? { systemPrompt: promptBuild.developerInstructions }
: {}),
prompt: additionalContext ? `${prompt}\n\n${additionalContext}` : prompt,
// Copilot SDK sessions own their own transcript. OpenClaw's
// mirrored messages are persistence state, not provider input.
historyMessages: [],
imagesCount: promptImagesCount,
tools: sdkTools,
},
ctx: hookContext,
});
};
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
const userInputBridge = createCopilotUserInputBridge({
paramsForRun: attemptInput,
signal: params.abortSignal,
});
userInputBridgeRef = userInputBridge;
const sessionConfig = createSessionConfig(
attemptInput,
modelRef.id,
sdkTools,
poolAcquire.auth,
sessionProvider,
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
emitLlmInput(prompt, additionalContext),
}
: undefined,
);
const compactionSessionConfig = byokProxy
? createSessionConfig(
attemptInput,
modelRef.id,
sdkTools,
poolAcquire.auth,
poolAcquire.provider,
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
emitLlmInput(prompt, additionalContext),
}
: undefined,
)
: sessionConfig;
const replayDecision = decideReplayAction({
sdkSessionId: input.initialReplayState?.sdkSessionId,
replayInvalid: input.initialReplayState?.replayInvalid,
});
downgradedFromResume = replayDecision.downgradedFromResume;
const resumeSessionId =
replayDecision.action === "resume" ? replayDecision.sdkSessionId : undefined;
// SAFETY: replay-shim owns the create/resume decision and the
// recovery policy when resumeSession fails. See replay-shim.ts.
// continuePendingWork is always false here so suspended tool/
// permission work cannot be replayed implicitly — replay-shim's
// worst-case-wins replayMetadata is the only signal the
// orchestrator uses to decide whether the next attempt is safe.
if (resumeSessionId) {
try {
session = (await client.resumeSession(resumeSessionId, {
...sessionConfig,
continuePendingWork: false,
})) as unknown as SessionLike;
} catch (error: unknown) {
const classification = classifyResumeFailure(error);
if (!classification.recoverable) {
throw error;
}
// Downgrade silently: the prior SDK session is gone, so start a
// fresh one. replayMetadata will reflect replaySafe:false via
// resumeFailureRecovered so the orchestrator does not blindly
// retry the same prompt with stale assumptions.
resumeFailureRecovered = true;
session = (await client.createSession(sessionConfig)) as unknown as SessionLike;
}
} else {
session = (await client.createSession(sessionConfig)) as unknown as SessionLike;
}
// Bind the session holder so the tool bridge's onYield callback
// can abort the live SDK session if a wrapped tool yields.
sessionRef.current = session;
// After a recovered resume, the prior sdkSessionId no longer exists
// server-side, so don't fall back to it: only the freshly-created
// session's id is valid.
sdkSessionId = readSessionId(session) ?? (resumeFailureRecovered ? undefined : resumeSessionId);
sessionIdUsed = sdkSessionId ?? input.sessionId;
if (sdkSessionId && deps.onSessionEstablished) {
try {
deps.onSessionEstablished({
compactionSessionConfig,
sdkSessionId,
pooledClient: handle,
sessionConfig,
});
} catch {
// never let session-tracking callbacks break attempts
}
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onAgentEvent: input.onAgentEvent,
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
return;
}
await runAgentHarnessBeforeCompactionHook({
sessionFile,
ctx: hookContext,
});
},
onCompactionComplete: async ({ messagesRemoved, success }) => {
const sessionFile = readString(input.sessionFile);
if (!success || !sessionFile) {
return;
}
await runAgentHarnessAfterCompactionHook({
sessionFile,
compactedCount: messagesRemoved ?? -1,
ctx: hookContext,
});
},
getSdkSessionId: () => sdkSessionId,
isAborted: () => aborted,
});
const activeRunHandle = {
kind: "embedded" as const,
queueMessage: async (text: string) => {
if (userInputBridge.handleQueuedMessage(text)) {
return;
}
throw new Error("Copilot runtime is not waiting for user input.");
},
isStreaming: () => !settled && !aborted,
isCompacting: () => bridge?.isCompacting() ?? false,
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
cancel: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
abort: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
};
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
activeRunHandleRef = activeRunHandle;
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
provider: poolAcquire.provider,
sandbox,
workspaceOnly: effectiveFsWorkspaceOnly,
});
promptImagesCount = messageOptions.attachments?.length ?? 0;
if (abortRequested || params.abortSignal?.aborted) {
aborted = true;
externalAbort = true;
} else {
sentTurnStarted = true;
if (!hasNativePromptHook) {
emitLlmInput(attemptInput.prompt);
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
await bridge.awaitAgentEventChain();
if (!bridge.recordSendResult(result) && !aborted) {
// SDK sendAndWait returning undefined is treated as a timeout by the
// capability inventory. Do not call session.abort() here: OpenClaw may
// resume the in-flight SDK session on the next attempt.
timedOut = true;
timedOutDuringCompaction = bridge.isCompacting();
}
const snap = bridge.snapshot();
if (!promptError && !timedOut && !aborted && snap.streamError) {
promptError = snap.streamError;
}
}
} catch (error: unknown) {
if (!aborted) {
if (isSdkSendAndWaitTimeoutError(error)) {
// The SDK's sendAndWait timeout rejects with a deterministic
// message but explicitly does NOT abort in-flight agent work
// (see isSdkSendAndWaitTimeoutError docstring and
// node_modules/@github/copilot-sdk/dist/session.js:156-164).
// Mark timedOut so createResult's computeReplayMetadata flips
// to side-effect-risky and the orchestrator's replay-shim can
// decide whether to resume or restart. Do NOT call
// session.abort() here: the orchestrator may resume the
// in-flight SDK session on the next attempt (the SDK keeps
// the server-side session intact across this kind of timeout).
timedOut = true;
timedOutDuringCompaction = bridge?.isCompacting() === true;
// Flush any in-flight delta promise chain so the snapshot
// built below in `finally` includes the deltas the SDK already
// delivered before the timer fired.
try {
await bridge?.awaitDeltaChain();
} catch {
// delta-flush failure must not mask the timeout state
}
await bridge?.awaitAgentEventChain();
} else {
promptError = toError(error);
}
}
} finally {
settled = true;
userInputBridgeRef?.cancelPending();
if (activeRunHandleRef) {
clearActiveEmbeddedRun(
input.sessionId,
activeRunHandleRef,
input.sessionKey,
input.sessionFile,
);
}
const retainSessionForDeferredCleanup =
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
if (retainSessionForDeferredCleanup && bridge && session && handle) {
const cleanupAbort = new AbortController();
const abortCleanup = () => cleanupAbort.abort();
if (params.abortSignal?.aborted) {
abortCleanup();
} else {
params.abortSignal?.addEventListener("abort", abortCleanup, { once: true });
}
const cleanup = deferBackgroundCompactionCleanup({
abortSignal: cleanupAbort.signal,
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
cleanupByokProxy,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
sdkSessionId,
session,
timeoutMs: resolveCompactionTimeoutMs(input.config),
});
void cleanup
.finally(() => {
params.abortSignal?.removeEventListener("abort", abortCleanup);
})
.catch(() => undefined);
if (sdkSessionId) {
try {
deps.onDeferredCompaction?.({
abort: () => cleanupAbort.abort(),
cleanup,
sdkSessionId,
});
} catch {
// Session tracking cannot interfere with timeout cleanup.
}
}
params.abortSignal?.removeEventListener("abort", onAbort);
} else {
// A normal sendAndWait result has observed session.idle, which the SDK
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
await cleanupByokProxy?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
if (session) {
try {
await session.disconnect();
} catch (error: unknown) {
disconnectError = toError(error);
// A timeout is a higher-fidelity signal than a cleanup-time
// disconnect failure; don't let a stale disconnect error
// mask the timeout classification the replay-shim depends on.
if (!promptError && !timedOut) {
promptError = disconnectError;
}
}
}
if (handle) {
try {
await deps.pool.release(handle);
} catch (error: unknown) {
const releaseFailure = toError(error);
if (promptError) {
console.warn(
"[copilot-attempt] pool.release failed after primary error",
releaseFailure,
);
} else {
releaseError = releaseFailure;
}
}
}
}
}
const snap = bridge?.snapshot();
const assistantTexts = bridge?.finalizeAssistantTexts() ?? [];
const lastAssistant = bridge?.buildAssistantMessage({ modelRef, now });
// Dogfood finding #3 (mirror codex parity):
//
// Without this synthesis the OpenClaw audit transcript never sees
// the user's prompt for a copilot attempt. The shell's
// `persistTextTurnTranscript` skips the user write when
// `embeddedAssistantGapFill` is true (its `body` arrives as ""),
// trusting the harness to mirror it. Codex does exactly this in
// `event-projector.ts:262` by prepending
// `{role:"user", content:params.prompt, ...}` tagged `${turnId}:prompt`.
// We mirror that pattern with `${runId}:prompt` as the turn-stable
// identity so re-mirror of the same turn is a true no-op AND two
// turns sharing the same SDK session produce distinct dedupe keys
// (the latter matters once session reuse lands in harness.ts).
//
// Defensive guard: if the caller already passed the same user turn
// as the tail of `messages`, skip synthesis to avoid double-writing
// the user message.
const syntheticUserText = readString(input.transcriptPrompt) ?? readString(input.prompt);
const tailUserText = readTailUserText(messages);
const syntheticUser: AgentMessage | undefined =
syntheticUserText && syntheticUserText !== tailUserText
? attachCopilotMirrorIdentity(
{ role: "user", content: syntheticUserText, timestamp: now() } as AgentMessage,
`${input.runId}:prompt`,
)
: undefined;
const taggedLastAssistant = lastAssistant
? attachCopilotMirrorIdentity(lastAssistant, `${input.runId}:assistant:final`)
: undefined;
const messagesSnapshot: AgentMessage[] = [
...messages,
...(syntheticUser ? [syntheticUser] : []),
...(taggedLastAssistant ? [taggedLastAssistant] : []),
];
// Best-effort dual-write: mirror this attempt's full message snapshot
// (user/assistant/toolResult) into the OpenClaw audit transcript at
// params.sessionFile, alongside the SDK's own session storage. The
// OpenClaw shell (attempt-execution.ts) writes only the user prompt
// and terminal assistant text; mirroring here captures intermediate
// tool calls/results for full audit/replay parity with the codex
// extension. Identity-tagged so re-emits dedupe. Errors are
// swallowed so a mirror failure cannot break the attempt.
const sessionFileForMirror = readString(input.sessionFile);
const openClawSessionIdForMirror = readString(input.sessionId);
const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror;
if (sessionFileForMirror && openClawSessionIdForMirror && messagesSnapshot.length > 0) {
const taggedMessages = messagesSnapshot.map((message, index) => {
if (
message.role !== "user" &&
message.role !== "assistant" &&
message.role !== "toolResult"
) {
return message;
}
// Preserve any caller-attached (or upstream-attached) mirror
// identity — especially the `${runId}:prompt` /
// `${runId}:assistant:final` identities attached above — so the
// dedupe key stays turn-stable. Falling back to a per-attempt
// positional identity here is only safe for messages that don't
// already carry a logical identity; with SDK session reuse the
// positional scheme would collapse turn 2's index-0 user onto
// turn 1's index-0 user inside the same `${sdkSessionId}`
// scope. See replay-shim.ts + harness.ts session-reuse path.
if (hasMirrorIdentity(message)) {
return message;
}
const identityScope = sdkSessionId ?? mirrorScopeSessionId ?? "attempt";
return attachCopilotMirrorIdentity(message, `${identityScope}:${message.role}:${index}`);
});
await dualWriteCopilotTranscriptBestEffort({
sessionFile: sessionFileForMirror,
sessionId: openClawSessionIdForMirror,
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentId: readString(input.agentId),
messages: taggedMessages,
idempotencyScope: mirrorScopeSessionId ? `copilot:${mirrorScopeSessionId}` : undefined,
config: (input as { config?: unknown }).config as never,
}).catch((mirrorError: unknown) => {
// Defense-in-depth: the best-effort wrapper already swallows
// mirror failures, but we double-guard here so any future
// signature change or unexpected rejection cannot break the
// attempt result. The SDK's own session storage remains
// authoritative; only the OpenClaw audit transcript would be
// missing intermediate messages for this turn.
console.warn(
"[copilot-attempt] dual-write transcript wrapper rejected unexpectedly",
mirrorError,
);
});
}
const result = createResult(input, {
aborted,
assistantTexts,
currentAttemptAssistant: lastAssistant,
downgradedFromResume,
externalAbort,
itemLifecycle: {
activeCount: Math.max((snap?.startedCount ?? 0) - (snap?.completedCount ?? 0), 0),
completedCount: snap?.completedCount ?? 0,
startedCount: snap?.startedCount ?? 0,
},
lastAssistant,
messagesSnapshot,
now,
promptError,
resumeFailureRecovered,
sdkSessionId,
sessionIdUsed,
timedOut,
timedOutDuringCompaction,
toolMetas: snap ? [...snap.toolMetas] : [],
usage: snap?.usage,
yieldDetected,
});
if (sentTurnStarted) {
runAgentHarnessLlmOutputHook({
event: {
runId: input.runId,
sessionId: input.sessionId,
provider: modelRef.provider,
model: modelRef.id,
...hookContextWindowFields,
resolvedRef:
input.runtimePlan?.observability.resolvedRef ?? `${modelRef.provider}/${modelRef.id}`,
...(input.runtimePlan?.observability.harnessId
? { harnessId: input.runtimePlan.observability.harnessId }
: {}),
assistantTexts: result.assistantTexts,
...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}),
...(result.attemptUsage ? { usage: result.attemptUsage } : {}),
...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
},
ctx: hookContext,
});
}
if (releaseError) {
await finalizeCopilotAttempt(
input,
{ ...result, promptError: releaseError },
hookContext,
attemptStartedAt,
now,
);
throw releaseError;
}
return finishAttempt(result);
}
function createResult(
params: AttemptParamsLike,
state: {
aborted?: boolean;
assistantTexts?: string[];
currentAttemptAssistant?: AssistantMessage;
downgradedFromResume?: boolean;
externalAbort?: boolean;
itemLifecycle?: { activeCount: number; completedCount: number; startedCount: number };
lastAssistant?: AssistantMessage;
messagesSnapshot: AgentMessage[];
now: () => number;
promptError: Error | undefined;
resumeFailureRecovered?: boolean;
sdkSessionId?: string;
sessionIdUsed?: string;
timedOut?: boolean;
timedOutDuringCompaction?: boolean;
toolMetas?: Array<{ meta?: string; toolName: string }>;
usage?: AssistantUsageSnapshot;
yieldDetected?: boolean;
},
): AttemptResultWithSdkSessionId {
const promptError = state.promptError;
const timedOut = state.timedOut === true;
const toolMetas = state.toolMetas ?? [];
const replayMetadata = computeReplayMetadata({
priorReplayInvalid: params.initialReplayState?.replayInvalid,
priorHadPotentialSideEffects: params.initialReplayState?.hadPotentialSideEffects,
thisAttemptTimedOut: timedOut,
thisAttemptHadPotentialSideEffects: copilotToolMetasHavePotentialSideEffects(toolMetas),
thisAttemptDowngradedFromResume: state.downgradedFromResume,
thisAttemptResumeFailureRecovered: state.resumeFailureRecovered,
});
return {
aborted: state.aborted === true,
...(state.sdkSessionId ? { sdkSessionId: state.sdkSessionId } : {}),
assistantTexts: state.assistantTexts ?? [],
attemptUsage: state.usage,
cloudCodeAssistFormatError: false,
currentAttemptAssistant: state.currentAttemptAssistant,
didSendViaMessagingTool: false,
externalAbort: state.externalAbort === true,
idleTimedOut: false,
itemLifecycle: state.itemLifecycle ?? {
activeCount: 0,
completedCount: 0,
startedCount: 0,
},
lastAssistant: state.lastAssistant,
messagesSnapshot: state.messagesSnapshot,
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
messagingToolSentTexts: [],
promptError,
promptErrorSource: promptError ? "prompt" : null,
replayMetadata,
sessionFileUsed: readString(params.sessionFile),
sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session",
timedOut,
timedOutDuringCompaction: state.timedOutDuringCompaction === true,
toolMetas,
yieldDetected: state.yieldDetected === true,
};
}
function createPromptError(code: string, message: string, cause?: unknown): PromptErrorWithCode {
const error = new Error(message) as PromptErrorWithCode;
error.code = code;
if (cause !== undefined) {
error.cause = cause;
}
return error;
}
function createSessionConfig(
params: AttemptParamsLike,
sdkModelId: string,
sdkTools: SdkTool[],
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
resolvedProvider: ResolvedCopilotProvider,
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
const hooks = createHooksBridge(params.hooksConfig, hooksBridgeOptions);
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
return {
model: sdkModelId,
// Permission decisions for SDK built-in tool kinds (shell, write,
// read, url, mcp, memory, hook) fall through to permission-bridge.
// The default (`rejectAllPolicy`) keeps the harness fail-closed,
// but the primary catalog restriction is `availableTools` below
// (PR #86155 [P1] round-8): the SDK only exposes the exact set of
// bridged tool names to the model, so native shell/read/write/url/
// mcp/memory/hook tools never appear in the catalog and cannot be
// invoked even under a permissive permission policy. The
// permission-bridge stays in place as defense-in-depth for any
// built-in kind that future SDK versions might surface outside
// `availableTools`. Every bridged tool is also registered with
// `overridesBuiltInTool: true` and `skipPermission: true` (see
// tool-bridge.ts) so 100% of tool calls go through OpenClaw's
// wrapped `execute()` which runs `runBeforeToolCallHook` (loop
// detection, trusted plugin policies, before-tool-call hooks,
// two-phase plugin approval). This mirrors the in-tree codex
// harness's split: bridged-tool enforcement happens inside the
// tool wrapper, and the SDK gate is a safety net for kinds we
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
onPermissionRequest: createPermissionBridge(permissionPolicy),
// Registers the SDK ask_user bridge. The bridge itself owns pending
// reply routing so generic mid-run steering still fails closed.
onUserInputRequest,
// The SDK's ResumeSessionConfig declaration omits ProviderConfig, but its
// client forwards config.provider on both session.create and session.resume.
// Keep one session config so BYOK resume/compaction stays on the same wire.
...(resolvedProvider.provider ? { provider: resolvedProvider.provider } : {}),
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
...(hooks ? { hooks } : {}),
// Session-level telemetry opt-out: only propagate when the host
// explicitly set a boolean. undefined means "use SDK default"
// (enabled for GitHub auth; disabled when a BYOK provider is set).
...(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 } : {}),
reasoningEffort: params.reasoningEffort,
tools: sdkTools,
// Restrict the SDK's tool catalog to the bridged tool names returned
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
// by `onUserInputRequest`. Without this, the SDK
// would still expose its native read/write/shell/url/mcp/memory/
// hook tools to the model alongside our overrides, which would
// bypass OpenClaw's wrapped-tool enforcement under any permissive
// permission policy and pollute the catalog with disabled tools
// under the default reject policy. An empty list (`[]`) is
// meaningful per the SDK contract
// (`@github/copilot-sdk/dist/types.d.ts:1059-1061`): when set,
// only the listed tools are available. Derived inside this
// function (not passed as a parameter) so create/resume always
// stay coupled to the registered external `tools` array. See PR
// #86155 [P1] round-8 and ResumeSessionConfig at
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
// `availableTools`, so the spread into `resumeSession` covers
// the resume path too).
availableTools: buildCopilotAvailableTools(sdkTools),
workingDirectory:
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
// When a task runs from a sub-cwd, keep SDK-native project docs
// (AGENTS.md, .github/copilot-instructions.md) visible from the
// canonical workspace too; workspace-bootstrap filters AGENTS.md
// because the SDK owns those instruction files.
...(effectiveWorkspaceDir && effectiveCwd && effectiveCwd !== effectiveWorkspaceDir
? { instructionDirectories: [effectiveWorkspaceDir] }
: {}),
// Session-level GitHub token. INDEPENDENT of the client-level
// token in `CopilotClientOptions.gitHubToken` (set in
// `resolvePoolAcquire().options`). Per the SDK contract
// (`@github/copilot-sdk/dist/types.d.ts:1168-1178`), the client-
// level token authenticates the CLI process while the session-
// level token determines the identity used for content exclusion,
// model routing, and quota — and is sent on BOTH `createSession`
// and `resumeSession` (`ResumeSessionConfig` picks `gitHubToken`
// at types.d.ts:1198). Omitted when `useLoggedInUser` is the
// resolved mode — passing both would be contradictory and the SDK
// already implies content-exclusion/quota from the logged-in
// identity in that mode.
...(resolvedAuth.authMode === "gitHubToken" && resolvedAuth.gitHubToken
? { gitHubToken: resolvedAuth.gitHubToken }
: {}),
// OpenClaw workspace bootstrap plus per-turn runtime guidance
// injected via the SDK's `systemMessage` field in append mode:
// SDK foundation + OpenClaw context. Append keeps every SDK
// guardrail intact while ensuring persona/identity/heartbeat and
// channel policy guidance reach the model without native reads.
// AGENTS.md and .github/copilot-instructions.md are filtered by
// workspace-bootstrap.ts because the SDK auto-loads them from
// `workingDirectory` (see `@github/copilot-sdk/dist/types.d.ts`
// L1036). Omitted when there is no OpenClaw-owned context so the
// SDK default foundation applies.
...(systemMessageContent
? {
systemMessage: {
mode: "append" as const,
content: systemMessageContent,
},
}
: {}),
};
}
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
}
async function createMessageOptions(
params: AttemptParamsLike,
context: {
effectiveCwd: string | undefined;
effectiveWorkspaceDir: string | undefined;
provider: ResolvedCopilotProvider;
sandbox: SandboxContext | null;
workspaceOnly: boolean;
},
): Promise<MessageOptions> {
const attachments = createPromptImageAttachments(await resolvePromptImages(params, context));
const requestHeaders = resolveProviderRequestHeaders(context.provider);
return {
prompt: params.prompt,
...(attachments.length > 0 ? { attachments } : {}),
// The SDK declares session-level provider headers, but its Anthropic
// runtime path consumes per-turn requestHeaders. Mirror them here so BYOK
// tenant/proxy headers survive every supported adapter.
...(requestHeaders ? { requestHeaders } : {}),
};
}
function resolveProviderRequestHeaders(
provider: ResolvedCopilotProvider,
): Record<string, string> | undefined {
const headers = provider.provider?.headers;
return headers && Object.keys(headers).length > 0 ? { ...headers } : undefined;
}
function createPromptImageAttachments(
images: unknown[],
): NonNullable<MessageOptions["attachments"]> {
return images.flatMap((image, index) => {
if (
!image ||
typeof image !== "object" ||
(image as { type?: unknown }).type !== "image" ||
typeof (image as { data?: unknown }).data !== "string" ||
typeof (image as { mimeType?: unknown }).mimeType !== "string"
) {
return [];
}
return [
{
type: "blob" as const,
data: (image as { data: string }).data,
mimeType: (image as { mimeType: string }).mimeType,
displayName: `prompt-image-${index + 1}`,
},
];
});
}
async function resolvePromptImages(
params: AttemptParamsLike,
context: {
effectiveCwd: string | undefined;
effectiveWorkspaceDir: string | undefined;
sandbox: SandboxContext | null;
workspaceOnly: boolean;
},
): Promise<unknown[]> {
const workspaceDir =
context.effectiveCwd ??
context.effectiveWorkspaceDir ??
readResolvedAttemptPath(params.cwd) ??
readResolvedAttemptPath(params.workspaceDir);
if (!workspaceDir) {
return [];
}
const localRoots =
context.workspaceOnly && context.effectiveWorkspaceDir
? [context.effectiveWorkspaceDir]
: undefined;
const result = await detectAndLoadAgentHarnessPromptImages({
prompt: params.prompt,
workspaceDir,
model: resolveImageCapabilityModel(params),
existingImages: Array.isArray(params.images) ? params.images : undefined,
imageOrder: Array.isArray(params.imageOrder) ? params.imageOrder : undefined,
config: params.config,
workspaceOnly: context.workspaceOnly,
localRoots,
sandbox:
context.sandbox?.enabled && context.sandbox.fsBridge
? { root: context.sandbox.workspaceDir, bridge: context.sandbox.fsBridge }
: undefined,
});
return result.images;
}
function resolveImageCapabilityModel(params: AttemptParamsLike): { input?: string[] } {
const model = params.model;
if (model && typeof model === "object" && Array.isArray((model as { input?: unknown }).input)) {
return { input: (model as { input: string[] }).input };
}
return { input: ["image"] };
}
function createSystemMessageContent(
params: AttemptParamsLike,
workspaceBootstrapInstructions: string | undefined,
): string | undefined {
const sections: string[] = [];
const bootstrap = workspaceBootstrapInstructions?.trim();
if (bootstrap) {
sections.push(bootstrap);
}
const extraSystemPrompt = readString(params.extraSystemPrompt)?.trim();
if (extraSystemPrompt && !isRawCopilotModelRun(params)) {
const contextHeader =
params.promptMode === "minimal" ? "## Subagent Context" : "## Conversation Context";
sections.push(`${contextHeader}\n${extraSystemPrompt}`);
}
return sections.length > 0 ? sections.join("\n\n") : undefined;
}
function isRawCopilotModelRun(params: AttemptParamsLike): boolean {
return params.modelRun === true || params.promptMode === "none";
}
function getMessagesSnapshotInput(params: AttemptParamsLike): AgentMessage[] {
return Array.isArray(params.messages) ? [...params.messages] : [];
}
// Returns the trimmed plain-text content of the tail user message in
// `messages`, if any. Used to skip synthetic-user injection when the
// caller already passed the current turn's user prompt as the last
// entry of `params.messages`, which would otherwise produce a duplicate
// user record in the audit transcript.
function readTailUserText(messages: AgentMessage[]): string | undefined {
const tail = messages[messages.length - 1];
if (!tail || tail.role !== "user") {
return undefined;
}
const content = (tail as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") {
const text = (part as { text?: unknown }).text;
if (typeof text === "string" && text.length > 0) {
return text;
}
}
}
}
return undefined;
}
// True when an AgentMessage already carries a stable mirror identity
// (e.g. the `${runId}:prompt` / `${runId}:assistant:final` identities
// attached in attempt.ts before the dual-write, or any caller-attached
// identity from a prior turn). Keep this in sync with the
// MIRROR_IDENTITY_META_KEY constant in dual-write-transcripts.ts; we
// duplicate the read here instead of importing the helper to avoid
// widening the module's public surface for what is otherwise a pure
// guard. See attempt.ts dual-write tagging block.
function hasMirrorIdentity(message: AgentMessage): boolean {
const record = message as unknown as { __openclaw?: unknown };
const meta = record["__openclaw"];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return false;
}
const id = (meta as Record<string, unknown>).mirrorIdentity;
return typeof id === "string" && id.length > 0;
}
function readSessionId(session: SessionLike | undefined): string | undefined {
if (!session) {
return undefined;
}
return readString(session.sessionId) ?? readString(session.id);
}
export function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readResolvedAttemptPath(value: unknown): string | undefined {
const raw = readString(value)?.trim();
if (!raw) {
return undefined;
}
if (process.platform !== "win32" && /^[A-Za-z]:[\\/]/.test(raw)) {
return raw;
}
return resolveUserPath(raw);
}
export function resolveModelRef(params: AttemptParamsLike): ModelRef {
const rawModel = (params as { runtimeModel?: unknown }).runtimeModel ?? params.model;
if (rawModel && typeof rawModel === "object") {
const model = rawModel as ModelRefInputObject;
const requestTransport = getModelProviderRequestTransport(rawModel);
const rawRequest = model.request;
return {
api: readString(model.api),
id:
readString(model.id) ??
readString((params as { modelId?: unknown }).modelId) ??
"unknown-model",
provider:
readString(model.provider) ??
readString((params as { provider?: unknown }).provider) ??
"unknown-provider",
baseUrl: readString(model.baseUrl),
azureApiVersion: readString(model.azureApiVersion ?? model.params?.azureApiVersion),
headers: model.headers,
authHeader: model.authHeader,
requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode),
requestProxy: requestTransport?.proxy ?? rawRequest?.proxy,
requestTls: requestTransport?.tls ?? rawRequest?.tls,
requestAllowPrivateNetwork:
requestTransport?.allowPrivateNetwork ?? rawRequest?.allowPrivateNetwork,
contextTokens: model.contextTokens,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
};
}
return {
id:
readString(typeof rawModel === "string" ? rawModel : undefined) ??
readString((params as { modelId?: unknown }).modelId) ??
"unknown-model",
provider: readString((params as { provider?: unknown }).provider) ?? "unknown-provider",
};
}
export function resolvePoolAcquire(params: AttemptParamsLike): {
key: PoolKey;
options: ClientCreateOptions;
/**
* The resolved auth result is returned so call sites that build a
* `SessionConfig` immediately afterwards (attempt.ts +
* side-question.ts) can populate `SessionConfig.gitHubToken`
* without re-resolving auth. `SessionConfig.gitHubToken` is
* INDEPENDENT of `CopilotClientOptions.gitHubToken` per the SDK
* contract (`@github/copilot-sdk/dist/types.d.ts:1168-1178`): the
* client-level token authenticates the CLI process, while the
* session-level token determines the identity used for content
* exclusion, model routing, and quota. Both `createSession` and
* `resumeSession` (`ResumeSessionConfig` at types.d.ts:1198) honor
* the session-level field, so per-session multitenancy requires
* setting both.
*/
auth: ReturnType<typeof resolveCopilotAuth>;
provider: ResolvedCopilotProvider;
} {
const model = resolveModelRef(params);
const provider = resolveCopilotProvider({
model,
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
});
const auth =
provider.mode === "byok"
? createCopilotByokAuth({
agentId: readString(params.agentId),
agentDir: readString(params.agentDir),
workspaceDir: readString(params.workspaceDir),
copilotHome: readString(params.copilotHome),
authProfileId: provider.authProfileId,
authProfileVersion: provider.authProfileVersion,
})
: resolveCopilotAuth({
agentId: readString(params.agentId),
agentDir: readString(params.agentDir),
workspaceDir: readString(params.workspaceDir),
copilotHome: readString(params.copilotHome),
auth: params.auth,
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
// main path for agents with a configured `github-copilot` auth
// profile. Falling through to env / useLoggedInUser when absent
// keeps the direct-CLI / dogfood paths working unchanged.
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
profileVersion: readString(params.profileVersion),
});
return {
key: {
agentId: auth.agentId,
authMode: auth.authMode,
...(auth.authMode === "gitHubToken" || auth.authMode === "byok"
? {
authProfileId: auth.authProfileId,
authProfileVersion: auth.authProfileVersion,
}
: {}),
copilotHome: auth.copilotHome,
},
options: {
copilotHome: auth.copilotHome,
...(auth.authMode === "gitHubToken" && auth.gitHubToken
? { gitHubToken: auth.gitHubToken }
: {}),
useLoggedInUser: auth.authMode === "useLoggedInUser",
},
auth,
provider,
};
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
/**
* Detect the @github/copilot-sdk `session.sendAndWait` timeout
* rejection shape. The SDK's `sendAndWait` races the internal
* `session.idle` event against a timer; when the timer fires first
* it REJECTS the promise with
* `new Error(`Timeout after ${effectiveTimeout}ms waiting for
* session.idle`)` (see
* `node_modules/@github/copilot-sdk/dist/session.js:156-164`), and
* the SDK docs explicitly note the timeout "does not abort in-flight
* agent work". The caller is therefore responsible for setting the
* timed-out state and (for paths where in-flight work should be
* stopped) calling `session.abort()`.
*
* Keep the regex anchored and narrow so unrelated errors that happen
* to mention "Timeout" are NOT mis-classified. The shape is a literal
* template-string concatenation in the 1.0.0-beta line; a minor
* version bump that changes the wording will safely fall through to
* the generic prompt-error path.
*/
export function isSdkSendAndWaitTimeoutError(error: unknown): boolean {
if (error === null || typeof error !== "object") {
return false;
}
const message = (error as { message?: unknown }).message;
if (typeof message !== "string") {
return false;
}
return /^Timeout after \d+ms waiting for session\.idle$/.test(message);
}