openclaw/extensions/agentsapi/agentsapi-client.ts
Sarah Fortune 051c8871bc
feat(agentsapi): enable native live web search (#156306)
Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
2026-09-23 07:54:02 +00:00

206 lines
6.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import OpenAI from "openai";
import type {
AgentReasoningParam,
AgentSessionEvent,
AgentSessionItem,
} from "openai/resources/beta/agents/agents";
import type { Turn } from "openai/resources/beta/agents/sessions/turns";
import { responseWithRelease } from "openclaw/plugin-sdk/fetch-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
/** The SDK owns the wire protocol; OpenClaw retains native session authority. */
export class AgentsApiClient {
private readonly sessions: OpenAI["beta"]["agents"]["sessions"];
constructor(
apiKey: string,
private readonly assertCurrent: () => void,
) {
this.sessions = new OpenAI({
apiKey,
// Ignore OPENAI_BASE_URL while retaining the SDK's official endpoint default.
baseURL: null,
// SDK retry backoff ignores aborts; preserve the harness's operation deadlines.
maxRetries: 0,
defaultHeaders: {
Authorization: `Bearer ${apiKey}`,
"OpenAI-Organization": null,
"OpenAI-Project": null,
},
fetch: async (input, init) => {
this.assertCurrent();
const guarded = await fetchWithSsrFGuard({
url: input instanceof Request ? input.url : String(input),
init,
signal: init?.signal ?? undefined,
beforeRequest: this.assertCurrent,
});
const response = responseWithRelease(guarded.response, guarded.release);
try {
this.assertCurrent();
} catch (error) {
await response.body?.cancel().catch(() => undefined);
throw error;
}
return response;
},
}).beta.agents.sessions;
}
async create(
signal: AbortSignal,
instructions: string,
model: string,
reasoningEffort?: AgentReasoningParam["effort"],
): Promise<string> {
const session = await this.sessions.create(
{
agent: {
model,
instructions,
reasoning: reasoningEffort === undefined ? undefined : { effort: reasoningEffort },
multi_agent: { enabled: false },
tools: [{ type: "web_search", mode: "live" }],
},
environment: { type: "openai_hosted" },
},
{ signal, headers: { "Idempotency-Key": randomUUID() } },
);
this.assertCurrent();
return session.id;
}
async setReasoningEffort(
sessionId: string,
effort: AgentReasoningParam["effort"],
signal: AbortSignal,
): Promise<void> {
const session = await this.sessions.update(
sessionId,
{},
{
signal,
headers: { "Idempotency-Key": randomUUID() },
// The API supports agent updates; this SDK version types only metadata.
body: { agent: { reasoning: { effort: effort ?? null } } },
},
);
this.assertCurrent();
if (session.id !== sessionId) {
throw new Error("Agents API returned a different session");
}
}
async subscribe(sessionId: string, signal: AbortSignal) {
const stream = await this.sessions.events.stream(sessionId, { signal });
try {
this.assertCurrent();
signal.throwIfAborted();
} catch (error) {
stream.controller.abort();
throw error;
}
return observeEvents(stream, signal, this.assertCurrent);
}
async session(sessionId: string, signal: AbortSignal) {
const session = await this.sessions.retrieve(sessionId, { signal });
this.assertCurrent();
if (session.id !== sessionId) {
throw new Error("Agents API returned a different session");
}
return session;
}
async turns(sessionId: string, signal: AbortSignal, after?: string, latestOnly = false) {
const turns: Turn[] = [];
const pages = this.sessions.turns.list(
sessionId,
{
order: latestOnly ? "desc" : "asc",
limit: latestOnly ? 1 : 100,
after,
},
{ signal },
);
for await (const page of (await pages).iterPages()) {
this.assertCurrent();
if (page.data.some((turn) => turn.session_id !== sessionId || turn.subagent_id !== null)) {
throw new Error("Agents API returned a turn outside the single-agent session");
}
turns.push(...page.data);
if (latestOnly) {
break;
}
if (page.has_more && !page.hasNextPage()) {
throw new Error("Agents API turns page has no continuation cursor");
}
}
return turns;
}
async message(sessionId: string, text: string, signal: AbortSignal): Promise<void> {
await this.sessions.events.create(
sessionId,
{
events: [
{
type: "agent.session.input.message",
input: [{ role: "user", content: [{ type: "input_text", text }] }],
},
],
"Idempotency-Key": randomUUID(),
},
{ signal },
);
this.assertCurrent();
}
async cancel(sessionId: string, signal: AbortSignal): Promise<void> {
await this.sessions.events.create(
sessionId,
{
events: [{ type: "agent.session.input.cancel" }],
"Idempotency-Key": randomUUID(),
},
{ signal },
);
this.assertCurrent();
// The input acknowledgement is not a settlement barrier for hosted work.
while (true) {
const session = await this.session(sessionId, signal);
if (session.status === "idle" || session.status === "failed") {
return;
}
await delay(500, undefined, { signal });
}
}
async items(sessionId: string, turnId: string, signal: AbortSignal): Promise<AgentSessionItem[]> {
const items: AgentSessionItem[] = [];
const pages = this.sessions.items.list(sessionId, { order: "asc", limit: 100 }, { signal });
for await (const page of (await pages).iterPages()) {
this.assertCurrent();
items.push(...page.data.filter((item) => item.turn_id === turnId));
if (page.has_more && !page.hasNextPage()) {
throw new Error("Agents API items page has no continuation cursor");
}
}
return items;
}
}
async function* observeEvents(
stream: AsyncIterable<AgentSessionEvent>,
signal: AbortSignal,
assertCurrent: () => void,
) {
for await (const event of stream) {
signal.throwIfAborted();
assertCurrent();
yield event;
}
signal.throwIfAborted();
}