mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Part 3 of the opt-in indexed history draft series. Validated at the integrated tip; activation remains disabled by default. Co-Authored-By: Paperclip <noreply@paperclip.ing>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import type { HarnessRuntimeRequest, PaperclipQuestionSet } from "../../contracts/harness-driver.js";
|
|
import {
|
|
CODEX_BLOCK_TOOL_NAME,
|
|
} from "../../contracts/codex.js";
|
|
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
|
import type { CodexRpcServerRequest } from "./app-server-transport.js";
|
|
import {
|
|
boundedCodexValue,
|
|
codexToolAcceptsResult as toolAcceptsResult,
|
|
isCodexSemanticTool as isSemanticTool,
|
|
isRetainableCodexPayload,
|
|
redactCodexValue,
|
|
rejectedCodexToolCall as rejectedToolCall,
|
|
} from "./codex-boundaries.js";
|
|
import {
|
|
createCodexQuestionResponseContext,
|
|
hasCodexQuestionForm,
|
|
normalizeCodexQuestionSet,
|
|
runtimeRequestKind,
|
|
runtimeRequestPrompt,
|
|
runtimeRequestProtocolPayload,
|
|
} from "./codex-question-adapter.js";
|
|
import { safeCodexRequestResponse as safeRequestResponse } from "./codex-thread-normalization.js";
|
|
import type { CodexSessionState } from "./codex-session-state.js";
|
|
import { admitResult } from "./codex-session-terminal.js";
|
|
import { boundedText, dynamicToolResponse, record, text } from "./codex-driver-values.js";
|
|
|
|
export async function handleServerRequest(
|
|
state: CodexSessionState,
|
|
request: CodexRpcServerRequest,
|
|
): Promise<Record<string, unknown>> {
|
|
const emittedEventIds: string[] = [];
|
|
let rejected = false;
|
|
try {
|
|
if (request.method !== "item/tool/call") state.pendingRuntimeRequestSources.set(String(request.id), structuredClone(request));
|
|
const pendingResponse = state.capturedEventIds.run(emittedEventIds, () => handleServerRequestBody(state, request));
|
|
// Runtime-input admission executes synchronously, then waits for a user.
|
|
// Persist that admission without holding the notification pump hostage.
|
|
if (request.method !== "item/tool/call") state.deliveryWriter?.changed(request.paperclipDelivery);
|
|
const response = await pendingResponse;
|
|
rejected = response.success === false;
|
|
return response;
|
|
} catch (error) {
|
|
rejected = true;
|
|
throw error;
|
|
} finally {
|
|
state.pendingRuntimeRequestSources.delete(String(request.id));
|
|
const correlation = request.paperclipTrace;
|
|
if (correlation !== undefined) {
|
|
try {
|
|
state.transport.recordTraceInterpretation?.({
|
|
sourceEventId: correlation.sourceEventId,
|
|
sourceEventType: correlation.sourceEventType,
|
|
providerMethod: request.method,
|
|
disposition: rejected
|
|
? "rejected"
|
|
: emittedEventIds.length > 0
|
|
? "mapped"
|
|
: "ignored",
|
|
emittedEventIds,
|
|
reason: rejected
|
|
? "Codex driver rejected the correlated provider server request"
|
|
: emittedEventIds.length > 0
|
|
? "Codex driver mapped the correlated provider server request into canonical PRP events"
|
|
: "Codex driver accepted the correlated provider server request without emitting a canonical PRP event",
|
|
});
|
|
} catch {
|
|
// Trace delivery is deliberately outside run authority.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleServerRequestBody(
|
|
state: CodexSessionState,
|
|
request: CodexRpcServerRequest,
|
|
): Promise<Record<string, unknown>> {
|
|
if (request.method === "item/tool/call") {
|
|
// Provider requests and turn/start responses have independent delivery
|
|
// paths. Judge the call against the admitted provider turn, not the
|
|
// temporary null/optimistic identity while its start is still pending.
|
|
await state.turnStartSettled;
|
|
state.assertProtocolIntegrity();
|
|
const tool = text(request.params.tool);
|
|
const threadId = text(request.params.threadId);
|
|
const turnId = text(request.params.turnId);
|
|
const callId = text(request.params.callId);
|
|
if (
|
|
state.protocolFailed ||
|
|
state.terminal ||
|
|
threadId !== state.opened.threadId ||
|
|
turnId.length === 0 ||
|
|
turnId !== state.activeTurnId ||
|
|
callId.length === 0
|
|
) {
|
|
state.failProtocol(
|
|
"tool_binding_mismatch",
|
|
"Semantic tool call did not name the active thread and turn.",
|
|
);
|
|
return rejectedToolCall(
|
|
"Semantic tool call was outside the active thread and turn.",
|
|
);
|
|
}
|
|
if (!isSemanticTool(tool)) {
|
|
const admitted = state.dynamicTools.some(
|
|
(candidate) => candidate.name === tool,
|
|
);
|
|
if (admitted && state.dynamicToolHandler !== undefined) {
|
|
state.emit(
|
|
"item.started",
|
|
{
|
|
kind: "dynamicToolCall",
|
|
item: {
|
|
type: "tool_use",
|
|
id: callId,
|
|
name: tool,
|
|
input: request.params.arguments,
|
|
},
|
|
},
|
|
{ turnId, itemId: callId },
|
|
);
|
|
try {
|
|
const result = await state.dynamicToolHandler({
|
|
tool,
|
|
callId,
|
|
threadId,
|
|
turnId,
|
|
arguments: request.params.arguments,
|
|
});
|
|
state.emit(
|
|
"item.completed",
|
|
{
|
|
kind: "dynamicToolCall",
|
|
item: {
|
|
type: "tool_result",
|
|
id: callId,
|
|
tool_use_id: callId,
|
|
result,
|
|
},
|
|
},
|
|
{ turnId, itemId: callId },
|
|
);
|
|
return dynamicToolResponse(result);
|
|
} catch (error) {
|
|
const message = boundedText(
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
state.emit(
|
|
"item.completed",
|
|
{
|
|
kind: "dynamicToolCall",
|
|
item: {
|
|
type: "tool_result",
|
|
id: callId,
|
|
tool_use_id: callId,
|
|
error: message,
|
|
is_error: true,
|
|
},
|
|
},
|
|
{ turnId, itemId: callId },
|
|
);
|
|
return {
|
|
success: false,
|
|
contentItems: [{ type: "inputText", text: message }],
|
|
};
|
|
}
|
|
}
|
|
state.diagnoseUnsupported(`dynamic tool ${tool}`);
|
|
return rejectedToolCall("Unsupported tool.");
|
|
}
|
|
if (!isRetainableCodexPayload(request.params.arguments)) {
|
|
return rejectedToolCall(
|
|
"Semantic result exceeded the retained payload limit.",
|
|
);
|
|
}
|
|
const validation = validatePrpStructuredRunResult(
|
|
request.params.arguments,
|
|
);
|
|
if (!validation.ok) {
|
|
return {
|
|
success: false,
|
|
contentItems: [
|
|
{ type: "inputText", text: "Invalid semantic result." },
|
|
],
|
|
};
|
|
}
|
|
if (
|
|
!toolAcceptsResult(tool, validation.result)
|
|
) {
|
|
return {
|
|
success: false,
|
|
contentItems: [
|
|
{
|
|
type: "inputText",
|
|
text:
|
|
tool === CODEX_BLOCK_TOOL_NAME
|
|
? "paperclip_block requires reportedWorkDisposition=blocked."
|
|
: "paperclip_finish accepts done, needs_review, or yielded with a response_wake continuation.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
let feedback = "Completion report accepted. Task status is committed after this turn and workspace finalization finish.";
|
|
try {
|
|
feedback = await state.completionFeedback?.(validation.result) ?? feedback;
|
|
} catch (error) {
|
|
return rejectedToolCall(boundedText(error instanceof Error ? error.message : error));
|
|
}
|
|
state.assertProtocolIntegrity();
|
|
if (state.terminal || state.activeTurnId !== turnId) {
|
|
return rejectedToolCall("The turn ended while checking completion. The result was not accepted.");
|
|
}
|
|
const admission = admitResult(state, validation.result, callId, turnId);
|
|
if (admission === "conflict") {
|
|
return rejectedToolCall(
|
|
"A different semantic result was already committed.",
|
|
);
|
|
}
|
|
return {
|
|
success: true,
|
|
contentItems: [
|
|
{ type: "inputText", text: feedback },
|
|
],
|
|
};
|
|
}
|
|
|
|
const requestKind = runtimeRequestKind(request.method);
|
|
if (requestKind === null) return safeRequestResponse(request.method);
|
|
const requestTurnId = text(request.params.turnId);
|
|
if (
|
|
text(request.params.threadId) !== state.opened.threadId ||
|
|
requestTurnId.length === 0 ||
|
|
requestTurnId !== state.activeTurnId ||
|
|
state.terminal ||
|
|
state.protocolFailed
|
|
) {
|
|
state.failProtocol(
|
|
"runtime_request_binding_mismatch",
|
|
"Runtime request did not name the active thread and turn.",
|
|
);
|
|
return safeRequestResponse(request.method);
|
|
}
|
|
const requestId = String(request.id);
|
|
if (state.pendingRuntimeRequestMap.has(requestId)) {
|
|
state.failProtocol(
|
|
"runtime_request_duplicate",
|
|
"Provider reused a pending runtime request identity.",
|
|
);
|
|
return safeRequestResponse(request.method);
|
|
}
|
|
let input: PaperclipQuestionSet | null = null;
|
|
const responseContext = createCodexQuestionResponseContext();
|
|
try {
|
|
input = normalizeCodexQuestionSet(
|
|
request.method,
|
|
request.params,
|
|
responseContext,
|
|
);
|
|
} catch {
|
|
state.emit("harness.diagnostic", {
|
|
code: "runtime_input_rejected",
|
|
adapter: "codex-app-server",
|
|
method: request.method,
|
|
reason: "The provider input request contained an invalid question form.",
|
|
}, { turnId: requestTurnId, itemId: String(request.id) });
|
|
return safeRequestResponse(request.method);
|
|
}
|
|
if (
|
|
(requestKind === "user_input" || requestKind === "elicitation") &&
|
|
input === null &&
|
|
hasCodexQuestionForm(request.method, request.params)
|
|
) {
|
|
state.emit("harness.diagnostic", {
|
|
code: "runtime_input_rejected",
|
|
adapter: "codex-app-server",
|
|
method: request.method,
|
|
reason: "The provider input request did not contain a supported question form.",
|
|
}, { turnId: requestTurnId, itemId: String(request.id) });
|
|
return safeRequestResponse(request.method);
|
|
}
|
|
const runtimeRequest: HarnessRuntimeRequest = {
|
|
requestId,
|
|
requestKind,
|
|
method: request.method,
|
|
turnId: requestTurnId,
|
|
itemId: text(request.params.itemId, requestId),
|
|
status: "pending",
|
|
prompt: runtimeRequestPrompt(requestKind, request.params),
|
|
details: record(redactCodexValue(boundedCodexValue(request.params))),
|
|
...(input !== null ? { input } : {}),
|
|
origin: request.method === "elicitation/create" ? {
|
|
adapter: "acpx-runtime-sidecar",
|
|
provider: text(record(request.params.origin).provider, "acpx"),
|
|
method: request.method,
|
|
} : {
|
|
adapter: "codex-app-server",
|
|
provider: "codex",
|
|
method: request.method,
|
|
},
|
|
};
|
|
state.emit(
|
|
"runtime_request.created",
|
|
{
|
|
request: runtimeRequestProtocolPayload(runtimeRequest),
|
|
},
|
|
{
|
|
turnId: requestTurnId,
|
|
itemId: runtimeRequest.itemId,
|
|
},
|
|
);
|
|
return new Promise<Record<string, unknown>>((settle) => {
|
|
state.pendingRuntimeRequestMap.set(requestId, {
|
|
request: runtimeRequest,
|
|
responseContext,
|
|
settle,
|
|
});
|
|
});
|
|
}
|