mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:01:34 +00:00
* fix(agents): honor run abort signal in image and pdf tools
The image and pdf agent tools declared `execute: async (_toolCallId, args)`
and dropped the run abort signal that `wrapToolWithAbortSignal` supplies as
the third execute argument. The wrapper only races the execute promise, so an
aborted run kept sequentially downloading images/PDFs (up to the per-tool cap,
each up to the byte cap) and still issued a paid vision/PDF-model call for a
dead run.
Thread the signal into the existing `requestInit: { signal }` seam (which the
media fetch layer already merges into the download fetch) and add
`signal.throwIfAborted()` between sequential loop items and before the paid
model call. No new media-options signal field; non-abort behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agents): keep the pdf test suite under the lint ceiling; guard model dispatch
Follow-up on the abort-signal change, resolving the check-lint failure and a
review finding on the same seam.
max-lines
---------
Adding the two pdf abort tests pushed src/agents/tools/pdf-tool.test.ts to 1018
effective lines against a ceiling of 1000, failing check-lint. Resolved without
touching config/max-lines-baseline.txt — a suppression there would also have
tripped the max-lines ratchet.
- The new tests declared their own `describe` with `beforeEach`/`afterEach`
hooks identical to the existing `describe("createPdfTool")`. They exercise
that same tool, so they now live in it and the duplicated scaffolding is gone.
- `stubPdfToolInfra`, `createPdfModelRegistry` and `FAKE_PDF_MEDIA` moved to
pdf-tool.test-support.ts, which already exists for exactly this. They go
through `createPdfToolInfraStub(completeMock)` rather than being exported
directly, because the stub wires the suite's own `complete` mock into the
model registry and vi.mock handles are file-scoped. All 21 existing call
sites are unchanged.
Abort propagation into model dispatch
-------------------------------------
The previous revision stopped cancellation at download boundaries and before
the first model call, but `runImagePrompt`/`runPdfPrompt` never saw the signal.
A run cancelled while the first provider request was in flight could still
issue the remaining ones — the image path dispatches `describeImage` once per
image in a sequential loop, so a dead run kept paying for every later image.
Both now take an optional `signal` and check it immediately before each
provider dispatch (3 sites in image-tool, 4 in pdf-tool).
Forwarding the signal further, into the provider transports themselves, is a
different seam and is deliberately left out of this PR.
Also declares `requestInit` on `ImageToolLoadWebMediaOptions`. The local facade
omitted it while the underlying loader accepts it (web-media.ts declares it and
forwards it to readRemoteMediaBuffer), so the option worked at runtime and only
compiled because spread properties skip excess-property checking. A non-spread
call site would not have.
tsgo core + core-test, oxlint, oxfmt clean; pdf-tool and image-tool suites 266
tests pass.
* test(agents): prove in-flight media aborts
* test(agents): narrow PDF loader options
* test(agents): reject abort mocks with errors
* fix(agents): propagate media cancellation
* test(agents): type PDF abort fixture
* test(agents): type prepared runtime snapshot
* fix(agents): normalize abort rejection errors
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
// Deepgram plugin module implements audio behavior.
|
|
import type {
|
|
AudioTranscriptionRequest,
|
|
AudioTranscriptionResult,
|
|
} from "openclaw/plugin-sdk/media-understanding";
|
|
import {
|
|
assertOkOrThrowHttpError,
|
|
postTranscriptionRequest,
|
|
readProviderJsonObjectResponse,
|
|
resolveProviderHttpRequestConfig,
|
|
requireTranscriptionText,
|
|
} from "openclaw/plugin-sdk/provider-http";
|
|
import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
|
|
export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = "https://api.deepgram.com/v1";
|
|
export const DEFAULT_DEEPGRAM_AUDIO_MODEL = "nova-3";
|
|
|
|
function resolveModel(model?: string): string {
|
|
const trimmed = model?.trim();
|
|
return trimmed || DEFAULT_DEEPGRAM_AUDIO_MODEL;
|
|
}
|
|
|
|
function readDeepgramTranscript(payload: Record<string, unknown>): string | undefined {
|
|
const results = asRecord(payload.results);
|
|
if (!results) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(results.channels)) {
|
|
throw new Error("Audio transcription failed: malformed JSON response");
|
|
}
|
|
const channel = asRecord(results.channels[0]);
|
|
if (!channel) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(channel.alternatives)) {
|
|
throw new Error("Audio transcription failed: malformed JSON response");
|
|
}
|
|
const alternative = asRecord(channel.alternatives[0]);
|
|
if (!alternative) {
|
|
return undefined;
|
|
}
|
|
if (alternative.transcript !== undefined && typeof alternative.transcript !== "string") {
|
|
throw new Error("Audio transcription failed: malformed JSON response");
|
|
}
|
|
return alternative.transcript;
|
|
}
|
|
|
|
export async function transcribeDeepgramAudio(
|
|
params: AudioTranscriptionRequest,
|
|
): Promise<AudioTranscriptionResult> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const model = resolveModel(params.model);
|
|
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
|
resolveProviderHttpRequestConfig({
|
|
baseUrl: params.baseUrl,
|
|
defaultBaseUrl: DEFAULT_DEEPGRAM_AUDIO_BASE_URL,
|
|
headers: params.headers,
|
|
request: params.request,
|
|
defaultHeaders: {
|
|
authorization: `Token ${params.apiKey}`,
|
|
"content-type": params.mime ?? "application/octet-stream",
|
|
},
|
|
provider: "deepgram",
|
|
capability: "audio",
|
|
transport: "media-understanding",
|
|
});
|
|
|
|
const url = new URL(`${baseUrl}/listen`);
|
|
url.searchParams.set("model", model);
|
|
if (params.language?.trim()) {
|
|
url.searchParams.set("language", params.language.trim());
|
|
}
|
|
if (params.query) {
|
|
for (const [key, value] of Object.entries(params.query)) {
|
|
if (value === undefined) {
|
|
continue;
|
|
}
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
}
|
|
|
|
const body = new Uint8Array(params.buffer);
|
|
const { response: res, release } = await postTranscriptionRequest({
|
|
url: url.toString(),
|
|
headers,
|
|
body,
|
|
timeoutMs: params.timeoutMs,
|
|
...(params.signal ? { signal: params.signal } : {}),
|
|
fetchFn,
|
|
allowPrivateNetwork,
|
|
dispatcherPolicy,
|
|
});
|
|
|
|
try {
|
|
await assertOkOrThrowHttpError(res, "Audio transcription failed");
|
|
|
|
const payload = await readProviderJsonObjectResponse(res, "Audio transcription failed");
|
|
const transcript = requireTranscriptionText(
|
|
readDeepgramTranscript(payload),
|
|
"Audio transcription response missing transcript",
|
|
);
|
|
return { text: transcript, model };
|
|
} finally {
|
|
await release();
|
|
}
|
|
}
|