mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 06:04:56 +00:00
fix(acp): avoid deadlock on permission requests during long POST
This commit is contained in:
parent
7f9460bbbb
commit
1d000581a0
2 changed files with 131 additions and 16 deletions
|
|
@ -376,32 +376,61 @@ class StreamableHttpAcpTransport {
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
|
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
|
||||||
const response = await this.fetcher(url, {
|
const responsePromise = this.fetcher(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(message),
|
body: JSON.stringify(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.postedOnce = true;
|
this.postedOnce = true;
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureSseLoop();
|
this.ensureSseLoop();
|
||||||
|
|
||||||
if (response.status === 200) {
|
const consumeResponse = async (): Promise<void> => {
|
||||||
const text = await response.text();
|
const response = await responsePromise;
|
||||||
if (text.trim()) {
|
|
||||||
const envelope = JSON.parse(text) as AnyMessage;
|
if (!response.ok) {
|
||||||
this.pushInbound(envelope);
|
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Drain response body so the underlying connection is released back to
|
if (response.status === 200) {
|
||||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
const text = await response.text();
|
||||||
// may stall subsequent requests to the same origin.
|
if (text.trim()) {
|
||||||
await response.text().catch(() => {});
|
const envelope = JSON.parse(text) as AnyMessage;
|
||||||
|
this.pushInbound(envelope);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Drain response body so the underlying connection is released back to
|
||||||
|
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||||
|
// may stall subsequent requests to the same origin.
|
||||||
|
await response.text().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't block subsequent writes (e.g. permission replies) behind long
|
||||||
|
// running prompt turns; prompt completions arrive via SSE.
|
||||||
|
if (isRequestMessage(message)) {
|
||||||
|
consumeResponse().catch((error) => {
|
||||||
|
this.handleDetachedRequestError(message, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await consumeResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDetachedRequestError(message: AnyMessage, error: unknown): void {
|
||||||
|
const id = requestIdFromMessage(message);
|
||||||
|
if (id === undefined) {
|
||||||
|
this.failReadable(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpcError = toRpcError(error);
|
||||||
|
this.pushInbound({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
error: rpcError,
|
||||||
|
} as AnyMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureSseLoop(): void {
|
private ensureSseLoop(): void {
|
||||||
|
|
@ -681,5 +710,51 @@ function buildQueryParams(source: Record<string, QueryValue>): URLSearchParams {
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestMessage(message: AnyMessage): boolean {
|
||||||
|
if (!isRecord(message)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = message as Record<string, unknown>;
|
||||||
|
const method = record["method"];
|
||||||
|
const id = record["id"];
|
||||||
|
return typeof method === "string" && id !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestIdFromMessage(message: AnyMessage): number | string | null | undefined {
|
||||||
|
if (!isRecord(message) || !Object.prototype.hasOwnProperty.call(message, "id")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const record = message as Record<string, unknown>;
|
||||||
|
const id = record["id"];
|
||||||
|
if (typeof id === "string" || typeof id === "number" || id === null) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRpcError(error: unknown): RpcErrorResponse {
|
||||||
|
if (error instanceof AcpHttpError) {
|
||||||
|
return {
|
||||||
|
code: -32003,
|
||||||
|
message: error.problem?.title ?? `HTTP ${error.status}`,
|
||||||
|
data: error.problem ?? { status: error.status },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
code: -32603,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: -32603,
|
||||||
|
message: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type * from "@agentclientprotocol/sdk";
|
export type * from "@agentclientprotocol/sdk";
|
||||||
export { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
export { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ import {
|
||||||
type CancelNotification,
|
type CancelNotification,
|
||||||
type NewSessionRequest,
|
type NewSessionRequest,
|
||||||
type NewSessionResponse,
|
type NewSessionResponse,
|
||||||
|
type PermissionOption,
|
||||||
type PromptRequest,
|
type PromptRequest,
|
||||||
type PromptResponse,
|
type PromptResponse,
|
||||||
|
type RequestPermissionRequest,
|
||||||
|
type RequestPermissionResponse,
|
||||||
type SessionNotification,
|
type SessionNotification,
|
||||||
type SetSessionConfigOptionRequest,
|
type SetSessionConfigOptionRequest,
|
||||||
type SetSessionModeRequest,
|
type SetSessionModeRequest,
|
||||||
|
|
@ -227,6 +230,9 @@ export class LiveAcpConnection {
|
||||||
bootstrapQuery: { agent: options.agent },
|
bootstrapQuery: { agent: options.agent },
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
|
requestPermission: async (request: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
||||||
|
return autoSelectPermissionResponse(request);
|
||||||
|
},
|
||||||
sessionUpdate: async (_notification: SessionNotification) => {
|
sessionUpdate: async (_notification: SessionNotification) => {
|
||||||
// Session updates are observed via envelope persistence.
|
// Session updates are observed via envelope persistence.
|
||||||
},
|
},
|
||||||
|
|
@ -1011,6 +1017,40 @@ function normalizeSessionInit(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoSelectPermissionResponse(
|
||||||
|
request: RequestPermissionRequest,
|
||||||
|
): RequestPermissionResponse {
|
||||||
|
const chosen = selectPermissionOption(request.options ?? []);
|
||||||
|
if (!chosen) {
|
||||||
|
return {
|
||||||
|
outcome: {
|
||||||
|
outcome: "cancelled",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: {
|
||||||
|
outcome: "selected",
|
||||||
|
optionId: chosen.optionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPermissionOption(options: PermissionOption[]): PermissionOption | null {
|
||||||
|
const allowOnce = options.find((option) => option.kind === "allow_once");
|
||||||
|
if (allowOnce) {
|
||||||
|
return allowOnce;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowAlways = options.find((option) => option.kind === "allow_always");
|
||||||
|
if (allowAlways) {
|
||||||
|
return allowAlways;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function mapSessionParams(params: Record<string, unknown>, agentSessionId: string): Record<string, unknown> {
|
function mapSessionParams(params: Record<string, unknown>, agentSessionId: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue