mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(omni): integrate multimodal media pipeline into main (#12019)
* ci: run PR CI for the omni-experiment base branch
PRs targeting omni-experiment need the same CI coverage as main.
Branch-local change only; main's ci.yml is untouched.
* feat(omni): S1 minimal path — local video via DashScope upload delivery (#8422)
* feat(omni): S1 minimal path — local video via DashScope upload delivery
Local @video files are recognized (magic-byte sniff + streaming SHA-256 +
ffprobe metadata), promoted into a content-addressed object store under
.qwen/omni/objects/, uploaded through the DashScope temporary upload
channel (getPolicy + OSS multipart form POST), and delivered to the model
as oss:// fileData parts resolved server-side via the
X-DashScope-OssResourceResolve header.
- omni pipeline gated on omni.enabled (or QWEN_CODE_ENABLE_OMNI=1) AND a
DashScope-compatible endpoint AND an available API key; any other
configuration falls back to the existing inline base64 behavior
- ffmpeg/ffprobe are hard runtime prerequisites when omni is enabled:
Config.initialize fails fast with an actionable FatalConfigError
- upload failures fail closed with an explanatory error instead of
silently downgrading to inline delivery
- per-file ceiling omni.upload.maxFileBytes (default 1 GiB) replaces the
10MB inline cap for omni-delivered video
- object store writes are atomic (.tmp + rename) and content-deduped;
.qwen/omni/ is self-gitignored
Part of the omni-experiment S1 slice (#8183).
* fix(omni): harden S1 gate, storage, and failure paths after review
Review round 1 findings addressed:
- gate: exclude Qwen OAuth (its ContentGeneratorConfig carries the
QWEN_OAUTH_DYNAMIC_TOKEN placeholder, unusable for the uploads
endpoint), require an explicit baseUrl so the configured credential is
never sent to a default origin, and require a trusted workspace before
writing .qwen/omni/ or uploading workspace bytes
- aborts: user cancellation propagates as the original abort error at
every pipeline stage instead of being wrapped into a delivery failure
- size ceiling: enforced from a cheap stat before any hashing/probing,
and invalid (<=0) configured limits fall back to the default
- object store: bytes are re-hashed while copying and verified against
the object key (TOCTOU fail-closed); dedup hits re-hash the existing
object and heal mismatched content; store directories refuse symlinks
- error hygiene: messages that can reach model-visible content carry
basenames only and a structured upstream error summary (status +
code/message) instead of raw response bodies
- getPolicy: validate the OSS ACL fields so a partial policy fails with
a clear error instead of an opaque OSS 403
Part of the omni-experiment S1 slice (#8183).
* refactor(omni): apply simplification review round
- reuse the shared combineAbortSignals helper (with listener cleanup)
instead of a local AbortSignal combinator
- deduplicate the stream-SHA-256 helper: storage now imports
hashFileSha256 from recognition
- collapse probeBinary's callback plumbing into a Map-based
availability cache
- drop the unreachable video/x-matroska extension branch (the sniffer
never emits that MIME type)
- move the omni read-result shaping out of fileUtils into
readVideoViaOmniDelivery so the file-reading hot path only carries
the gate check and a single delegation call
Part of the omni-experiment S1 slice (#8183).
* fix(omni): keep abort/timeout coverage through response body reads
Regression review of the previous two commits found:
- upload.ts: cleanup() of the combined abort signal ran at
headers-arrival time, leaving the subsequent response body read
(getPolicy JSON / failure summary) unabortable — a stalled body would
hang with neither the timeout nor ESC able to interrupt it. Body
handling now happens inside the signal's lifetime.
- storage.ts: the rename-race fallback could convert a user abort into
a dedup success after an unabortable full-file re-hash; aborts now
rethrow before the fallback, and the verify hash carries the signal.
- storage.ts: healing a planted entry now removes directories too, not
just files.
Part of the omni-experiment S1 slice (#8183).
* fix(cli): make Esc cancel @-preprocessing (media uploads) in the TUI
The @-resolution phase of a user query (file reads, and with omni
enabled potentially minutes of media hashing + upload) ran while
streamingState still reported Idle: no loading indicator appeared and
cancelOngoingRequest early-returned, so Esc was silently swallowed for
the whole preprocessing window.
Track an isPreparingQuery state around prepareQueryForGemini for
interactive user queries and feed it into streamingState. Preprocessing
now renders the spinner with the esc-to-cancel hint, and Esc aborts the
turn controller — the signal already flowed through handleAtCommand
into the omni upload pipeline, which cancels cleanly (verified: no
orphan temp files, no lingering sockets, session stays usable).
Scoped to SendMessageType.UserQuery: notification/cron/teammate drains
key off streamingState transitions and keep their existing behavior;
/btw side-questions leave the main stream state untouched.
Part of the omni-experiment S1 slice (#8183).
* docs(omni): add multimodal experiment architecture designs (#8110)
Four interlinked design docs for the omni multimodal experiment:
- file recognition & metadata: unified MediaRecognitionService, two
normalization trigger points, URL localization, raw-resource token
estimation, ffmpeg/ffprobe as hard dependency
- policy orchestration: MediaPolicyTool contract with per-invocation
args + per-tool settings, fixedPolicies with condition DSL, transport
guard on upload-channel limits, DashScope temporary-upload delivery
(all-upload, no inline), lossy-output disclosure contract
- memory: two collection trigger points (FileRecognized /
OmniPolicySucceeded), harness-exclusive writes, in-file graph,
active/side-query recall sharing one protocol
- managed media storage: content-addressed object store, staging /
quarantine lifecycle, upload cache (sha256+model -> oss URL, 48h),
mark-and-sweep GC rooted at memory references
Verified against origin/main and validated end-to-end against the
DashScope uploads API (123KB / 21MB / 424MB video, image, audio).
* feat(omni): S2 input expansion — image/audio/URL sources and token-dimension transport guard (#8512)
* feat(omni): S2 input expansion — three modalities, URL sources, token guard
Extends the S1 video-only upload delivery to the full S2 input surface:
- recognition: generalized to image/audio/video with content sniffing
(magic bytes for png/jpeg/webp/gif, mp3/wav/flac/ogg/m4a, plus the S1
video set) and per-modality ffprobe metadata; modality mismatches
between sniff and reference fail closed
- delivery: all modalities upload ORIGINAL bytes — no resizing or
transcoding on the default path (lossy transforms are the job of omni
policies, which must disclose); images skip renderImageOverview when
omni is active and carry a dimensions+zoom-hint text part instead
- converter: new fileData→input_audio branch passing the bare oss:// URL
(previously audio fileData silently textified as 'Unsupported file
media type' after a successful upload); getAudioFormat widened to
flac/ogg/m4a in lockstep with the recognizer
- URL inputs: @https://… is intercepted ahead of filesystem resolution
(previously silently dropped via the ENOENT skip), streamed to
downloads/ with SSRF/redirect policy mirroring fetchWithPolicy, hashed
while writing, re-recognized from local bytes, and delivered through
the same pipeline; per-URL failure isolation keeps the turn alive
- token estimation: versioned raw-resource estimator (method
raw-resource-v1) attached to read results as the single tokenEstimate
location; formula is a swappable slot pending provider confirmation
- transport guard: byte ceiling (S1) plus omni.transport.maxEstimatedTokens
(default 0 = disabled — observability only until the formula is
confirmed; positive values fail closed before any copy/upload)
- second normalization trigger point: processToolResultOmniMedia converts
inline tool-result media to oss:// fileData at both physical funnels
(CoreToolScheduler terminal sites and ACP Session.runTool), as a
sibling of the vision bridge — converted parts are invisible to
isImagePart so bridge behavior is untouched
Part of the omni-experiment S2 slice (#8184).
* fix(cli): deliver URL-only @ media instead of dropping it after upload
The no-file-paths early return in resolveAtCommandQuery did not count
urlMediaRefs, so a prompt whose only @-reference was a URL ran the full
download → recognize → store → upload pipeline and then discarded both
the delivered fileData parts and their display cards. Found by S2 E2E
verification (the model improvised around the missing image via its own
tools, masking the drop).
Part of the omni-experiment S2 slice (#8184).
* fix(omni): apply S2 adversarial review findings
Security lens:
- tool-result media funnel: per-result upload budget (8 parts /
128 MiB aggregate — a malicious tool result must not fan out
unbounded uploads from the user's account); excess parts stay inline
- modality gate on tool-result parts now keyed to the SNIFFED modality,
not the declared MIME type (a part declared audio whose bytes are a
video container no longer bypasses a video-disabled config)
- fs error messages are path-scrubbed before reaching model-visible
error text (basenames survive, absolute paths do not)
Correctness lens:
- non-sniffable media formats (bmp/tiff/heic images, exotic containers)
fall back to the baseline inline path via a cheap content pre-sniff
instead of failing closed — fail-closed remains for genuine pipeline
failures, not for formats the recognizer simply does not support
- user cancellation during URL media download ends the turn gracefully
instead of rejecting through resolveAtCommandQuery (which has no
throw contract) into an unhandled-rejection banner
Part of the omni-experiment S2 slice (#8184).
* fix(omni): keep the omni graph out of the ACP static bundle closure
The serve fast-path bundle-closure CI check failed: iconv-lite encoding
tables (553KB) became statically reachable from the acpAgent chunk.
Root cause: the CLI-side dynamic imports of the FULL core barrel
(`import('@qwen-code/qwen-code-core')` in atCommandProcessor) request
the whole namespace object, defeating tree-shaking of the barrel — which
statically re-exports sync-file-encoding → iconvHelper → iconv-lite
tables — and esbuild folds a dynamic import of an already-statically-
imported module into a static chunk edge.
Fixes:
- add a `./omni` subpath export to @qwen-code/qwen-code-core (pattern:
existing ./transcriptRecords) and point every CLI-side dynamic import
at it, so only the omni module graph is requested;
- re-export processToolResultOmniMedia from omni/index (circular-safe:
both modules only bind functions) so the subpath serves the
tool-result funnel;
- coreToolScheduler and ACP Session load the funnel via dynamic import
(mirroring fileUtils' existing pattern for the omni module).
Verified: `npm run check:serve-fast-path-bundle` passes; bisected
against base in a clean worktree to isolate the offending edge.
Part of the omni-experiment S2 slice (#8184).
* fix(build): register the core ./omni subpath in test resolver configs
The previous fix added a `./omni` subpath export to
@qwen-code/qwen-code-core, but vitest/tsc in this monorepo resolve the
core package through explicit aliases to TS sources rather than the
package exports map — so every suite loading atCommandProcessor or the
ACP Session failed with "Failed to resolve import
'@qwen-code/qwen-code-core/omni'".
Add the subpath alias alongside the existing transcriptRecords/goalWire
precedents in cli/acp-bridge/sdk-typescript vitest configs and the
cli/acp-bridge tsconfig paths.
Part of the omni-experiment S2 slice (#8184).
* test: isolate runtime-path suites from the runner's QWEN_RUNTIME_DIR
The self-hosted CI runners export QWEN_RUNTIME_DIR for their own qwen
job tooling. Storage.getRuntimeBaseDir gives that env var top priority,
so logger.test.ts (HOME-derived path expectations) and the ACP Session
runtime-pinning tests (Storage.setRuntimeBaseDir-based expectations)
fail whenever a PR lands on such a runner — independent of the change
under test. Reproduced locally with QWEN_RUNTIME_DIR=/tmp/fake-spool.
Clear the variable inside the affected suites (restored after each
test), making them hermetic on any runner.
* fix(omni): gate the omni dynamic import behind isOmniEnabled
The `await import()` of the omni graph inside processSingleFileContent and
resolveAtCommandQuery performs a real filesystem read under vitest (the SSR
transform writes and reads the module under /tmp/RP_*/ssr/), so mock-fs
based suites — pathReader's image cases — failed with ENOENT on a path they
never mocked.
Gate both call sites on the cheap synchronous config.isOmniEnabled() before
reaching for the import. Sessions without omni configured (every existing
test suite, and every user who has not opted in) now never load the module,
which also avoids the wasted module load on the common path. Delivery
behavior when omni IS enabled is unchanged: isOmniDeliveryActive still
performs the full five-condition gate check afterwards.
* fix(omni): resolve DNS before each media fetch and close review gaps
SSRF (review-blocking): downloadMediaUrl's gate called only isPrivateHost,
which classifies hostname text and IP literals but never resolves. A
syntactically public name whose A/AAAA record points at 127.0.0.1,
169.254.169.254 or an RFC1918 address was therefore accepted, and because
this path uploads the fetched bytes to DashScope, an internal endpoint's
response would be exfiltrated.
Add findNonPublicAddress() to utils/fetch.ts: it resolves the hostname via
dns.lookup({all: true}) and rejects if ANY returned address is non-public,
since the connect may pick any of them. IP literals short-circuit without a
lookup. Extract isPrivateAddress() as the shared bare-address classifier so
isPrivateIp keeps one implementation.
download.ts calls it inside the redirect loop, immediately before each
connect, so every accepted hop is re-validated — a permitted same-host
redirect can still re-resolve to a private address between hops.
Two deliberate choices, both recorded in the doc comment: the helper is NOT
wired into fetchWithPolicy, because WebFetch intentionally supports intranet
FQDNs resolving to private addresses, so applying it there would be a silent
breaking change; and DNS failure returns null rather than failing closed,
because the connect that follows reports the real error and failing closed
would turn a resolver blip into something the user cannot act on. The
residual check-then-connect race is documented too: fetch resolves again, so
closing it fully needs an agent that pins the connect to a vetted address,
which undici does not expose portably.
Also fixed: sniffMediaType classified any file starting with 0xFF 0xFE (the
UTF-16 LE BOM) as audio/mpeg, because 0xFE passes the frame-sync mask. Real
MPEG frames never use 0xFE — the layer bits would be 11, reserved — so
excluding it costs no genuine detection and keeps the "null for non-media"
contract honest for callers without a secondary modality gate.
Tests for the previously uncovered branches reviewers flagged:
- download: public-looking host resolving to 127.0.0.1/169.254.169.254/
10.1.2.3; mixed A records where only one is private; per-hop re-check
asserting the redirect is refused before the second fetch; DNS failure;
the ?? fetch fallback via a globalThis spy; and the production DNS path
asserting dns.lookup receives {all: true, verbatim: true}
- readMediaViaOmniDelivery: image gets the resolution + zoom_image hint
parts, audio gets a bare fileData, failure yields an error result and
never inline base64
- probeMediaMetadata: the audio branch reads the audio stream (proven with a
file carrying both h264 and aac), the image branch reports no duration
- sniffFileModality: recognized headers, non-media, and an absent path
- atCommandProcessor URL pipeline: a URL with omni active becomes a fileData
part, the same URL with omni disabled falls through as text without
downloading, and a URL-only prompt does not hit the no-content early
return
* fix(omni): pin URL-media connections to the vetted address, fail closed
Validating the resolved address and then letting fetch re-resolve is
check-then-connect: a low-TTL record answering public once and private
the second time would still be connected to, and the fetched bytes are
uploaded to a third party. Each hop now resolves via
resolveNetworkTarget('public') — which vets every returned address and
returns a lookup pinned to the vetted one — and installs that lookup as
the request dispatcher, so the name is never resolved a second time.
- fail closed: unresolvable/unvettable hosts and targets without a
pinned lookup are refused instead of fetched unpinned
- re-validated and re-pinned at every redirect hop; the previous hop's
agent is replaced and closed
- fetch defaults to undici's own fetch so dispatcher and fetch come
from one undici version (mixing majors rejects the agent)
- Node-only: Bun accepts dispatcher and silently ignores it (verified
on 1.3.11), so non-Node runtimes are refused with a clear message
- https and credential-free URLs enforced with named errors
- utils/fetch.ts: remove findNonPublicAddress/HostResolver (a weaker
duplicate of resolveNetworkTarget) and the doc paragraph wrongly
claiming undici cannot pin connections
* fix(cli): stop the @-path parser truncating URLs at query strings
The @-path terminator set treats '?', '&', ',' and ';' as sentence
punctuation, which is right for filesystem paths but wrong for URL refs:
a presigned OSS/S3 link carries its signature in the query string, so
'@https://…/a.mkv?x-oss-signature=…' was silently cut at the '?' and the
download failed with HTTP 403 naming only the host.
URL refs now run to the first character RFC 3986 does not permit
unencoded (covering whitespace and unspaced CJK prose), with trailing
sentence punctuation trimmed afterwards so it can never cut a URL short
mid-query. Filesystem path parsing is unchanged.
* fix(cli): highlight the full URL ref in the input box
The input-box highlighter had its own @-ref charset that excluded '%',
'?', '&' and '=', so a presigned URL was painted file-colored only up to
the first percent-escape and looked truncated even after the parser was
fixed to consume it whole. URL refs now use the same RFC 3986 charset
and trailing-punctuation trim as parseAllAtCommands, keeping the painted
span identical to what the parser consumes.
* fix(omni): close review round-2 findings
Criticals:
- tool-result-media: staging-dir setup moved inside convertPart's try —
a mkdir failure (ENOSPC, EACCES, ~/.qwen/omni as a regular file) now
degrades that part to inline instead of rejecting the whole tool
result and reporting a succeeded tool as failed
- sanitizeErrorMessage: exact split/join replacement of the known
filePath before the pattern pass (immune to CJK segments, ~-prefixed
and special-character basenames), with separator-based — not
ASCII-word-based — fallback classes and a Windows-path pass
- the fail-closed delivery result now sanitizes BOTH llmContent and the
error field (the scheduler sends 'error', not llmContent, to the model
on READ_CONTENT_FAILURE) and names the file by displayName
- the 100 MB image-source cap only applies when the overview decoder
will actually run: it protects the decoder, and the omni path uploads
original bytes under its own 1 GiB ceiling without decoding
Suggestions:
- malformed redirect Location surfaces as a named OmniDownloadError
instead of a raw TypeError (header value kept out of the message)
- header/idle watchdog timeouts are now identifiable in the surfaced
error instead of a generic 'This operation was aborted'
- effectiveMaxDownloadFileBytes is clamped to the upload cap even when
omni.download.maxFileBytes is configured higher
- estimation rejects degenerate present values (0/negative/non-finite)
as 'unavailable' instead of emitting a 0-token 'ok' estimate
- corrected the 0xFE sniff comment: 0xFF 0xFE is a valid MPEG-1 Layer I
header (reserved encodings are version 01 / layer 00); the exclusion
is a deliberate trade against UTF-16 LE false positives
- SSRF tests now cover private IPv6 (loopback, link-local, ULA,
IPv4-mapped) plus a public-IPv6 acceptance case
Regression tests are mutation-verified: reverting the mkdir placement
or the error-field sanitization makes exactly the new tests fail.
* fix(omni): close review round-3 criticals
- sanitizeErrorMessage: a path segment containing a space defeats the
pattern pass (segment classes break at whitespace), so 'john doe'
survived scrubbing. Call sites now pass every path they know about:
the store wrap adds the store root (covers putFile throwing before
objectPath is assigned), and the upload wrap - previously passing
err.message through verbatim - sanitizes with the object path and
store root. Function exported for direct shape tests.
- estimation: animated images (GIF/APNG/animated WebP) no longer
estimate as a single frame. The image probe branch retains ffprobe's
nb_frames as frameCount, and the estimator applies the design 6.4
visual formula with the real count - a 480x480 300-frame GIF now
estimates ~33,750 tokens instead of 113, so an enabled token guard
actually engages. Static images and containers that do not report a
frame count keep frameCount 1.
- tool-result media: a transport-guard rejection is a policy verdict,
not a transfer failure - the part is now withheld with a text
placeholder instead of being delivered inline as base64, which
silently bypassed an enabled guard at greater request cost than the
upload it was rejecting. Transfer failures keep the inline
degradation; the batch never fails for one part.
- mid-turn URL media: resolveAtCommandQuery runs under a fixed 10s
mid-turn budget that structurally killed every mid-turn @https
reference (the download alone can exceed it, and inside the resolver
the timeout abort is indistinguishable from a user cancel). URL-media
turns are now exempt from that timer - the download path carries its
own 30s header and 60s idle watchdogs - while filesystem resolution
keeps the 10s cap unchanged.
- input-box highlighter: match the URL scheme case-insensitively, like
the parser it mirrors; an uppercase @HTTPS ref no longer half-paints.
* fix(omni): close review round-3 findings
- download: resolve-phase watchdog, malformed-URL and proxy refusal,
resolver-error translation, path scrubbing, cause-chain surfacing,
DownloadedMedia slimmed to partPath; mutation-proven tests for
redirect cap, both watchdogs, byte-cap streaming, abort wiring
- recognition: direct recognizeMediaFile tests, GIF 6-byte signature,
guarded probe-handle close; sha256 moved out of RecognizedMedia
- pipeline: hash after guards (stat -> byte guard -> probe -> token
guard -> hash); displayName override so URL-funnel errors name the
remote file, not the staging path
- fileUtils: omni gating suite incl. 100 MB image-cap bypass pin
- dead S1 shims deleted (video-only probe/delivery/read wrappers)
- URL funnel: post-download modality gate mirroring the local-file gate
* feat(omni): S3 delivery reliability — upload cache, credential reuse, failure semantics, startup recovery (#8632)
* feat(omni): S3 delivery reliability — upload cache, credential reuse, recovery
Third omni slice: repeated deliveries stop re-uploading, transient
server-side failures self-heal, and crash leftovers get swept.
- upload cache: persistent (sha256, model) → oss:// URL map at
.qwen/omni/upload-cache.json with a 47h TTL (48h official validity
minus margin). Atomic writes; corrupt files are backed up and rebuilt;
per-file op serialization prevents same-process load-modify-save
races; TTL configurable via omni.upload.cacheTtlHours (0 disables).
The cache file is the only place omni persists an oss URL — the URL
remains a delivery cache, never an identity.
- credential reuse: module-level getPolicy cache keyed by
origin|model|apiKey-hash, 240s TTL (300s official validity), shared
in-flight fetches, failures never cached.
- failure semantics: provider errors that name the oss scheme or the
media download step invalidate the matching cache entries (throttling
is explicitly exempted), so the user's retry re-uploads instead of
resending a dead reference — no automatic in-pipeline resend.
- startup recovery (lazy, once per process, never throws): expired
downloads/*.part removed (48h resume window), promotion .tmp orphans
older than a 1h grace window removed (younger ones may be another
process's in-flight rename), and a day-rotating sample of ≤3 objects
(≤64MiB each) is hash-verified with corrupt objects deleted and their
cache entries cascaded.
Verified: 89 omni unit tests; 9/9 real-API E2E (cache hit 3.1× faster on
a 20MB image; fake-URL 403 → invalidate → self-heal on retry; corruption
cascade; recovery sweep), adversarial review round applied (6 findings
fixed: cross-process .tmp grace, sampling bias+size cap, throttle-safe
invalidation matching, apiKey-scoped credentials, NaN expiry, in-process
cache races).
Part of the omni-experiment S3 slice (#8185).
* fix(omni): harden S3 cache, credential, and invalidation paths from review
Upload cache (upload-cache.ts):
- reject entries:null/array shapes as corrupt (backup + rebuild) instead
of throwing raw TypeErrors on every subsequent read
- treat only ENOENT/ENOTDIR as an empty cache; other read errors skip the
operation rather than persisting a wiped file over valid entries
- key entries by (sha256, model, scope) where scope fingerprints the
endpoint credential, so URLs never cross accounts or origins
- clamp positive TTLs to the 48h server-side URL validity
- cap .corrupt-* backups at 2; shrink the fileOps serializer map
Credential cache (upload.ts):
- getPolicy no longer bakes the first caller's AbortSignal into the
shared in-flight fetch; each caller races the shared promise against
its own signal, so one caller's abort cannot poison others
Failure semantics (pipeline.ts):
- invalidate oss cache entries on the streaming error path too (the CLI
default) by threading the request into processStreamWithLogging
- tighten /oss/i to /oss:\/\//i so 'connection loss' never nukes
healthy entries; await the invalidation instead of fire-and-forget
Pipeline integration (index.ts):
- check the upload cache BEFORE store promotion: a hit now skips the
full-file copy as well as the upload
- pass the credential-scope fingerprint into the cache
Recovery (recovery.ts):
- key the one-shot latch by omni root; guard a synchronously throwing
store; fix the stride-sampling tail blind spot; make sample budgets
injectable for tests; correct the .part retention comment
Adds mutation-proof suites across all five areas: 89 tests in the omni
package plus a dedicated pipeline invalidation suite.
* fix(omni): keep startup recovery inside the managed omni root
Recovery readdir'd downloads/, the objects root, and each shard without
checking for symlinks, and hashed sampled candidates via lstat without an
isFile() check — so a symlinked shard (or a symlinked downloads/ or
objects/ directory) could redirect the scan outside .qwen/omni, where a
hash-mismatched name would get an external file deleted. The store's own
symlink guard only runs at putFile, after recovery.
- reject symlinked/non-directory managed dirs (lstat isDirectory) before
traversing downloads/, the objects root, and every shard
- reject non-regular-file candidates before hashing in the sampler
- regression tests: symlinked shard (reviewer repro), symlinked
downloads/, symlinked objects root, symlinked candidate — all four
fail against the unfixed code and pin that external files survive
with content intact
* fix(omni): close root-level symlink escape and add wholesale cache TTL sweep
- startup recovery now verifies the intermediate directory chain (omni
root and objects/) with lstat before any sweep runs: the per-directory
guards only check the final path component, so a symlink planted at
.qwen/omni or .qwen/omni/objects redirected the entire scan outside
the managed tree; two containment tests mirror the existing four at
the new levels
- upload-cache put() now sweeps all expired entries while it holds the
serialized write: get() only prunes the queried key, so entries never
read again accumulated in upload-cache.json forever
* feat(omni): S4 policy pipeline — three-modality degradation policies, Stage B tools, modelAccess gating (#8815)
* refactor(omni): migrate settings to design namespace (processing/delivery/ingestion/storage)
- omni.upload.maxFileBytes -> omni.processing.transportGuard.maxUploadFileBytes (<=1GiB)
- omni.transport.maxEstimatedTokens -> omni.processing.transportGuard.maxEstimatedTokens
- omni.upload.cacheTtlHours -> omni.delivery.upload.urlTtlHours
- omni.download.maxFileBytes -> omni.ingestion.localization.url.maxFileBytes
- new: omni.processing.{limits,fixedPolicies,transportGuard.policies,policyTools},
omni.storage.quarantine.{retentionDays,maxBytes}
- core getters renamed to match; error messages cite new key paths
Experimental branch: one-shot rename, no migration shim.
* feat(omni): surface bitRate/sampleRateHz/channels from ffprobe
ffprobe already reports these; the parse branches discarded them. The
policy condition DSL (resource.bitRate / resource.sampleRateHz /
resource.channels) and degradation disclosures both need the real
numbers. Format-level bit_rate is preferred over the stream's.
* feat(omni): add media-policy tool protocol (execution origin, descriptor, modelAccess gate)
Protocol core for the S4 policy pipeline:
- ToolExecutionOrigin (model | client | fixed_policy) on
ToolCallRequestInfo; only settable by in-process callers, never
deserialized from protocol payloads; missing origin fails closed as
model.
- MediaPolicyToolDescriptor as a DeclarativeTool code-registration
getter (default undefined) — config can never turn an ordinary tool
into a policy tool.
- PolicyArtifactBatch channel on ToolCallResponseInfo, capturing raw
successful media-policy artifacts before PostToolUse hook merging;
error/timeout results promote nothing.
- Shared modelAccess resolver + call gate
(omni/policy/model-access.ts): media-policy tools are
fixed-policy-only unless
omni.processing.policyTools.<name>.modelAccess.enabled; model calls
get defaultArguments/lockedArguments merged and are rejected when
they name a locked key; a forged fixed_policy origin on a
non-media-policy tool is rejected.
- Gate enforced on every model surface: registry declaration lists
(incl. includeDeferred for subagents), ToolSearch keyword + select:,
CoreToolScheduler pre-build, and ACP Session.runTool.
- fixed_policy scheduler calls skip the interactive permission flow but
keep PermissionManager tool-enablement and hook execution.
- omni.processing.policyTools threaded through ConfigParameters
(core + cli).
* feat(omni): add staging/quarantine storage areas and recovery sweeps
Storage design §4.3/§4.4/§6.1 for the policy pipeline:
- OmniObjectStore gains staging/ and quarantine/ areas (0o700, symlink
refusal in ensureLayout) plus the invocation lifecycle: exclusive
createStagingDir (16-hex id validation, no silent reuse),
removeStagingDir, and quarantineInvocation — which writes reason.json
(policyId/toolName/reason/failedAt) into the staging directory before
a single atomic rename into quarantine/<invocationId>/.
- Startup recovery deletes everything under staging/ (entries belong to
invocations that never committed) and trims quarantine/ to a
retention window (default 7 days) and size budget (default 5 GiB,
oldest-first), following the existing isRealDirectory containment
convention: symlinked roots and entries are never traversed, sized,
or deleted through.
* feat(omni): add fixed-policy when-condition DSL evaluator and validator
Implement the restricted when-condition DSL from the policy design (§8.3):
recursive all/any combinators over gt|gte|lt|lte|eq comparisons, with
operands drawn from three read-only namespaces (resource.*, request.*,
session.*) or literals. No arbitrary code, no JSONPath.
Evaluation is three-valued. A comparison over an unresolvable field yields
`unavailable` with the missing fields recorded — never a silent false —
so the caller can apply the policy's onConditionUnavailable behavior and
surface the fields in the run record. Combinators use strong Kleene
logic: `all` with a false branch is a determinate no_match regardless of
unavailable siblings; `any` with a true branch is a determinate match.
Vacuous semantics: all [] → match, any [] → no_match.
The evaluator is a total function that never throws — structurally
malformed nodes degrade to `unavailable`, the fail-safe outcome. The
separate structural validator (for the startup config-normalization pass,
policy design §13 #5) rejects malformed conditions with path-annotated
errors: exactly-one-of comparison/all/any, non-empty combinator arrays,
exactly-one-of field/value per operand, known-field membership, and
finite numeric literals for ordering operators.
Condition types are re-exported from omni/policy/types.ts alongside the
rest of the policy protocol surface.
* feat(omni): add three-modality degradation media-policy tools
Commit 6 of the S4 policy pipeline: the three built-in degradation tools
(mapping doc §6), each a real BaseDeclarativeTool carrying a
media_policy descriptor so the orchestrator and the modelAccess gate can
key off code-level facts.
- omni/policy/tools/media-policy-tool.ts: shared base — validates
against the NATIVE parameter schema (never the model-visible
projection Stage B will narrow), async io assertions (lstat: symlink
input refused, real output directory required), per-tool
policyTools.<tool>.runtime.timeoutMs resolution (default 600s),
uniform success/error ToolResults; success emits exactly one lossy
artifact whose metadata.omniDisclosure carries the D8 disclosure and
whose workspacePath is staging-relative.
- omni_downsample_image (sharp, lazy-loaded per D9; load failure is an
execution failure): fit 1568px / JPEG q75, EXIF orientation baked in,
animated inputs refused outright (sharp would silently keep only the
first frame).
- omni_downscale_video (ffmpeg): scale to even target height computed
in JS (no filtergraph expressions), fps 10, x264 crf 28 veryfast;
audio stream-copy with AAC 64k fallback.
- omni_downsample_audio (ffmpeg): AAC 64kbps/16kHz/mono, -vn strips
cover art.
- omni/ffmpeg.ts: runFfmpeg — never rejects, callers branch on the exit
code and must check signal.aborted explicitly.
- Registered lazily behind isOmniEnabled(); the commit-3 modelAccess
gate keeps them hidden from model surfaces by default.
* feat(omni): add degradation result cache keyed by policy fingerprint
Commit 7 of the S4 policy pipeline (decision D2): reusing a previous
transcode instead of re-paying it — a 424MB video downscale is
minutes-long and must not run once per delivery round.
- omni/json-cache-file.ts: the file mechanics extracted verbatim from
upload-cache (per-file serialized load-modify-save, atomic tmp+rename
0600 writes, corrupt backup+rebuild with capped .corrupt-* backups,
unreadable-but-existing file = operation no-op) as a shared
OmniJsonCacheFile — the design doc mandates the degradation cache
mirror these exact semantics, so they now exist once.
- omni/upload-cache.ts: refactored onto the shared file; entry
semantics (model/scope keys, TTL clamp, invalidation) unchanged — all
22 existing tests pass untouched.
- omni/policy/degradation-cache.ts: (originalSha256, policyFingerprint)
→ { degradedSha256, extension, disclosure, mimeType } at
.qwen/omni/policy-cache.json. policyFingerprint = sha256(toolName +
key-sorted tunables + tool version); per-invocation io params
(inputPath/outputDir) are excluded — they are plumbing, not policy
identity. Entries carry no TTL (content-addressed identities never go
stale); removeByOriginalSha256 / removeByDegradedSha256 serve the
GC/corruption cascades. Object existence checks stay with the
orchestrator.
* feat(omni): add fixed-policy orchestrator and reorder the delivery pipeline
Introduce runFixedPolicies: for each recognized media resource it matches
normalized fixed policies (modality, origins, when-conditions), executes the
policy's media tool through the real executeToolCall protocol (fixed_policy
execution origin, recordToolResult:false, isolated staging outputDir),
validates the returned policy artifacts against the tool's declared
descriptor (workspace containment, recognized kind/mime match, mandatory
disclosure for lossy outputs), promotes derivatives into objects/ and records
them in the degradation cache keyed by original sha + policy fingerprint.
Reorder processMediaForOmniDelivery so transport guards judge the FINAL
delivery set instead of the source: recognize -> recovery -> fixed policies
-> guards -> hash -> upload. Degraded deliveries carry a disclosure that is
emitted as a text Part immediately before the media Part everywhere media
surfaces (read tool results, tool-result inline media conversion), and the
OpenAI converter moves the disclosure together with its media part when
splitting tool media into a follow-up user message.
* feat(omni): normalize omni.processing config with system default policies
Startup normalization of the fixed-policy pipeline configuration
(policy design §13 applicable subset):
- omni/policy/config.ts: normalizeOmniProcessingConfig merges user
settings over system defaults (id-merge, whole-entry replacement,
null tombstones for fixedPolicies only) and validates structure,
enums, when-conditions, tool references (registered + media_policy
descriptor + required/lossy-disclosure outputs), fixed arguments
against the io-stripped settingsSchema, reserved io keys, guard
rules (no when, source=omit, mandatory three-modality coverage),
limits (§12.2 defaults), the 1 GiB upload cap and the 48h URL TTL.
Any violation throws OmniPolicyConfigError and aborts startup — a
mis-configured guard must never degrade into sending over-limit
media.
- System defaults (D7 dual registration): the three degradation tools
registered as preprocessing fixedPolicies WITH when-thresholds and
as transportGuard.policies WITHOUT when.
- core Config: thread fixedPolicies / transportGuard.policies /
limits / quarantine settings; normalize in initialize() after tool
warmup; expose getOmniProcessingConfig and quarantine getters.
- cli: thread the new omni.processing / omni.storage.quarantine keys.
* feat(omni): enforce policy budgets, transport-guard passes and quarantine (Stage B)
- runFixedPolicies enforces maxPolicyRunsPerRoot (checked before each
execution), maxArtifactsPerRoot and maxDerivedBytesPerRoot (checked as
derivatives land); exhaustion records outcome 'budget_exhausted' while
committed deliveries stand; maxLineageDepth clamps reprocessing.
- Failed invocations quarantine their staging dir with a reason.json
(policyId, toolName, reason, failedAt); user aborts and quarantine
failures fall back to plain staging removal. Startup recovery receives
quarantine retention/size settings from config.
- Transport-guard violations now run modality-matched guard policies for
up to maxTransportPasses passes on the final delivery; a still-violating
resource is explicitly omitted (【媒体省略】notice replaces the part in
both the read path and tool-result funnel) instead of delivered
oversized; guard-pass failures stay fail-closed. Configs without a
normalized processing config keep the Stage A throw.
- Media-policy tools project a model-visible declaration (D6): locked
arguments removed from properties/required, optional narrowing-only
parameterSchema merge and description override; validation keeps the
native schema.
* fix(omni): declare the disclosure text output in degradation tool descriptors
All three degradation tools emit a disclosure at runtime, but their
descriptors never declared the text output, so the system default
policies failed the lossy-requires-disclosure validation (config §13 #8)
at real startup. Stub-based unit tests missed the drift; config.test.ts
now normalizes the defaults against the real tool instances.
* fix(omni): harden policy pipeline per review (cache validation, permissions, settings, sweep grace, concurrency)
- validate degradation-cache entries and object-store path components
(sha256/extension shape) so a poisoned policy-cache.json cannot traverse
paths or serve malformed derivatives; malformed entries self-heal
- re-hash cache-hit objects before reuse: mismatched bytes trigger a fresh
transcode and heal the object store (D2 integrity)
- default media policy tools to 'ask' permission via a shared
BaseMediaPolicyToolInvocation (model-origin calls confirm outside yolo;
fixed_policy runs are unaffected)
- consume policyTools settings from config: tool-level settings defaults
merge under policy arguments and feed the cache fingerprint so settings
edits invalidate cached derivatives
- deliver @url omission notices and degradation disclosures: omission
replaces the fileData part with the notice text; disclosure text lands
immediately before its fileData part (D8)
- give staging sweep a 1h multi-process grace window (only entries older
than the window are deleted; symlink entries removed regardless of age)
- gate concurrent fixed-policy runs per omni root with a FIFO counting
semaphore honoring maxConcurrentResources
* fix(omni): harden policy pipeline after self-review round
Orchestrator: exclude animated images from policy matching (D9), self-heal
unverifiable degradation-cache entries, key cache fingerprints with the
descriptor version, and warn when reprocess origins exclude 'policy'.
Scheduler: fixed_policy invocations no longer bounce PreToolUse 'ask' to an
unanswerable awaiting_approval (deny stays fail-closed), and
processToolResultImages short-circuits for fixed_policy results at the
method level so all three call sites skip the omni re-delivery / vision
bridge re-entrancy.
Delivery/CLI: chain preprocessing and guard disclosures instead of replacing
(D8), exclude hidden media-policy tools from the /context per-tool breakdown
to keep it aligned with getFunctionDeclarations(), annotate fixed-only
media-policy tools in /tools, and drop the stale maxConcurrency mention from
the policyTools settings description.
Tests: add negative coverage for artifact validation (no artifacts,
undeclared media type, kind mismatch, missing required output) and for the
fixed_policy hook ask/deny and image-funnel paths.
* fix(omni): drop system-default fixedPolicies; guard is the only default
The three degradation tools were registered twice: as transportGuard
policies (design-mandated, always-on, transport-limit triggered) and as
fixedPolicies with invented when-thresholds (1568px/480p/96kbps). The
upstream design gives fixedPolicies pure user-experiment semantics — a
zero-config setup must not trigger any preprocessing below transport
limits. Remove the fixedPolicies-side registration and its threshold
constants; keep the guard defaults. Threshold acceptance for #8186 moves
to explicitly-configured fixedPolicies in E2E.
* feat(omni): add four local stage B policy tools (keyframes/extract-audio/clip/convert)
- omni_extract_keyframes: ffmpeg scene detection (select+showinfo) with
uniform-fps fallback, multi-artifact batch, per-frame disclosures
- omni_extract_audio: audio track extraction to WAV/MP3/M4A, 16kHz mono
WAV default (ASR-recommended shape)
- omni_clip_video: frame-accurate time-axis cut (input-side -ss/-t,
libx264 re-encode, faststart), rejects no-op invocations
- omni_convert_image: sharp re-encode to JPEG/PNG/WEBP with EXIF
orientation baked in, animated inputs refused
- uniform lossy declaration per mapping doc §6.1: every output carries a
disclosure since the representation change itself is lossy
- extract shared sharp loader (sharp-module.ts) and describeChannels
helper; register the four tools lazily behind isOmniEnabled()
* feat(omni): add omni_transcribe_audio with transcript delivery protocol
* feat(omni): fill request/session condition namespaces and wire client origin
The when-condition evaluator has supported the request.* and session.*
namespaces since the initial S4 landing, but no caller populated them, so
every policy referencing those fields resolved to 'unavailable'. This
fills both per policy design section 8.3:
- request.totalEstimatedMediaTokens is computed inside the orchestrator
from the pending-delivery work-item set at each pass start, so it is
recomputed as derivatives enter later passes. If any pending resource
is unestimable the whole namespace reads unavailable - a partial sum
must never pass thresholds as a smaller total. Callers can override
via options.conditionContext.request (future multi-root aggregation).
- session.* is snapshotted once per media delivery by the new
policy/session-context.ts (contextWindowTokens from
contentGeneratorConfig.contextWindowSize, promptTokenCount from the
CURRENT chat's getLastPromptTokenCount, availableContextTokens as a
clamped subtraction only when both inputs are known) and reused across
preprocessing and every transport-guard pass of that delivery.
Also wires the client execution origin: slash commands scheduling a tool
(schedule_tool in useGeminiStream) now stamp executionOrigin
{kind:'client'}, putting the in-process client channel under the same
media-policy modelAccess gate as model calls.
* fix(omni): deliver every media deliverable of multi-output fixed policies
A fixed policy producing multiple media deliverables (omni_extract_keyframes
promoting N frames) previously failed delivery with "exactly one is
supported" after all frames were already atomically promoted to objects/ —
zero frames reached the model, violating the multi-output transaction
visibility acceptance.
Fixed-side contract now: deliveries[0] is the primary (unchanged path:
guard re-check, upload, disclosure); the rest ride along as
OmniMediaDelivery.additionalMedia, each independently judged against the
transport limits and uploaded through the same hash → upload-cache →
objects promotion → DashScope pipeline. Extras are processed lazily after
the primary settles, so the primary uploads first and a fail-closed
primary never wastes extra uploads. An over-limit extra becomes an
explicit omission entry (visible reason, no guard-policy re-derivation —
it is already a policy product). The guard side keeps its strict 1:1
semantics.
All three delivery consumers (readMediaViaOmniDelivery, tool-result
media conversion, the @-command URL funnel) materialize extras through a
shared buildAdditionalMediaParts helper: per-extra [disclosure?,
fileData | omission text] pairs after the primary slot, before
transcripts, preserving disclosure adjacency per pair.
* fix(core): detect mkv/avi/flac/aac media missing from mime/lite standard db
mime/lite's default database carries no mapping for .mkv/.avi/.flac/.aac,
so detectFileType fell through to the binary content sampler and a real
Matroska movie was rejected by the 10MB inline cap instead of entering
media delivery. Generalize the existing .m4v override map to cover these
containers (authoritative types from the full mime db), scoped to
extensions the omni recognizer can sniff-confirm so extension lies still
fall back to the legacy path.
* fix(omni): harden policy pipeline per self-review correctness findings
- Validate operator-configured defaultArguments/lockedArguments against a
tool's NATIVE parameterSchema instead of the model-visible projection,
which strips exactly those keys and rejected every legitimate locked or
operator-only argument at startup; regression test uses real tools.
- Write cache files with noFollow so the atomic rename replaces a symlink
planted at the cache path instead of writing (and chmod-ing) through it.
- Route every .part staging write through prepareOmniDownloadsDir, a
fail-closed symlink guard (mkdir recursive succeeds silently on a link).
- Detect ADTS AAC in the MPEG-audio sniffer (layer bits 00) so .aac
streams stop being labeled audio/mpeg.
- Preserve metadata.omniRole in degradation-cache entries so cached
reruns keep the same artifact matching as fresh derivations.
- Fall back to the default quarantine retention when the configured value
is zero/negative/NaN instead of expiring the whole quarantine.
- Propagate error causes through OmniTransportGuardError and document the
operatorOnlyParams contract on MediaPolicyToolDescriptor.
* refactor(omni): consolidate media-policy tool plumbing
- Single-source tool names from ToolNames across all eight policy tools
and the config validator.
- Share the config view on the base class (protected configView) instead
of eight shadowed per-subclass fields; move the view type and the
isPlainRecord helper to policy/types.
- Memoize the model-visible schema projection on the settings object's
identity (one normalized settings object per initialize()).
- Extract sharpTimeoutSeconds for the duplicated libvips timeout
conversion and route clip-video's io validation through the base
validateToolParamValues.
* refactor(omni): share the transcript materializer across delivery consumers
Extract buildTranscriptParts (§6.2/D8 ordering: each transcript follows
its media part or omission notice, preceded by its own disclosure) and
use it from readMediaViaOmniDelivery, the tool-result funnel, and the
CLI @-command URL funnel instead of three hand-rolled loops. Also
extract the textOnlyDelivery helper, upload additional-media extras
concurrently (content-addressed store and per-file serialized upload
cache make them independent; map preserves deliverable order), and drop
the now-consumerless formatTranscriptText barrel export.
* perf(omni): parallelize orchestrator validation and promotion
Validate a batch's artifacts and promote them into the object store
concurrently — they are independent files in a shared staging dir and
the store is content-addressed with race-safe puts; map keeps the batch
order and a sequential partition preserves derived/derivedFiles order.
File artifacts hash the bytes already in memory for the UTF-8 check
instead of streaming the file a second time.
* fix(omni): add display-name translations for the media-policy tools
The eight omni media-policy tools landed without toolDisplayName locale
entries (cli zh/zh-TW/en) or web-shell wire-name display mappings and
toolName translations, tripping both display-name drift guards
(cli src/i18n/index.test.ts and web-shell toolFormatting tests).
* fix(omni): open the download .part fd before the pipeline starts
createWriteStream opens lazily, so a pipeline failure on an
all-synchronous body (the byte cap tripping on the first chunks) could
reject — and reach the outer .part cleanup — before the file was even
created, resurrecting the .part after its rm. Await the open event
before streaming so creation always precedes cleanup.
* feat(omni): switch when-condition DSL to expression arrays
Replace the {left, operator, right} object form with Mapbox-style
expression arrays: a comparison is [op, operand, operand] with op one of
> >= < <= == !=, an operand is a ["field", "<namespace.name>"]
reference or a bare literal, and combinators nest as ["all", ...],
["any", ...], and the new ["!", expr] negation. The array form is
shorter to write, nests without ceremony, and leaves room for future
operators.
Semantics are unchanged: three-valued evaluation with strong Kleene
combinators, unavailable fields never silently collapse to false, and
negation passes unavailable through rather than laundering unknowns.
Startup validation rejects the retired object form with a pointed
migration hint instead of a generic type error.
* feat(omni): degrade delivered media and retry when the server rejects input as over-limit
The server's real input ceiling is unknowable locally (frame-based video
billing is invisible to the byte/duration estimator), so a 400 like
DashScope's 'Range of input length should be [1, 196608]' previously
aborted the session even though history compression cannot shrink media
tokens.
Now, before reactive compression, the chat loop reverse-maps each
delivered oss:// URL through the upload cache to its stored object,
re-runs the configured transport-guard policy with an escalating
argument ladder (video fps 2 -> 0.5 -> 0.25 is the only lever that
reduces frame-billed tokens), re-uploads the derivative, swaps the
history parts in place with a fresh disclosure, and retries. Bounded by
limits.maxTransportPasses; best-effort throughout - any failure falls
through to the existing compression/error paths.
Also: parse the DashScope input-range upper bound as limitTokens, allow
fractional fps in omni_downscale_video, and add a scope-agnostic
findSha256ByUrl reverse lookup to the upload cache.
* feat(omni): inject progressive media understanding guidance into the system prompt
Disclosure parts state WHAT a delivery lost, but nothing told the model
WHY: degradation is a context-budget-driven progressive-understanding
strategy, the degraded delivery is an overview rather than the complete
content, and fuller evidence can be fetched on demand. Without that
contract the model treats a 600s clip + opening keyframes as the whole
film and extrapolates.
- omni/media-guidance.ts: builds a stable system-prompt section that
explains the three disclosure markers, frames degraded deliveries as
progressive-understanding overviews (never conclude undelivered
content doesn't exist), and directs proactive targeted evidence
fetching via exactly the modelAccess-enabled media policy tools; with
none enabled it instructs stating missing evidence instead of
extrapolating. Returns null when omni delivery is inactive.
- prompts.ts: new stable SystemPromptLayers slot mediaGuidance, placed
right after base so it stays inside the cached static prefix.
- client.ts: wire the section into the main-session stable layers.
- omni/delivery-gate.ts: isOmniDeliveryActive moved out of omni/index.ts
into a leaf module so prompt assembly doesn't statically pull the
whole delivery pipeline; index.ts re-exports it unchanged.
* fix(omni): extract keyframes across the full video duration (bucketed sampling)
The scene-detection pass stopped at the first maxFrames scene changes,
so every keyframe of a long video landed in its opening minutes (an
81-minute film yielded 16 frames all within 0-130s). Split the timeline
into maxFrames equal buckets instead: each bucket contributes one frame
- a scene change from its opening window (input seek, 30s search cap)
when one exists, the bucket midpoint otherwise. Absolute timestamps are
reconstructed as bucketStart + showinfo pts_time. Individual bucket
failures are tolerated and all runs share one wall-clock budget. The
single-pass path remains for unknown duration or maxFrames = 1; the
now-unreachable uniform-sampling fallback is removed.
* fix(omni): chunk long-audio transcription and collapse repetition degeneration
A single ASR request over long audio truncates well before the end and
degenerates into repetition loops (an 81-min film came back as 2114
chars ending in 48 copies of "Hej!"). Audio longer than chunkSeconds
(default 180s) is now split into equal segments, re-encoded to 16kHz
mono AAC, transcribed with bounded concurrency under one shared
wall-clock budget, and assembled into [MM:SS-MM:SS]-labeled lines.
Per-segment failures become inline markers instead of failing the run;
the run only errors when every segment failed. Repetition degeneration
is detected and collapsed in every transcript (chunked and single-shot)
and reported in the disclosure.
* docs(omni): add media policy orchestration usage guide
* fix(omni): key pure-transcript guard resolution on this pass's file deliveries
A transport-guard pass that omitted the source without producing any
deliverable used to short-circuit into textOnlyDelivery whenever an
EARLIER pass had already collected a transcript, silently dropping the
zero-deliverable error. Key the branch on the current pass's
fileDeliveries instead of the cumulative transcript list.
* fix(omni): render the primary disclosure on pure-transcript deliveries
All three delivery consumers (readMediaViaOmniDelivery, tool-result
media, @-command processor) dropped delivery.disclosure whenever the
primary media degraded all the way to a transcript-only delivery. The
disclosure chains every prior lossy step (decision D8) and the
transcript was derived through those steps, so it must still render —
as the leading text part of the delivery.
* fix(omni): prune non-object entry values when loading a JSON cache file
The load-time shape check validated the top-level {version, entries}
envelope but trusted every entry VALUE, so a crafted workspace cache
holding e.g. `"key": null` surfaced as TypeErrors from field accessors
in whole-file scans (removeByDegradedSha256 touches every entry, not
just the requested key). Drop malformed values at load and log the
count; the next put() rebuilds them from verified data.
* fix(omni): harden degradation-cache hits against tampered entries
Two gaps in the workspace-shippable cache's trust boundary:
- disclosure had no length ceiling, leaving an unbounded
prompt-stuffing channel that renders into the conversation on every
cache hit. Entries with a disclosure over 2048 chars are now dropped
like any other malformed field.
- the hit path verified the derivative's hash but not its TYPE: a
swapped extension/mimeType could deliver a media kind the producing
tool never declared. The orchestrator now cross-checks the recognized
MIME type against the tool descriptor's declared media outputs and
re-transcodes on mismatch, dropping the entry.
* fix(omni): include the hours field when a rounded duration reaches 3600s
withHours compared the raw duration while formatClock rounds each
boundary: a 3599.6s audio rounded to 3600s inside the label and, with
hours disabled, rendered as "00:00". Round the duration the same way
formatClock does before deciding the format.
* fix(omni): cap chunked transcription at 512 segments (fail closed)
The claimed duration comes from container metadata, which a crafted
file controls freely: an absurd duration used to fan out into hundreds
of thousands of outcome slots and queued ffmpeg cuts before the time
budget could intervene. Reject anything over 512 segments — at the 30s
chunkSeconds floor that is ~4h16m of audio, far past what the 10MiB
default input cap plausibly holds.
* fix(omni): disable the omni pipeline in bare mode
isOmniEnabled ignored bareMode, so --bare sessions still registered the
media-policy tools, ran content normalization and the ffmpeg startup
assert, and gated deliveries. Bare mode now wins over the env opt-in at
the single choke point every omni entry path already consults.
* fix(omni): degrade media nested in functionResponse.parts on reactive retry
The reactive over-limit fallback only walked top-level content parts,
so media delivered inside a tool result's functionResponse.parts (the
normal carrier for tool media) was invisible to it: the retry loop
resent an unchanged request and stalled. Collection and replacement now
descend one level into functionResponse.parts, and the swap rebuilds
the nested array in place — the disclosure must land immediately before
its media part (D8), which hoisting to the top level would break.
* fix(omni): detect animated images whose headers carry no frame count
Animated WebP/APNG containers report no nb_frames, so the D9 animation
exclusion silently passed them and the image tools kept only the first
frame with no disclosure. Two independent gates now close this:
- the ffprobe path re-probes animation-capable containers
(gif/webp/png/apng) with -count_frames when nb_frames is absent
- both image tools additionally check sharp's metadata().pages before
encoding and refuse multi-page inputs
* docs(omni): align tunable descriptions with the implemented semantics
- maxWidth/maxHeight are same-value aliases of width/height, not
independent axis caps
- request.totalEstimatedMediaTokens is the per-resource estimate
(resource plus its derivatives) within the current scheduling pass,
not a cross-resource total
* fix(omni): keep a raw "__proto__" policy id as an ordinary own key
A JSON settings file can carry "__proto__" as an ordinary key; spreading
it into a plain object routes it through the prototype setter and the
entry silently vanishes. Merge policy maps onto a null-prototype object
so the id reaches normalizePolicy and is rejected by the id pattern.
* fix(omni): validate transportGuard.maxEstimatedTokens at startup
Settings load performs no runtime type checks and guard.ts compares the
threshold with <=/>: a string value makes both comparisons false and
silently disables the token guard (fail-open). Reject anything but a
finite number >= 0 at normalization, and thread the value through
Config so the check actually sees it.
* fix(omni): raise quarantine retention/budget schema minimums to 1
The runtime accessors treat non-positive values as absent and fall back
to the defaults, so a configured 0 never means "delete immediately" /
"no budget" — advertising minimum: 0 in the IDE schema promised a
semantic the code does not honor. Document the fallback in the
descriptions as well.
* docs(omni): correct the fixedPolicies merge/tombstone description
There are no built-in default fixed policies, so "null tombstones a
default policy" described a merge target that does not exist. Describe
what the merge actually does: entries merge by id across settings
scopes and a null entry tombstones a policy from a lower-priority
scope.
* fix(omni): expose io paths to the AUTO-mode permission classifier
Media policy tools override getDefaultPermission but left
toAutoClassifierInput at the empty-string sentinel, so the AUTO-mode
classifier saw "Arguments: {}" and its path-based block rules could
never fire on a model-origin call. Project exactly inputPath and
outputDir — the fields those rules key on, carrying no secrets.
* fix(omni): reject unknown keys in policyTools entries (§13 #1)
A typo like "settigns" or "modelaccess" read as an absent optional
section downstream, so the intended configuration silently never took
effect. Fail startup on unknown keys at the entry, runtime, and
modelAccess levels, and drop the stale maxConcurrency mention from the
runtime doc comment (timeoutMs is the only supported limit).
* fix(omni): reject parameterSchema overrides that loosen native constraints
Design §11.2: a projection may only narrow the tool's native schema —
类型、枚举、范围 included, not just the property set. The declaration
merges each projected property's keys over the native ones, so an
override raising maximum, lowering minimum, widening an enum, changing
the type, or relaxing min/max length/items promised the model a range
the native per-call validation then rejects. Validate the merged
property schema against the native effective bounds at startup
(minimum/exclusiveMinimum interplay included).
* fix(omni): clamp downsample-audio targets to the probed source
A source already below the target bit rate, sample rate, or channel
count re-encoded into a LARGER derivative that the transport guard
counted as progress, under a disclosure claiming 高频细节丢失/声道合并
losses that never happened. Clamp each target to the probed source
(the withoutEnlargement analogue of the image/video tools) and build
the loss clause from the drops that actually occurred (D8).
* fix(omni): reject full-span no-op clips instead of re-encoding
clip_video is a time-axis cut, not a degradation tool: a span covering
the entire video only burns a lossy re-encode while the disclosure
falsely claims content outside the span was discarded. Reject
startSec 0 with no durationSec at the parameter layer, and a
probed-duration-covering span before the transcode starts.
* fix(omni): disclose partial bucket coverage in keyframe extraction
When some buckets yield no frame (scene attempt and midpoint fallback
both empty), the blanket 全片分桶采样 note overstated coverage — the
un-sampled buckets' time ranges were silently invisible to the model.
Disclose 仅覆盖 N/M 个分桶,其余时段未采样 whenever fewer frames than
buckets survive (D8).
* fix(omni): only claim alpha loss for alpha-capable JPEG conversion sources
A JPEG→JPEG re-encode cannot drop a transparency channel that never
existed, yet the disclosure asserted 透明通道丢弃 unconditionally.
Derive the loss clause from the probed input codec: alpha-capable
formats (png/webp/gif/tiff) keep the claim, others get 元数据丢弃, and
an unknown codec hedges with 如有 (D8).
* fix(omni): build the downscale-video loss clause from actual drops
A 360p@8fps input downscaled against the 480p/10fps defaults lowers
neither dimension, yet the disclosure always claimed
分辨率与帧率下降 — contradicting the before/after figures shown right
next to it. Emit 分辨率下降/帧率下降 only for the parameters that
actually dropped, falling back to 重新编码压缩 for a pure re-encode
(D8).
* fix(omni): stop calling a degraded delivery "full resolution" in the zoom hint
On the degradation path delivery.recognized re-recognizes the
DERIVATIVE, so the hint's width/height are the downsampled dimensions.
Calling them "full resolution" contradicted the degradation disclosure
pushed immediately below and steered the model away from zoom_image —
the exact remedy for degradation-stripped detail, since it reads the
original from disk.
* fix(omni): localize the fixed-only tool description suffix
The '[fixed-only: runs via media policies, not the model]' suffix was
appended as a hard-coded English string, so zh/zh-TW tool listings
showed a mixed-language description. Route it through the i18n table
like the display names it accompanies.
* fix(omni): unify the downscale-video display name as 降采样视频
降清视频 was a coined term inconsistent with the 降采样图像/降采样音频
siblings; use 降采样视频 (zh-TW: 降採樣影片) across the CLI locales and
the web-shell tool-name table.
* test(omni): match the actual .tmp suffix in cache-file cleanup assertions
The write-temp files are named <name>.<random>.tmp; filtering on
'.tmp-' could never match anything, so the leak assertions were
vacuously green. Filter on the real endsWith('.tmp') suffix.
* test(omni): pin the repetition-collapse rep threshold independently of the span floor
The 好的, unit (3 chars × 7 reps = 21 chars) sat below the 24-char span
floor, so the test passed for the wrong reason and REPETITION_MIN_REPS
itself was unpinned. Use a 6-char unit so 7 reps already clear the span
floor, and assert the collapse output at 8 reps.
* fix(omni): cascade corrupt-object deletion into the degradation cache
Startup recovery's sample verification deleted a corrupt object and its
upload-cache entry but left policy-cache.json untouched, accumulating
entries that can never be served again. Cascade the deletion both ways
— the object may be a policy SOURCE (its derivatives can no longer be
re-verified) or a policy DERIVATIVE (entries pointing at it are dead) —
mirroring the storage design's upload-cache cascade. The orchestrator's
lazy self-heal only fires on a cache hit; the sweep side is proactive.
* refactor(omni): resolve role selectors via a role→output-spec map
The role existence check consulted a Set while the transcript shape
check re-scanned descriptor.outputs with a find, duplicating the
first-declaration-wins semantics across two structures. A single
Map<role, spec> now serves both.
* test(omni): pin the when-condition round-trip through normalization (D7)
when is preprocessing's ONLY trigger mechanism: a normalization
regression that drops or rewrites it would silently widen every user
condition to ALL matching resources, with no failing test. Pin that a
valid condition tree survives normalization verbatim.
* test(omni): pin the runtime.timeoutMs / staging-grace boundary (recovery §5)
The timeoutMs < STAGING_GRACE_MS cap is a cross-file invariant: a tool
allowed to run for the full grace window could have its live staging
directory swept as crash leftovers mid-run by another process's startup
recovery. Pin BOTH sides of the boundary against the exported constant
so removing, inverting, or relocating the cap fails a test.
* feat(omni): S5 multimodal media memory — two-trigger collection, root-bounded recall, active/sideQuery surfaces (#8949)
* feat(omni): collect multimodal media memory at recognition and policy-success points
Add the media-memory service (packages/core/src/services/media-memory):
a persistent, file-scoped lineage graph over .qwen/omni/memory.json that
records recognized files/versions and policy executions with their
derived media and text outputs.
Collection side (Stage A of the multimodal memory design):
- store.ts: single-JSON v1 backend with atomic writes, corrupt-document
backup/rebuild, and read/transact serialization shared with the omni
JSON cache chain.
- service.ts: recordFileRecognized (idempotent file+version upsert,
CURRENT_VERSION follows disk both ways) and commitPolicySucceeded
(one atomic commit per succeeded policy execution: execution record,
derived versions with DERIVED_FROM/PRODUCED_BY lineage, and
derived_media/policy_result entries with conservative channel and
coverage derivations). executionId is a content-identity key
(source sha256 + policy fingerprint), so replays and degradation-cache
hits converge on the same execution node. findBindingBySha256 gives
the reactive ladder a read-only identity lookup.
- config.ts: omni.memory normalization with fail-fast validation
(unknown keys, budgets, kinds, cross-field ordering).
Wire both trigger points: processMediaForOmniDelivery records the
source file after recognition (hashing the source upfront and threading
sha256 through the policy pipeline), and executePolicy commits after
object promotion on both the success and degradation-cache-hit paths.
Persistence failures are logged and never block delivery; a policy
commit itself stays all-or-nothing.
* fix(omni): label keyframe and clip artifacts with their roles
omni_extract_keyframes and omni_clip_video emitted artifacts without
metadata.omniRole, so role consumers could not tell an excerpt from a
complete derivative: output routing selectors (role:keyframe /
role:clip) never matched, and media-memory coverage derivation fell
through to 'complete' for sampled keyframes and temporal clips.
Label them 'keyframe' and 'clip', matching the transcript labeling
convention.
* feat(omni): add session resource registry and memory recall service
Stage B read side of multimodal media memory (design M §9):
- MediaResourceRegistry: session-scoped opaque handle binder
(fileVersionId <-> resourceId, M §5.2) so recall payloads never
carry real paths or stable identifiers.
- MediaMemoryRecallService: current-version-first recall (§9.5) over
the rootFileId-bounded derivation graph (§8), returning the minimal
protocol shape (§9.4) with synthesized metadata/execution entries,
honest gaps (not_processed / partial_coverage / artifact_unavailable,
D5), and advisor-driven nextPolicyActions. Unknown or empty resource
requests reject as a whole (§9.2); an unreadable store degrades to
a plain miss.
* feat(omni): mount session media resource registry on delivery
Hang a lazy MediaResourceRegistry off Config and bind every
memory-known resource the delivery pipeline puts in front of the
model — the source at recognition, each preprocessing deliverable,
and the transport-guard replacement — so recall can rebind opaque
session handles back to persistent memory identities without ever
exposing a path.
* feat(omni): add active-mode media memory recall tool and session handle disclosure
Stage B recall surface #1 (memory design M §9, D10 mutual exclusion):
- omni_recall_media_memory tool, registered only when omni is enabled AND
omni.memory.recall.mode === 'active'. Read-only (D11): consults the
persistent store through MediaMemoryRecallService and binds session
handles for returned derived artifacts; whole-request rejection maps to
invalid_tool_params so the model can correct and retry.
- Session resource handles are now disclosed to the model: delivery
carries the SOURCE binding's resourceId, and both consumers (file reads
and the tool-result funnel) lead the part group with a 【媒体资源】
annotation — the handle stands in for the path the model never sees
(M §5.2), while disclosures keep their D8 adjacency to the media part.
- Advisor wiring: recall gaps suggest nextPolicyActions only for tools
that are both registered and opened via modelAccess.enabled — recall
never steers the model into calls the media-policy gate would reject.
- omni.memory normalization moved to first use (ensureOmniMemoryConfig):
createToolRegistry needs recall.mode before initialize()'s omni block
runs; invalid settings stay startup-fatal on both paths.
* feat(omni): resolve resourceId to inputPath at the media-policy call gate
Stage B evidence-gathering path (memory design M §5.2): a gated
model/client call of a media-policy tool may now name its source by the
opaque session resourceId announced at delivery or returned by recall,
instead of a filesystem path the model must never see.
- evaluateMediaPolicyToolCall resolves the handle through the session
registry BEFORE the lockedArguments check (a resolved inputPath cannot
sidestep an operator-pinned input); unknown/fabricated handles and
inputPath+resourceId together reject as invalid_params; fixed_policy
origins remain untouched (RESERVED_ARGUMENT_KEYS already bans the key
in policy arguments).
- Shared io schema advertises resourceId as the alternative to
inputPath; required drops inputPath (presence is enforced in
validateMediaPolicyIoParams AFTER gate resolution, with an actionable
either-or message).
- assertMediaPolicyIo input errors now name only the file's basename:
those messages reach the model, and a handle-resolved call must not
leak the locator the handle stands in for — basename matches the
displayName the model already saw at delivery (D5: a deleted source
errors without disclosing its path).
* feat(omni): passive sideQuery media-memory recall before the main request
Stage B recall surface #2 (memory design M §9.3, D10 sideQuery mode):
- MediaMemoryRecallService grows the two sideQuery faces on the same
root-bounded walk recall() uses: candidateSummaries() builds the
bounded selector manifest (structure + capped preview, never raw
media/full text/paths; maxCandidateEntries cap, deterministic order),
and recallSelection() materializes a selection through the unified
protocol — an unknown/over-budget entryId rejects the WHOLE selection
(invalid_selection), never a partial fulfilment.
- runOmniMemorySideQuery orchestrates one passive pass: handles are
parsed from the 【媒体资源】 annotations the request itself carries
(never a project-wide scan), the selector runs on the runSideQuery
JSON face (entryIds only, validate enforces manifest membership and
maxSelectedEntries), and every failure — timeout, selector error,
rejected selection — degrades to an empty recall with a recorded
reason while the main request proceeds.
- client.ts injects the materialized recall as a system reminder on
UserQuery/Cron turns strictly BEFORE the main request is sent (M
§9.3: never retrofitted into a later turn); latency is bounded by
sideQuery.timeoutMs.
- disclosure.ts gains parseResourceHandleText, keying on the
harness-minted handle grammar so displayNames containing the
separator cannot confuse extraction.
* fix(omni): propagate the recall tool display name to every drift-guarded surface
A new core tool name must land in four places, not one: core
ToolNames/ToolDisplayNames, the cli i18n dictionaries, and the web-shell
display-name map plus its own zh dictionary. Two drift tests guard this
(cli i18n/index.test.ts and web-shell toolFormatting) and both were red.
* fix(omni): reject unknown omni.memory root keys, stop advising on deleted sources, keep the selector's view of the question
Three independent correctness fixes in the memory slice:
- Config normalization rejected unknown keys at every nested level but
not at the root, so `{recalll: {...}}` silently discarded the entire
configuration and ran default active mode while the operator believed
sideQuery was configured — the exact silent fallback this module's
startup-fatal stance exists to prevent.
- gapsForVersion emitted `artifact_unavailable` for a deleted source and
then CONTINUED the channel scan, adding `not_processed` siblings. The
advisor only filters `artifact_unavailable`, so recall steered the
model into evidence-gathering calls on a file that no longer exists —
the gate resolves the handle and the io assertion then fails. Nothing
can be gathered from a deleted file: report the loss and stop.
- The sideQuery selector dropped any text part starting with
`<system-reminder>`, but IDE context is PREPENDED INTO the user's own
part before the passive pass runs — so in IDE mode the selector never
saw the question and picked relevance-blind. Strip reminder blocks
instead of discarding the part.
* fix(omni): bump clip/keyframe descriptor versions alongside their role annotations
The role labels these tools gained are what memory maps to honest
coverage (`clip` → partial, `keyframe` → sampled); role-less outputs
derive `complete`. The descriptor version feeds the policy fingerprint,
which keys both the degradation cache and the content-identity execution
id — so leaving it at '1' let pre-role cache entries and recorded
executions converge onto the post-change fingerprint two ways: a cache
hit recommits with the persisted `role: undefined`, and the replay
branch returns without upgrading recorded entries.
Either path makes recall report a temporal excerpt or a sampled frame
set as complete coverage: the model then answers about footage it never
saw and the advisor never proposes closing the gap. Bumping the version
partitions pre- and post-annotation state instead.
* fix(omni): prune malformed memory record values instead of blacking out every read
`load()` validated the document envelope but not the record values inside
it. A single non-object value — one hand edit, bad merge, or truncated
sync — made every read path throw (`indexSnapshot` dereferences
`version.parentVersionId`, `findBindingBySha256` reads `version.sha256`),
and those throws are caught into miss/empty by the never-fatal read
wrappers. The result was a PERMANENT global recall blackout for the whole
project, with no `.corrupt-*` backup and nothing named in the debug log,
because the envelope itself stayed valid.
Prune the bad values individually and name them, matching the sibling
`OmniJsonCacheFile.load()` defense. Dangling references to pruned records
already degrade gracefully — a missing version reads as an
`artifact_unavailable` gap.
* fix(omni): give every file its own policy execution and derivative rows
Two Files with identical bytes shared one execution node: the key was
`sha256 ⊕ configHash`, so the second file took the replay branch and got
ZERO records of its own, while the first file's derivative versions were
handed back stamped with the second file's rootFileId. Recall on the
second file then reported false `not_processed` gaps across every channel
and re-paid the work, and the derivative's lineage pointed into a
different root's tree. M §11.2/§11.3 prescribe the opposite: identical
bytes never merge Files, each File writes its own PolicyExecution and
provenance, and only the computation and the stored bytes are reused.
Three layers, cleanly separated:
- execution identity is now per-File (source VERSION ⊕ config), so a
replay is a genuine same-file replay and stays idempotent;
- the content-identity reuse key (`sha256 ⊕ config`) is derived
separately and spans Files — that is what makes cross-file reuse
possible while provenance stays apart. A match records
`reusedExecutionId` (declared since the schema landed, never assigned
until now) pointing at the original;
- derivative File nodes are keyed by (root, object path) instead of the
object path alone, so each root owns cheap metadata rows over the SAME
content-addressed object rather than sharing a node whose root belongs
to whoever derived it first — bounded traversal (M §8) holds for both.
* fix(omni): anchor tool-result media to the object store, not its staging file
Collection recorded the source's persistent identity as the path it was
handed — correct for a user file, whose bytes stay in place (S §4), but
wrong for tool-result media: that path is a staging `.part` the funnel
deletes in its `finally` the same turn, while this same delivery promotes
the identical bytes into the content-addressed object store.
So the disclosed handle resolved to a file that no longer existed: any
media-policy tool the model pointed at it failed with ENOENT, and recall
reported `artifact_unavailable` across every channel for an artifact that
actually persists. Tool-origin sources now record (and bind) the
content-addressed object location, which is derivable from the hash
before promotion runs. If promotion never happens — the transport guard
omitted the media — the ref dangles and recall says
`artifact_unavailable`: honest, because the bytes were not retained.
* feat(omni): commit model and client policy successes through the same memory boundary
Memory design M §7.1 requires fixed calls, model tool calls and direct
client calls to enter ONE `OmniPolicySucceeded` boundary, and §17 names
both capture sites — the core scheduler and ACP's own executor. Only the
fixed-policy orchestrator ever committed: the scheduler built the
`PolicyArtifactBatch` (with the real origin) and nothing consumed it, and
the "model-call artifact bridge" its comment referred to did not exist.
So evidence gathering never accumulated. Recall reported a gap, the
advisor suggested a tool, the model called it with a session handle, the
tool succeeded and wrote real artifacts — and the next recall reported the
identical gap, re-paying the work every session, forever. That is exactly
the loop the resourceId path in this PR was built to close.
The bridge walks the same gates in the same order as the orchestrator:
validate each artifact against the descriptor (contained in the output
directory the call declared), require every declared output, promote to
the content-addressed store, THEN commit (M §6.4 / S §5 — no record may
reference bytes that are not yet in `objects/`). A handle-driven call
recovers its source identity from the session registry instead of
re-hashing the input; a raw-path call falls back to an idempotent
recognition upsert. Fixed-policy batches are skipped: the orchestrator
already owns them.
`resourceId` also joins the fingerprint's excluded keys. Like
`inputPath`, it is per-invocation plumbing — and it is minted fresh every
session, so leaving it in would make each session re-derive identical
work.
* feat(omni): reuse recorded policy outputs instead of re-running the tool
The degradation cache maps one input to ONE media derivative, so its own
comment admits that multi-output tools and text products are "simply not
cached — re-run instead of guessing". That made #8189's «同文件同 settings
二次触发同一 policy:直接复用,无重复执行» unmet in the cases that cost the
most: re-delivering an 81-minute movie re-ran the whole ASR pass every
time, because a transcript is a text product.
Memory already records every output of every execution, keyed by content
identity — so it can answer the reuse question the cache cannot. The
orchestrator now consults it BEFORE the cache and, on a hit, rebuilds the
deliverables from the recorded objects and skips execution entirely,
covering media, text and multi-output alike.
Reuse is verified exactly like a cache hit, because memory.json is
project-local and hand-editable: every object must still be a regular
file, must still hash to its recorded identity, and a media object must
re-recognize as a type the tool DECLARES producing — otherwise a crafted
record could route arbitrary store content through a policy that never
made it. Text is re-read from the promoted object rather than from the
entry's `inlineText`, which is truncated to
`collection.maxInlineTextBytes` and must never be delivered as the whole
transcript. Any doubt falls through to a fresh derivation.
The reusing file still commits its own execution, stamped with
`reusedExecutionId` (M §11.3): the computation is shared, the provenance
is not.
* docs(omni): reconcile the memory design with the S5 issue acceptance wording
Two acceptance criteria in #8188 read as direct contradictions of this
document, and one of them contradicts #8189 as well. Both are resolved by
naming the layer each statement belongs to, rather than by changing the
model.
- §11.2.1 (new): «内容相同的两个文件不重复建节点» and «不合并不同文件»
live on different layers. Content-keyed reuse covers the physical object
and the derived computation; the File node stays locator-keyed because
identity MUST be stable across content changes — key it on content and
§11.1's version chain and #8189's «修改内容后旧衍生物不默认召回» both
become inexpressible (an edit yields an unrelated node, and editing back
makes history a cycle). Also records why derivatives are keyed by
(root, object path).
- §9.2.1 (new): «按路径/内容 hash 查询» and «返回中永不暴露真实本地路径»
are reconciled per caller — the model gets session handles only
(unforgeable capability; a path parameter would let injected media steer
it into probing other files, whose transcripts recall returns), while
path/hash lookup stays a harness capability that already exists. The
known cost is written down too: a deleted file's memory is currently
unreachable, and the fix belongs in the authorization layer
(@-reference re-anchoring), not in widening what the model may pass.
The issue bodies were updated to match.
* fix(omni): reuse text products too, and fail on unknown omni settings sections
Both found by running the real pipeline rather than the unit suites.
Text-product reuse never fired. `findReusableOutputs` only resolves an
object path for outputs that have a derived VERSION node, which text
products do not have — so `rebuildReusedOutputs` bailed and the tool
re-ran. The comment there even said the caller reconstructs the path from
the content hash; that half was never written. A real run made it obvious:
extract-audio and keyframes logged reuse hits while the transcript re-ran
the entire ASR pass — the single most expensive thing #8189 exists to
avoid, and the headline claim of the reuse commit. The object store is
content-addressed, so the path is derivable; text outputs now reuse like
media. The existing test passed because it used a media artifact, so a
transcript-shaped case was added alongside it.
Unknown sections under `omni` were silently ignored. The settings loader
scans TOP-LEVEL keys only, and only to a debug line, so a nested typo was
caught by nothing: `omni.memoryy.recall.mode = sideQuery` started
cleanly and registered the active-mode recall tool — the operator's whole
configuration discarded without a word. The same hole swallowed
`omni.processingg`, which would silently disable every degradation
policy. `omni`'s own normalizers are startup-fatal by design, so the
namespace now rejects unknown sections with the allowed set named. The
permitted keys are derived from the settings schema, not hardcoded, so
they cannot drift.
* fix(omni): stop policy artifacts colliding, guard duration, record real execution windows
Three defects a real multi-session run surfaced. The first destroyed work.
**Fixed output filenames collided.** Every policy tool wrote a constant
name (`clip.mp4`, `downsampled.jpg`, `transcript.txt`). Under
fixed-policy orchestration that is safe — each invocation gets its own
staging directory — but `modelAccess` lets a caller pick a PERSISTENT
`outputDir`, and there the second call silently destroyed the first
artifact. In the run: a clip cut on day one was overwritten by a different
clip on day three, and the commentary written against the first clip began
describing the wrong footage. Worse, the model noticed something was off,
could not diagnose it, and skipped re-watching — so the defect surfaced as
degraded output quality rather than an error.
Names now carry the axes that distinguish artifacts: the source stem, plus
a natural variant where the operation has one (a clip's span, a frame's
index). Same source and variant still resolve to one name, so re-running
an operation replaces its own output — idempotent, not destructive. The
keyframe lister derives its matcher from the same template, so it can
never pick up a sibling video's frames out of a shared directory.
**The transport guard had no duration dimension.** A 98-minute film
downscaled to 474 MB cleared the 1 GiB byte ceiling, uploaded, and was
then refused by the provider for being too long: the guard reported
success, the caller got an opaque 400, and a 97-second transcode was paid
for nothing. Duration is a transport limit like the others, so
`maxDurationSeconds` now sits beside the byte and token checks with the
same semantics (unset disables it; missing metadata never rejects) and a
message that names duration and says downscaling cannot fix it.
**Model-origin executions recorded the collection window, not the work.**
`startedAt` was taken after the tool had already run, so extracting audio
from a 98-minute film was recorded as 0.6s. The scheduler already tracks
`executionStartTime` (approval wait excluded); it is now threaded through.
* test(omni): keep '+' in generated artifact stems
A derived artifact of a clip re-uses the clip's filename as its source
stem, and the clip's own variant contains '+' (`123s+40s`). Stripping it
made the derived stem drift from its parent (`clip-123s-40s-keyframe-…`).
'+' is portable on every major filesystem, so keep it: names now round-trip
through a derivation chain.
* feat(omni): re-anchor remembered media, and say when recall was truncated
Two friction points the character-analysis run surfaced.
**A remembered file's memory was unreachable once its bytes were gone.** A
handle is minted only at delivery, and delivery needs the bytes — so a
transcript or keyframe set outlived the file it described with no way back
into a session. The same wall hits an audit that does not want to
re-deliver a 2.4 GB film just to ask what work was recorded: with no
handle, recall rejects everything, and the natural next move (passing the
filename as a resourceId) is correctly refused. Observed exactly that: the
model reached for `resourceIds: ["robot-dreams.mkv"]` and got
`not issued in this session`.
An `@`-reference to a missing-but-remembered path now mints a handle from
the recorded identity — the user's reference is the same authorization a
delivery carries, so the fix belongs at the authorization layer rather
than by widening what the model may pass (design M §9.2.1). The model gets
the handle plus an explicit 【媒体缺失】 note, so it recalls instead of
trying to read. Path/locator lookup stays a harness capability.
**A truncated recall page was indistinguishable from an exhaustive one.**
An audit read 6 clips under `limit: 12` and reported "no keyframes were
ever extracted" while the store held 72 of them. The reader was honest
about what it saw — it simply had no way to know it was looking at a page.
`matchedEntries` now states the full match count, and only when the budget
actually cut something, so an exhaustive page stays as small as before
(§9.4 minimal return).
* feat(omni): tell the model what a media handle is and to recall before reprocessing
The handle annotation ships with every memory-known delivery, but nothing
in the model's context explained it: the media-guidance section listed only
the three disclosure markers, and the tool that consumes handles is
deferred, so its description — which does explain them — is not in context
until ToolSearch surfaces it. Meanwhile the same section actively tells the
model to gather missing evidence with the policy tools, i.e. to reprocess
from scratch exactly what memory already holds. The active-recall surface's
whole purpose could silently never fire.
The section now names the handle marker, states that it is the only
identity the model will ever get for that file, and says to consult recall
BEFORE reprocessing. Gated on active recall mode: under sideQuery the
harness injects recalled memory itself and the tool is not registered, so
naming it would invite a guaranteed unknown-tool error (D10).
The tool-display-name drift test now runs over every translating locale
rather than zh alone. `t()` has no cross-locale fallback, so a tool added
to one locale renders a raw English badge beside translated siblings in the
other.
* fix(omni): close the honesty and hygiene gaps found reviewing S5 memory
Fourteen findings from the PR review, verified one at a time. The ones
that could mislead a caller:
- a truncated recall entry now says so. maxTextChars (24k chars) is
smaller than the collection bound (64 KiB), so any long transcript is
cut at READ time while coverage still legitimately reports `complete`
— the model saw a prefix, no gap, and answered about audio it never
read. `contentTruncated` marks the prefix.
- a resourceId handle whose modality the tool does not accept is refused
as a parameter error instead of becoming a spawned ffmpeg that burns
the tool timeout and returns an opaque stderr tail.
- the advisor no longer suggests audio extraction against a video's
speech_text gap: that gap is still open in the very payload that
RETURNS the extracted track, so the suggestion repeated itself. Nor
does it suggest anything against `partial_coverage` — sampled evidence
stays sampled by design, so that advice could never close the gap.
- a derived version keeps the execution that actually produced it. A
second execution landing on byte-identical output was rewriting the
pointer, leaving the version naming an execution whose outputs it is
not.
- `maxAttempts` governs the client's retry loop only; a selection that
parses but names entries outside the manifest is refused without a
retry. The comment and the setting description said otherwise.
- `sideQuery.model: null` means the side-query default (the configured
fast model, falling back to the session model), not "the session's
active model" as three places claimed.
And the hygiene:
- duplicate resourceIds and repeated selector picks collapse instead of
spending the budget twice
- an empty materialization records `materialized_nothing` rather than
injecting a reminder that says nothing
- `candidateSummaries` lets a defect propagate (the caller already
degrades with a recorded reason) instead of swallowing the stack
- `kinds`/`roles` reject `[]`, which read as "restrict to nothing"
- count and millisecond settings are `integer` in the generated schema
- dropped three never-written fields; corrected the `managedId` doc,
which described an extension the key does not carry
* fix(omni): disclose the session handle for URL-delivered media too
The @-command/URL funnel records the file in memory and mints a registry
binding, then never tells the model the handle. Active recall rejects the
handle as never-issued, and the passive selector finds no handles to
consult — so a session could pay for a download, an upload, and a policy
run, and be structurally unable to recall any of it.
The withheld case is where it matters most: the model cannot see the
media, so the handle is its only remaining route to the content.
* docs(omni): record the two recall fields that keep a page honest
`contentTruncated` and `matchedEntries` both exist because a reader that
cannot tell a prefix from the whole, or a page from the set, draws
confident wrong conclusions — and both were shipped in code without the
design doc naming them.
* test(omni): pin the selector gate and the handle disclosure
Mutation probes showed both were free to break silently:
- inverting the selector's manifest-membership check kept the whole suite
green while, with the default single attempt, every passive recall
degrades to `selector_failed` — the feature dead in production. The
mock now forwards responses through `validate` the way production does,
so the happy path exercises the gate too; three tests fail on the
inversion.
- dropping `...handleParts` at any of the five replacement sites left
tool-produced or withheld media with no recall handle, and no test
cared. Each site now fails exactly one case.
Also pinned: the 4000-char selector request cap, the abort/timeout signal
composition (a Ctrl-C in the selector window must cancel it, not leave
the main request waiting out the full timeout), promptId attribution, and
the basename-only input error — whose leak-prevention was satisfied by
the old full-path message too. The manifest no-path assertion moved out
of the mock: a leak used to fail as "result was null" instead of naming
the path.
* test(omni): pin the memory invariants that mutation probes walked through
Sixteen behaviors the design doc states and the code implements, none of
which any test defended. Each was verified by breaking the source, seeing
the new test fail, and restoring it. The ones that matter most:
- gaps must speak for the CURRENT version's processing state, always, and
must be computed from the FULL subgraph. Two mutants — looping gaps over
every consulted version, and threading the request's kinds/roles filter
into the coverage scan — would have made recall report missing work that
exists, purely because the caller narrowed what it asked to see. The
existing history test could not catch either; the new fixtures invert
its direction (unprocessed history, processed current) and kill both.
- the selector-visible manifest: the 200-char description cap was never
binding (longest fixture was 184 chars), so removing it would have sent
full transcripts to the selector; and validation against the CAPPED
manifest let an entry past maxCandidateEntries be materialized though
the selector was never shown it.
- registry rebinding now pins first-binding-wins explicitly. Updating in
place would repoint a handle the model already holds; minting a second
handle for one version would break cross-call correlation.
- the store's rename-based write and newest-wins backup retention, pinned
black-box (inode replacement, no orphan .tmp) rather than by mocking.
- multi-output executions: every call site in the suite committed exactly
one output, so truncating the output loop was invisible.
- the config normalizer's real contract: it throws rather than clamps, its
bounds are non-strict, and it validates the MERGED values — lowering
only maxEntries to 6 is startup-fatal against the default 12.
* fix(omni): keep a withheld resource reachable, and stop losing why recall degraded
Three findings that each end the same way — the session holds a memory it
cannot get back to, and nothing says so:
- A transport-guard rejection threw away the handle it had already minted.
Every throw site sits downstream of the bind, and the omission branch
with the identical "over-limit, withheld" verdict does disclose its
handle — so the rejection was the one path that stranded a recorded
resource: the model can neither see the bytes nor ask memory about them.
The verdict now carries the handle, and the tool-result funnel emits it.
- Passive recall degrades silently by design (the turn proceeds without the
memory it was meant to carry). With a pinned-but-unavailable selector
model that is a permanent outage with nothing to see, so the reason is
now recorded at the injection seam, and the manifest-failure branch —
which had no log at all — gets one.
- Chinese queries never ranked. Splitting on separators yielded one token
per phrase, scored by whole-substring containment: unless an entry
repeated the caller's exact phrasing, everything scored zero and ordering
collapsed to newest-first. Unsegmented scripts now index as overlapping
character bigrams, split at script boundaries so `480p字幕` does not
produce bigrams that straddle them.
Also: `PolicySucceededCommit.created` had no consumer outside tests. It
distinguishes a content-identity replay from a fresh record, which is
exactly what a reader of the debug log cannot otherwise tell — "no new
execution appeared" reads identically to "collection did nothing".
* test(omni): cover the four seams where memory reaches the pipeline
Each was free to disappear without a red test:
- the orchestrator's post-promotion commit, its degradation-cache-hit
commit, and the memoryBinding threaded onto each WorkItem. Disabling the
commit, or always threading the source binding instead of the derived
one, left the suite green while every fixed-policy execution recorded
wrong or no lineage. Covered with a real MediaMemoryService on a tmp
store, reading memory.json back — including a two-stage chain, where
stage two must be EXECUTED_ON stage one's version, not the root.
- the reactive-degradation ladder's memory wiring, which had no coverage
at all beyond its pure helpers. One test pins that a rung's re-derivation
lands on the binding found by content hash; the other that an unknown
hash degrades normally and records NO execution, rather than inventing
provenance on someone else's lineage.
- the client injection seam: moving it after the systemReminders splice —
the exact regression that kills passive recall — now fails.
- the D10 exposure gate at registry-construction level: the recall tool
appears in active mode, is absent in sideQuery mode, and an invalid
omni.memory.recall.mode aborts instead of falling back to defaults.
* fix(omni): keep S5 off the root barrel, which was dragging 550 KB into ACP
`Check serve fast-path bundle closure` went red: the ACP agent's static
import closure had acquired iconv-lite's encoding tables (~553 KB).
The mechanism is worth writing down, because the obvious readings are all
wrong. Nothing imported iconv-lite. What S5 did was add five re-exports to
the ROOT barrel, which is statically imported by ~150 modules in the ACP
closure. esbuild assigns modules to chunks by which entry points reach
them, so every module added to the barrel's graph regroups the split — and
that regrouping moved `sync-file-encoding` (and through it `iconvHelper`,
whose top-level `import 'iconv-lite'` has side effects and cannot be shaken
out) into a chunk the ACP chunk statically imports.
So the fix is not "move the export somewhere else": my first two attempts
relocated it to the `omni` barrel, which is itself statically imported by
the root barrel and by tool-result-media, and stayed red. The barrel
surface itself has to come back to baseline.
None of the five needed to be there. Four had no consumer outside their own
module and tests; the rest are reached by dynamic import, which does not
care where the export lives:
- ACP's model-call collection gets a dedicated `./omniPolicyCollection`
subpath, the pattern `./goalWire` and `./transcriptRecords` already
establish for "the CLI needs one deep function, not a barrel".
- `reanchorRememberedMedia` moves to the `omni` door the `@`-reference
funnel already loads for every other omni call it makes.
- `createMediaMemoryRecallService`, `buildMediaMemoryRecallAdvisor`,
`extractRequestResourceIds`, `runOmniMemorySideQuery`,
`formatOmniMemorySideQueryReminder`, `OmniRecallMediaMemoryTool` and
`formatResourceHandleText` simply leave the root barrel.
Located by bisecting the 28 commits with the check itself, then diffing the
ACP closure module sets between a green and a red metafile: the delta was
exactly three modules, which is what named the mechanism.
* fix(omni): anchor URL media off its staging file, and record ACP calls as model calls
Two findings from the re-review, both real:
- The URL funnel hands its staging download to the delivery pipeline with
no lifetime marker, so memory recorded the `.part` path as the file's
persistent identity — the same defect C9 fixed for tool-result media,
missed on this third funnel. The handle already shown to the model
resolves to ENOENT the moment the funnel's `finally` runs: every later
policy call on it fails, and cross-session recall reports
`artifact_unavailable` for bytes the object store still holds. URL media
now anchors to the content-addressed object path, and the version's
source records the URL itself (`protocol: 'url'` existed in the schema,
unused until now) — the durable identity of downloaded media is where it
came from, not where it briefly landed.
- ACP committed its policy successes with `executionOrigin: 'client'`
while the modelAccess gate forty screens up pins the very same call as
`{ kind: 'model' }` ("Every ACP-originated call is a model call").
Recall provenance contradicted the gate that admitted the call.
The URL-anchor test is mutation-verified: reverting the lifetime marker
fails it.
* docs(omni): reconcile storage lifecycle with the shipped recovery, stake out GC
Section 6.1 described the recovery scan as drafted; the implementation
learned things the draft could not know, and the doc should carry them:
"delete all of staging/" became a grace window because a second CLI
process's recovery must not delete a live invocation's transcode out from
under it (config validation caps tool timeouts below the window for the
same reason), and corrupt-object deletion cascades into the degradation
cache so a cache hit can never point at bytes that are gone.
Section 6.2 is S6's landing point, sharpened by what S5 settled: the GC
root set has TWO sources in the memory snapshot (artifactRef.managedId
AND managed-protocol source locators — tool/URL media anchor their
identity there), the snapshot read is fail-closed (no roots readable, no
deletion at all), and object deletion reuses the recovery scan's existing
cache-cascade rather than growing a second one.
* test(omni): stop asserting an exact timeout the wall clock can undercut
The bucket loop's budget guard calls remainingTimeoutMs() right before
the ffmpeg call reads it again; on a slow runner a millisecond elapses
between the two reads and the exact-equality assertions on the first
call's timeoutMs turn flaky (observed on ubuntu CI: 599999 != 600000).
Freeze Date.now in the full-duration spread test (ffmpeg is mocked,
nothing there needs real time) and tolerate the guard-to-use drift in
the shared-budget test, which does need the real clock for its 50ms
burn. Restore spies in afterEach so the frozen clock never leaks into
neighbouring tests.
* feat(omni): S6 governance — object-store GC with byte budget, JSONL trajectory export (#9081)
* feat(omni): mark-and-sweep GC over the object store, with a byte budget
The store only ever grew: recovery cleans staging, downloads and
quarantine, but a committed object had no way out — one film-commentary
session leaves 38 objects behind, forever. S6 (storage design §6.2).
Two hard rules shape the implementation:
- An unreadable ledger deletes nothing. The root set is the memory
snapshot; if it cannot be read there is no telling "unreferenced" from
"unknown", so the run skips. This interacts with the store's own
corruption recovery (C8) in a way the first test caught: a corrupt
memory.json is backed up and REPLACED WITH AN EMPTY LEDGER — right for
recall, catastrophic for a GC that trusts it, because empty reads as
"nothing is referenced" and one corruption event would erase the whole
store. A fresh .corrupt-* backup therefore blocks the run until it ages
past the retention window.
- A referenced object is never deleted, even over budget. Roots come from
BOTH reference kinds in the snapshot — entries[].artifactRef.managedId
and managed-protocol source locators (tool/URL media anchor their file
identity in the store; missing these would delete exactly the objects
whose store copy is the only copy) — plus the live session registry,
which may hold a binding whose memory commit has not landed yet. Budget
pressure with only referenced objects left suspends NEW derivations
(policy design §8.4) instead: the orchestrator refuses to execute a
policy tool, while reuse and cache hits — which produce no new bytes —
stay allowed.
Sweep: unreferenced and older than omni.storage.retentionDays (14d
default); over omni.storage.maxTotalBytes (20 GiB default), oldest
unreferenced first regardless of age. Deletion cascades into the upload
cache and the degradation cache, same invariant recovery's corrupt-object
path enforces. Symlinked shards are never descended into. Runs once per
store root per process, asynchronously after startup recovery.
* feat(omni): export a session's trajectory as training-consumable JSONL
S6's second deliverable (issue #8190, absorbing #8196): the experiment's
whole point is trajectories — what media came in, what the pipeline did
to it, what the model actually saw, what it answered — and until now they
lived split across the chat transcript and memory.json in shapes no
training script can consume.
The exporter is a pure reader over those two artifacts. No collection
points are added: it cannot affect runtime behavior and works on sessions
recorded before it existed. Three record kinds per line — `turn` (request
text with reminders stripped and any injected passive recall extracted,
the media annotations the model saw, the response and its tool calls),
`execution` (full provenance: arguments, config hash, reuse marker), and
`file` (the durable identity: fileVersionId, sha256, origin, source).
The join is explicit rather than fabricated: model/client executions join
turns on callId ↔ invocationId (both sides record the scheduler's id);
fixed-policy executions join through file identities. No turn↔execution
edge is invented from timestamps — a wrong edge in training data is worse
than an explicit join key.
Privacy carries over from recall: reserved runtime keys (inputPath /
outputDir) are stripped from exported tool args, media bytes are never
embedded (sha256 is the pointer, valid while GC retention lasts), and
every execution line carries omniConfigHash so downstream can split
trajectories by processing configuration instead of mixing distributions.
Deterministic ordering makes re-exports byte-identical. An unreadable
memory store degrades to transcript-only output; an unreadable transcript
is an error — there is no trajectory without it. Wrapper script at
scripts/export-omni-trajectory.js; the function is exported through the
`omni` door (never the root barrel — see e60efd81fc for why).
* docs(omni): draw the exporter's scrub boundary where the model's sight ends
* fix(omni): settle the startup GC before the budget gate reads the flag
The startup GC is fire-and-forget so delivery never blocks on a sweep,
but the orchestrator's budget gate consulted isOmniDerivationSuspended
immediately - the first derivation of every fresh process raced past
the budget before the sweep could raise the suspension flag (observed
in a real-API E2E run: a new extract-audio derivation landed ~900ms
before the over-budget verdict, leaking two objects).
Add settleOmniGc(root), which awaits the process's in-flight GC for a
store root if one was started (no-op otherwise, never rejects), and
await it in the orchestrator right before the gate. Covered by a new
gc test that fires the GC without awaiting, exactly like the index.ts
wiring.
* fix(omni): close the GC's stale-root races and root URL-origin objects
Review round 1 (R1-2, R1-15, R1-16) plus an external audit found three
ways the sweep could delete bytes the ledger still vouches for:
- URL-origin media were never in the root set: their versions keep the
original URL as source.locator, but the staging download dies the
turn it lands and the store copy (the file's fileRef) is the only
persistent bytes. Root every version of a store-anchored file by its
content hash.
- The root snapshot and object listing go stale while the sweep runs,
and a concurrent commit can reference OLD bytes in that gap — the
retention-window argument only covers newly-promoted bytes. Three
layers now close it: putFile's dedup hit refreshes the object's
mtime (every reference is preceded by a promotion of the same bytes,
so the touch always lands before the commit), both passes re-stat a
candidate at delete time, and the budget pass — where age protects
nothing — re-reads the ledger for a fresh root set and skips objects
touched since the sweep began. The residual window shrinks from the
whole sweep duration to the moment between re-stat and rm.
- The suspension messages told the operator to raise the budget or
delete records, but neither can take effect inside the session that
shows them (the GC runs once per process) — say so.
The gc.ts header's multi-process claim now describes the layered story
instead of overstating the retention argument.
* fix(omni): make the trajectory export faithful to what the model saw
Review round 1 (R1-1, R1-3..R1-9, R1-17..R1-19) plus an external audit
found the exporter unfaithful in both directions — content the model
never saw leaked in, content it did see was lost or mangled:
- Dead branches exported as genuine turns: records carry uuid/parentUuid
and /rewind re-roots the chain, so the exporter now replays only the
active chain (uuid-less fixtures fall back to append order).
- Thought parts merged into response.text: internal chain-of-thought is
now excluded — it is not the response the user saw.
- "Single-session" export was project-wide: memory records are filtered
to the closure reachable from THIS transcript (callId joins + media
annotation names + the identity graph between them), so a text-only
session exports zero unrelated history.
- The recall extraction targeted a transcript shape no writer produces:
the sideQuery injection now records its payload as a system record
(subtype omni_recall) and the exporter reads that — older transcripts
simply have no recall field instead of a silent lie.
- Annotation parsing was line-based and first-colon-split: multi-line
transcripts truncated, colon-bearing display names mis-split and
cross-contaminated, look-alike prose was harvested, reminder-quoted
grammar was consumed before stripping. Writers now escape the
separator in display names, the exporter matches whole parts (writers
emit one annotation per part), payload newlines survive, and reminder
stripping runs first. tool_result records are harvested too — the
dominant agentic media path was invisible.
- The inputPath/outputDir strip on transcript tool calls was vacuous
for its stated purpose (the gate injects those AFTER the assistant
turn is recorded) and destructive in practice (a model-authored
inputPath is a legal call the gate accepts) — arguments are now
preserved verbatim, and the false equivalence comment is gone.
- Exporting was not read-only: a corrupt memory.json went through the
store's self-heal, renaming the live ledger and silently blocking the
GC for a whole retention window. The exporter now reads the file raw
and degrades to transcript-only.
- Writing the trajectory over the source transcript destroyed it with
exit 0 — identical resolved paths are rejected, and the out dir is
created when missing.
The test suite grew from 4 to 14 cases and now pins response.text, the
lineage fields, the fixed-policy join, multi-turn isolation, the
subtype turn-boundary guard, and the memory-degrades/transcript-rejects
asymmetry.
* fix(omni): validate the GC knobs where a typo would silently disable them
Review round 1 (R1-11, R1-13, R1-14) plus an external audit, on the
configuration surface and the test gaps around the sweep:
- omni.storage.maxTotalBytes accepted any positive value; the storage
design requires at least 10× the transport guard's single-media
ceiling (a budget that cannot hold a handful of normal uploads reads
as permanent derivation suspension, not configuration). The GC wiring
now clamps through effectiveOmniStorageMaxTotalBytes with a warn.
- retentionDays accepted sub-day values while the schema promises
minimum 1 — the accessor now enforces what the schema says.
- Unknown keys nested under omni (omni.storage.retentionDay, …) were
silently ignored, leaving the GC on defaults; the settings validator
now walks nested object schemas, stopping at free-form map nodes
(fixedPolicies/policyTools names are user-defined).
GC test gaps closed: the suspension-release assertion now proves the
relaxed sweep clears a STANDING flag (reset keeps it by request); the
corrupt-ledger test is named for the recovery-backup guard it actually
exercises and a new test pins the refs===null hard rule; entry
artifactRefs are rooted independently of version locators; a registry
fileRef outside the store shields nothing; the cache cascade asserts
its rm-first ordering; a failed rm keeps its bytes in the budget
arithmetic; and two roots in one process get independent latches and
suspension verdicts.
* test(omni): give the sweep-touch fixture a margin over coarse mtime clocks
The touched-during-sweep test stamped the second candidate with "now";
on filesystems that truncate mtime to whole seconds (the ubuntu CI
runner) the stored time floored below the sweep start and the object
was deleted anyway. Stamp +10s instead — what the test pins is "touched
AFTER the sweep began", and a ten-second margin says that regardless of
timestamp granularity. Production is unaffected: the budget pass's
primary protection is the fresh ledger re-read; the mtime check is the
belt to that suspender.
* test(omni): stub the ffprobe subprocess out of model-call collection
Twice now an overloaded CI runner killed the REAL ffprobe this suite
spawns (its 15s subprocess timer), the failure was swallowed by design
(D12: collection never affects the tool call), and the tests read "0
executions committed" — a false red that blocks unrelated PRs. The
content sniff stays real (in-process magic bytes); only the probe
subprocess is stubbed. Nothing this suite asserts depends on ffprobe
output — the subprocess only added a machine-load dependency.
* feat(omni): separate upload and inference channels (#9569)
* feat(omni): policy-tool expansion, look-closer pipeline fixes and fixedPolicy preset (#10351)
* feat(omni): S7 policy-tool expansion — 6 new tools, memory.* conditions, token-budget resize
Design-doc gaps ❶–❻ (omni-harness-gap-analysis, priorities 1–5):
- omni_clip_image: pixel-rectangle crop (sharp, EXIF baked first), the
policy form complementing model-facing zoom_image
- omni_caption_image / omni_ocr_image: VL caption and OCR text products
(roles caption/ocr, both pre-reserved in the memory role enum); ocr
unlocks the §4.5 document-image fixedPolicy
- omni_clip_audio: startMs/durationMs (≥1000ms) audio cut, symmetric
with clip_video
- omni_caption_audio: omni semantic audio description (timbre, events,
mood) reusing the transcribe chunk+concurrency skeleton
- omni_understand_video_segments: fixed-length segments (default 30s,
≤10MB each) → parallel omni captions (cap 8) → time-labeled summary
- shared omni-chat-request.ts for the four model-call tools (SSE
assembly, HTTP-status-only errors, baseUrl/apiKeyEnv operator-only)
Gap ❼ — memory.* condition namespace (unlocks configs 4.1/4.4):
hasTranscript/hasOcr/hasCaption/hasSummary/hasKeyframes/hasClip flags
collected over the item's DERIVED_FROM subgraph (root-bounded), wired
into the orchestrator's per-pass condition snapshot; absent namespace
evaluates unavailable, never silently false; lazy store read only when
a policy's `when` references memory.*.
Gaps ❿⓫ — billing-token levers (ported from the plugins):
- omni/smart-resize.ts: 28px patch-grid smart_resize + token budget
tiers (image 256/1024/2048, video-frame 80/256/1024)
- omni_downsample_image tokenBudget param (exact grid-snapped dims,
EXIF-aware, small-image upsampling)
- omni_extract_keyframes strategy:'uniform' — duration-adaptive dynamic
fps (clamp(window×fps, 1, maxFrames)) with parallel per-timestamp
seek extraction, startSec/endSec windowing, frameTokenBudget sizing
Tests: 45 files / 1017 tests pass across src/omni + media-memory
(per-tool suites, smart-resize math, memory subgraph role collection,
orchestrator end-to-end memory gating); eslint clean; tsc no new errors.
Docs: design doc 2026-08-18-omni-policy-tools-expansion.md; user doc
tool table + memory.* field table; E2E plan in .qwen/e2e-tests/.
Co-Authored-By: Claude <[email protected]>
* docs(omni): fixedPolicy preset covering design-doc 4.1–4.9 with priority ordering
Ships docs/users/features/omni-fixed-policies-preset.json — a copy-ready
settings template implementing all nine fixedPolicy configurations from
the design doc (long-video ASR chain, large-image downsample, tight-
context keyframes, long-audio transcribe, document-image OCR, large-
video downscale, and the three >10MB oversize fallbacks), ordered by
priority (chain starters → chain followers → independent degradations →
oversize fallbacks) with the ASR chain's timeout/budget companions.
A vitest guard (preset-validation.test.ts) normalizes the preset against
the real tool registry so the shipped template cannot drift from the
tools and DSL it references. User doc gains a pointer to the preset.
Co-Authored-By: Claude <[email protected]>
* fix(omni): qwen omni models resolve to full multimodal; preset opens modelAccess
Two gaps surfaced by real harness runs with qwen3.5-omni-plus:
1. modalityDefaults had no omni entry — 'qwen3.5-omni-plus' fell through
to the generic /^qwen/ text-only pattern, so @-referenced video/audio
was skipped at the delivery gate ('the active model does not accept
video input') even though the omni pipeline itself was healthy. Adds
qwen*-omni patterns as FULL_MULTIMODAL before the plus/qwen fallbacks.
2. The fixedPolicy preset kept every policy tool model-invisible, so the
model could not progressively re-derive higher-fidelity evidence
(clip/transcribe/keyframes/OCR) from delivered media. The preset's
policyTools now sets modelAccess.enabled for the evidence-gathering
tools; pure degradation tools stay operator-only. Preset validation
test registers the newly referenced tools.
Co-Authored-By: Claude <[email protected]>
* feat(omni): look-closer pipeline fixes + policy-tool updates
Clip / keyframe budget & token-cost fixes measured on Video-MME-v2:
- clip-video: encode video-only (-an) so a clip read never re-runs the
extract-audio -> transcribe chain (the full transcript already covers
the span); append a soft clip-budget brake past 3 clips steering the
model to densify-locate instead of unbounded trial clipping.
- image-budget: cap assembled requests at 250 image parts, evicting the
oldest keyframes to a text placeholder, so a long look-closer
trajectory never trips DashScope's hard 256-image 400.
- extract-keyframes / media-guidance: densify-before-clip guidance.
- usage-log policy tool; disclosure / media-guidance / read-file wording
tightened; fixed-policy config / types updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
* fix(omni): harden policy tool contracts (#10364)
* fix(omni): harden policy tool contracts
* fix(omni): qualify clip audio disclosure
* fix(i18n): localize omni policy tools
* feat(omni): reference model-visible local media by absolute path instead of resourceId (#10690)
* feat(omni): reference model-visible local media by absolute path, not resourceId
When media is delivered from a model-visible local source (user-origin
read_file, whose registry binding fileRef equals the path the model read),
the 【媒体资源】annotation now shows that absolute path instead of an opaque
media-<n>-<hex> handle: the model already holds the path and can re-read it
or point tools at it, so the handle was redundant noise. Path-less sources
(tool/URL/recall media, whose fileRef is an internal object-store locator)
keep the handle — no usable path exists to show.
Passive media-memory recall is preserved: extractRequestResourceIds now
recovers the session handle from a path-form annotation via
registry.resolveByFileRef, so recall keys on both annotation forms.
- disclosure.ts: add formatResourcePathText / parseResourcePathText
- index.ts: choose path-form vs handle-form by binding.fileRef === filePath
- memory-side-query.ts: resolve path-form annotations back to handles
* docs(omni): note outputDir must exist and is not auto-created in the tool schema
The media-policy tools assert the output directory exists (assertMediaPolicyIo)
rather than creating it; state that in the shared outputDir parameter
description so callers pass a directory that already exists.
* fix(omni): migrate every 【媒体资源】 consumer to the path form
PR #10690 replaced the opaque handle with the absolute path for
model-visible local media in EVERY recall mode, but only the passive
sideQuery selector understood the new form. Review flagged three
regressions plus follow-ups; migrate the remaining consumers so the
handle and path forms are interchangeable everywhere.
- recall (active tool): resolveBindings resolves an identifier as a
handle, then falls back to registry.resolveByFileRef, so the displayed
path recalls exactly as the handle would; an identifier that is neither
still rejects the whole request. Dedup is per resolved binding, so a
handle and the path of one resource collapse to a single gap. Recall
tool + active-mode guidance now describe both forms.
- trajectory export: consumeAnnotationPart learns the path form, keying
the media entry by the path basename (the local source locator) so the
turn keeps its media and the session filter seeds the file's closure.
- per-version identity: resolveByFileRef returns the LATEST binding at a
locator (the version the model currently sees), not the first.
- escape-aware handle parse: parseResourceHandleText splits at the first
UNESCAPED separator and only accepts an exact media-<n>-<hex> payload,
so a path ending in a handle-shaped suffix is not misread as a handle.
- whitespace filenames: passive extraction prefers the untrimmed line and
probes both candidates against the registry, so a filename ending in
whitespace still resolves.
- refresh the stale "handle is the only identity / never a path"
invariant comments; pin the outputDir schema wording and its
reject-and-never-create behavior with tests.
All omni + media-memory suites pass (1081 tests); typecheck clean on the
changed files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(omni): close the round-2 review gaps on path-form media references
Follow-up to the path-form annotation change, addressing the second
review round on #10690 (two fix-induced Criticals + suggestions).
Criticals
- recall/model-access accepted the ESCAPED path the annotation displays
but compared it byte-exact against the unescaped fileRef, so every
native Windows path (and any :-bearing name) failed to recall and
poisoned any handles batched with it. Add resolveMediaReference
(registry.ts): resolve as handle, then as fileRef with the displayed
form unescaped. Shared by active recall and the media-policy call gate.
- resolveByFileRef claimed latest-wins recency, but bind() returned an
existing version without re-inserting it, so a revert to previously-seen
bytes resolved to the superseded version and persisted a wrong reuseKey.
bind() now refreshes a re-delivered version's position (delivery order).
Suggestions
- Exporter: guard parseResourcePathText on an absolute-path shape so it no
longer consumes ordinary 【媒体资源】-prefixed prose; stop the full trim
from truncating a trailing-whitespace filename (dropped the file's
closure from the export).
- sideQuery selector: strip 【媒体资源】 lines from the selector payload so
a local absolute path never reaches the selector model call (§17.5).
- Guidance/contracts: emit the path/handle reference guidance in BOTH
recall modes (sideQuery no longer left with stale schema text); teach the
media-policy gate to accept a displayed path via resourceId; raise the
recall resourceIds maxLength so a long path is not rejected pre-resolution;
add the "must already exist" outputDir contract to the clip-video override;
correct the misleading "not issued this session" rejection message; update
the stale handle-is-the-only-identity invariant comments.
Tests: registry revert + resolveMediaReference (POSIX/Windows-escaped);
recall path-form gaps/advice dedup; disclosure path-shape guard; exporter
phantom-prose + trailing-whitespace; side-query selector path-strip,
two-version latest-wins, trimmed-fallback; index round-trip consumability;
model-access path-via-resourceId; clip-video outputDir wording.
1095 omni+media-memory tests pass; typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(omni): close the round-3 review gaps on the media path form
Round-3 review traced four findings (R1-1, R3-2, R2-6, R3-10) to one
root cause: the path form shared the 【媒体资源】 marker and ran through
escapeAnnotationName, so the string the model saw was not the path on
disk. Split it — the path form now rides its own 【媒体路径】 marker with
the absolute path VERBATIM (never escaped); the handle form keeps
【媒体资源】 + escaping for its displayName.
- R1-1: resolveMediaReference drops the unescape-before-exact branch
that resolved `/tmp/proj/a\b` to `/tmp/proj/ab`'s binding; exact
lookup only (recall.ts, model-access.ts inherit the fix).
- R3-2: verbatim output makes index.test.ts `toContain(filePath)` hold
on every OS (escapeAnnotationName doubled `\` under windows-2022).
- R2-6: the displayed path IS the on-disk path, so the guidance's
re-readable promise holds and the policy gate's lstat passes.
- R3-10: a `:`-bearing filename resolves verbatim; the unescape rule
(and its zero coverage) is gone.
- R3-4: the writer tags its path Parts `omniHarnessAnnotation`; the
exporter consumes a path-shaped part only when that provenance
survives the transcript (the OpenAI converter picks known fields, so
the tag never reaches the model API). A byte-identical user paste
stays prose.
- R3-1: the exporter keys the media entry by a cross-OS basename split,
not host `path.basename`.
- R3-3: the writer falls back to the single-line handle form when the
path contains a newline, so a multi-line annotation can't slip past
the selector's line-based strip.
- R3-8: the selector strip is parse-gated, not prefix-gated, so
marker-prefixed prose (`【媒体资源】清单`) survives to the question.
- R3-9: the recall cap counts DISTINCT resolved files, so one file
named by both path and handle isn't charged twice.
- R3-5/R3-6/R3-7/R3-11: witness tests + guidance/invariant corrections.
Each finding carries a witness test that goes red if its fix is removed.
D3-1 left deferred per the reviewer. Verified: build, tsc, eslint,
prettier, and the full omni + media-memory suite (1109 tests).
---------
Co-authored-by: 王越 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* fix(omni): align source mappings for named exports
---------
Co-authored-by: wangyuenlp <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: 王越 <[email protected]>
This commit is contained in:
198 files changed
+56858
-57
No files matched your search
@@ -22,6 +22,7 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
- 'omni-experiment'
|
||||
merge_group:
|
||||
# The macOS and Windows lanes' ONLY remaining trigger, and therefore this
|
||||
# repository's only signal about a host that is not Linux with a GNU
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
# 多模态文件识别与元数据提取架构设计
|
||||
|
||||
## 状态
|
||||
|
||||
- 状态:Draft
|
||||
- 范围:Qwen Code 内部的多模态文件识别与元数据提取
|
||||
- 基线:`origin/main`(包含 PR #7484 的工具结果统一处理链路)
|
||||
- 后续设计:
|
||||
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
|
||||
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)、
|
||||
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md)
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Qwen Code 当前已经可以从多个入口接触媒体数据:用户可以附加本地文件,
|
||||
ACP 可以传入 image、audio 或 resource,内置工具、MCP、Extension 和 Skill
|
||||
也可能返回 inline data、本地路径、URL 或资源链接。
|
||||
|
||||
这些入口目前没有共享一套文件身份与元数据语义:
|
||||
|
||||
- 本地文件识别主要依赖文件名扩展名和声明的 MIME;
|
||||
- MCP 的 image、audio 和 blob 主要信任返回方声明的 MIME,`resource_link`
|
||||
仍可能退化为文本;
|
||||
- 工具结果虽然已经通过 PR #7484 汇入统一的 `FunctionResponse`
|
||||
处理点,但媒体引用尚未形成独立、结构化的候选集合;
|
||||
- 图片尺寸提取、二进制 magic bytes 判断等能力散落在不同用途的工具函数中,
|
||||
还不能形成一致的音频、视频和图片元数据结果;
|
||||
- URL、base64、data URI 和 bytes 没有先统一成本地文件再识别的共同规则。
|
||||
|
||||
因此,本设计在现有 Qwen Code 输入和工具执行链路上增加一个统一的
|
||||
`MediaRecognitionService`。它只负责回答两个问题:
|
||||
|
||||
1. 一个显式提供的资源是否是图片、音频或视频;
|
||||
2. 如果是,它的统一元数据和稳定内容身份是什么。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 支持本地路径、HTTP(S) URL、base64、data URI、bytes 和 MCP resource
|
||||
等明确结构化的资源表示;
|
||||
- 不信任扩展名或声明 MIME,以文件内容作为最终类型判断依据;
|
||||
- 将 URL 和内存数据在受控条件下落为 Qwen Code 管理的本地文件,之后统一走
|
||||
本地识别链路;
|
||||
- 为图片、音频和视频输出统一的公共元数据与各模态技术元数据;
|
||||
- 按统一、可追溯的公式,基于原始资源的技术 metadata 估算每个媒体资源进入
|
||||
模型时占用的 token 数;
|
||||
- 以本地路径和内容哈希登记资源身份,并保留原始来源;
|
||||
- 在用户输入和工具返回两条现有主链路中各设置一个逻辑识别触发点;
|
||||
- 延续 PR #7484 的原则:各来源先归一化,再在公共漏斗中处理一次。
|
||||
- 通过顶层 `omni.ingestion` 配置 URL 本地化、sniff 预算、probe 预算和模型可见
|
||||
metadata 投影;
|
||||
- 为每次识别记录 resolved ingestion 配置哈希和 probe 后端信息,支持实验复现。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
本文不设计:
|
||||
|
||||
- 文件是否能够上传给某个模型;
|
||||
- 模型路由、Vision Bridge、媒体 payload 拼装或 provider 限制;
|
||||
- 压缩、切分、抽帧、转码等预处理 policy;
|
||||
- 衍生物管理和 memory;
|
||||
- computation 调度;
|
||||
- 面向第三方的通用媒体平台或新的独立产品。
|
||||
|
||||
识别结果以后可以被这些能力消费,但本设计不为它们预先引入抽象。
|
||||
本文只定义 metadata 的模型可见字段投影,不负责把投影插入具体 Provider 请求。
|
||||
|
||||
## 3. 核心决策
|
||||
|
||||
### 3.1 一个服务,两处逻辑触发
|
||||
|
||||
识别器只有一个实现,位于 `packages/core`。调用它的逻辑时机只有两处:
|
||||
|
||||
1. **用户输入完成结构化归一化之后**:TUI、非交互模式、ACP、Web Shell
|
||||
等入口先把附件或明确资源引用转为 `MediaCandidate`,再统一识别;
|
||||
2. **工具输出完成统一归一化之后**:内置工具、MCP、Extension 和 Skill
|
||||
先汇入同一个 normalized tool result,再对其中的 `mediaCandidates`
|
||||
统一识别。唯一例外是内部 `fixed_policy` 调用:它的结果由发起调用的 policy
|
||||
orchestrator 独占消费,在同一个“Tool 输出完成后”逻辑时机复用识别服务,但不再
|
||||
进入公共 Tool result 漏斗,因此不构成第三个触发点。
|
||||
|
||||
“两处触发”指逻辑边界,不要求所有客户端共享同一个物理函数。各客户端可以有
|
||||
自己的输入适配代码,但不得复制 sniff、probe 或 hash 逻辑。
|
||||
|
||||
### 3.2 不在每种工具结果里分别识别
|
||||
|
||||
工具适配器只承担结构转换:把明确类型的媒体块、媒体 artifact 或资源链接提取为
|
||||
`MediaCandidate`。它不判断 magic bytes、不运行 ffprobe,也不递归扫描任意对象。
|
||||
|
||||
统一工具结果增加媒体候选旁路:
|
||||
|
||||
```ts
|
||||
interface NormalizedToolResult {
|
||||
responseParts: Part[];
|
||||
mediaCandidates: MediaCandidate[];
|
||||
}
|
||||
```
|
||||
|
||||
公共调度链路在工具输出归一化完成后只处理一次:
|
||||
|
||||
```ts
|
||||
const normalized = normalizeToolResult(toolResult);
|
||||
|
||||
if (normalized.mediaCandidates.length > 0) {
|
||||
await mediaRecognitionService.recognizeAll(normalized.mediaCandidates);
|
||||
}
|
||||
```
|
||||
|
||||
上述公共链路处理 model/client/普通 Tool 结果;`executionOrigin = fixed_policy` 在进入
|
||||
该链路前由 owning orchestrator 截获,避免相同 policy artifact 被识别两次。
|
||||
|
||||
这不是“逐个理解每种工具结构”。Builtin、MCP、Extension 和 Skill
|
||||
只需在各自现有的 adapter 边界保留结构化媒体引用,后续识别完全共用。
|
||||
|
||||
### 3.3 只处理显式候选,不扫描文本
|
||||
|
||||
以下内容可以成为候选:
|
||||
|
||||
- 用户显式附加或通过文件引用语法解析出的文件;
|
||||
- ACP 的 image、audio、resource 和 file resource link;
|
||||
- 工具返回的 inline data、file data、媒体 `ToolArtifact`;
|
||||
- MCP 的 image、audio、blob、resource 和 `resource_link`;
|
||||
- 其他入口已经明确标记为媒体资源的 path、URL、data URI 或 bytes。
|
||||
|
||||
以下内容不能自动成为候选:
|
||||
|
||||
- 普通对话文本中看起来像路径、URL 或 base64 的片段;
|
||||
- 工具文本输出中偶然出现的路径或 URL;
|
||||
- 为文件激活规则或 Skill 而产生的通用 `resultFilePaths` 全量列表;
|
||||
- 任意工具返回对象的递归字段扫描结果。
|
||||
|
||||
媒体生产者应通过已有 artifact、typed part 或显式媒体引用表达资源;不能表达时,
|
||||
只在对应 adapter 增加结构映射,不把来源特例加入识别器。
|
||||
|
||||
### 3.4 内容证据优先
|
||||
|
||||
识别结果同时保留三种类型证据:
|
||||
|
||||
- `declaredMimeType`:调用方或协议声明;
|
||||
- `extensionMimeType`:文件名或 URL 后缀推断;
|
||||
- `detectedMimeType`:magic bytes、容器签名和 probe 得到的结果。
|
||||
|
||||
最终 `mediaType` 和 `detectedMimeType` 以内容证据为准。声明 MIME 和扩展名只用于
|
||||
提示、初筛与冲突告警,不能覆盖内容结果。
|
||||
|
||||
### 3.5 顶层 Omni ingestion 配置
|
||||
|
||||
识别与 metadata 配置直接放在 settings 顶层 `omni.ingestion`。它仍复用 Qwen
|
||||
Code 现有 settings 加载和 workspace trust,不增加独立配置文件、环境变量、CLI
|
||||
flag 或热更新。首版要求重启生效。
|
||||
|
||||
本文是 `omni.ingestion` 的权威契约;Policy 编排文档中的完整 Omni 示例只做同步
|
||||
展示。Scope 沿用现有优先级
|
||||
`SystemDefaults < User < trusted Workspace < System`,未信任 workspace 不参与
|
||||
合并。普通对象和 scalar 按现有 deep merge/last-wins 处理,所有字段数组整体替换而
|
||||
不做 concat 或 union;resolved ingestion config hash 在合并、默认值补齐和校验后
|
||||
计算。
|
||||
|
||||
配置只开放会影响实验结果、资源成本或等待时间的参数。以下正确性和安全边界不是
|
||||
开关:两个统一触发点、显式候选、内容证据优先、受管本地化、SHA-256 身份、
|
||||
redirect 重新校验、`.part` 原子落盘和下载后重新识别。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Omni 实验配置的唯一顶层入口;不增加 omni.enabled。
|
||||
"omni": {
|
||||
// 配置结构版本,用于记录实验和兼容后续结构调整。
|
||||
"schemaVersion": 1,
|
||||
|
||||
// 文件本地化、识别和 metadata 提取配置。
|
||||
"ingestion": {
|
||||
// 将 URL、base64 等资源转为本地受管文件的配置。
|
||||
"localization": {
|
||||
// HTTP(S) URL 的预检和下载配置。
|
||||
"url": {
|
||||
// 超过该大小时必须征求用户下载许可;默认 100 MiB,只允许调低。
|
||||
"approvalThresholdBytes": 104857600,
|
||||
// URL 预检最多读取的响应前缀,用于初步判断 magic bytes。
|
||||
"preflightBytes": 65536,
|
||||
// HEAD 或 Range 预检的超时时间。
|
||||
"preflightTimeoutMs": 30000,
|
||||
// 用户许可后,完整文件下载允许的最长时间。
|
||||
"downloadTimeoutMs": 600000,
|
||||
// URL 下载最多允许的重定向次数;每一跳都会重新安全检查。
|
||||
"maxRedirects": 5,
|
||||
},
|
||||
// base64、data URI 和直接 bytes 输入的本地化配置。
|
||||
"inline": {
|
||||
// 允许解码并落盘的最大二进制大小;超限返回 resource_limit。
|
||||
"maxDecodedBytes": 104857600,
|
||||
},
|
||||
},
|
||||
|
||||
// 文件内容类型识别配置。
|
||||
"recognition": {
|
||||
// sniff 阶段最多读取的本地文件字节数;不能改变内容证据优先原则。
|
||||
"sniffMaxReadBytes": 65536,
|
||||
},
|
||||
|
||||
// Metadata 的 Harness 内部提取与模型可见字段配置。
|
||||
"metadata": {
|
||||
// Harness 内部完整 metadata 的提取配置。
|
||||
"extraction": {
|
||||
// 图片 probe 的资源控制配置。
|
||||
"image": {
|
||||
// 图片尺寸、方向和动图信息提取的最长执行时间。
|
||||
"timeoutMs": 10000,
|
||||
},
|
||||
// 音频 ffprobe 配置。
|
||||
"audio": {
|
||||
// 单个音频 probe 的最长执行时间。
|
||||
"timeoutMs": 30000,
|
||||
// ffprobe probesize;null 表示使用 ffprobe 默认值。
|
||||
"probeSizeBytes": null,
|
||||
// ffprobe analyzeduration;null 表示使用 ffprobe 默认值。
|
||||
"analyzeDurationUs": null,
|
||||
},
|
||||
// 视频 ffprobe 配置。
|
||||
"video": {
|
||||
// 单个视频 probe 的最长执行时间。
|
||||
"timeoutMs": 30000,
|
||||
// ffprobe probesize;可实验探测精度与耗时的关系。
|
||||
"probeSizeBytes": null,
|
||||
// ffprobe analyzeduration;可调整流信息分析深度。
|
||||
"analyzeDurationUs": null,
|
||||
},
|
||||
},
|
||||
|
||||
// 控制模型能够看到哪些 metadata,不影响 Harness 内部 policy 判断。
|
||||
"modelVisibility": {
|
||||
// 所有媒体都可以展示的公共字段。
|
||||
"commonFields": [
|
||||
"displayName",
|
||||
"sizeBytes",
|
||||
"detectedMimeType",
|
||||
"container",
|
||||
"probe.status",
|
||||
"tokenEstimate.status",
|
||||
"tokenEstimate.estimatedTokenCount",
|
||||
"tokenEstimate.method",
|
||||
],
|
||||
// 图片可以展示的技术字段。
|
||||
"imageFields": [
|
||||
"width",
|
||||
"height",
|
||||
"orientation",
|
||||
"animated",
|
||||
"frameCount",
|
||||
"durationMs",
|
||||
],
|
||||
// 音频文件级可以展示的字段。
|
||||
"audioFields": ["durationMs", "bitRate"],
|
||||
// 音频 stream 可以展示的字段。
|
||||
"audioStreamFields": [
|
||||
"codec",
|
||||
"sampleRateHz",
|
||||
"channels",
|
||||
"channelLayout",
|
||||
"bitRate",
|
||||
],
|
||||
// 视频文件级可以展示的字段。
|
||||
"videoFields": ["durationMs", "bitRate"],
|
||||
// 视频 stream 可以展示的字段。
|
||||
"videoStreamFields": [
|
||||
"codec",
|
||||
"width",
|
||||
"height",
|
||||
"frameRate",
|
||||
"bitRate",
|
||||
],
|
||||
// 是否向模型展示 MIME 冲突、probe 不完整等非致命警告。
|
||||
"includeWarnings": true,
|
||||
// 是否向模型展示内容哈希;内部身份计算不受该开关影响。
|
||||
"includeContentHash": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`metadata.extraction` 是 Harness 的完整事实,供 fixed policy 条件判断和后续 memory
|
||||
使用;`metadata.modelVisibility` 只控制模型可见字段。字段数组可以为空以进行
|
||||
metadata 消融实验,但不能导致内部字段停止提取。模型信封中的 `resourceId` 和
|
||||
`mediaType` 始终存在;`localPath` 和鉴权 header 永不暴露。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph User["用户输入链路"]
|
||||
U1["TUI / Non-interactive / ACP / Web Shell"] --> U2["输入 adapter"]
|
||||
U2 --> U3["MediaCandidate[]"]
|
||||
end
|
||||
|
||||
subgraph Tool["工具返回链路"]
|
||||
T1["Builtin / MCP / Extension / Skill"] --> T2["来源 adapter"]
|
||||
T2 --> T3["NormalizedToolResult"]
|
||||
T3 --> T4["mediaCandidates"]
|
||||
end
|
||||
|
||||
U3 --> R["MediaRecognitionService"]
|
||||
T4 --> R
|
||||
R --> S["解析为受管本地文件"]
|
||||
S --> N["sniff: magic bytes / container"]
|
||||
N --> P["probe: image / audio / video"]
|
||||
P --> T["estimate tokens"]
|
||||
T --> H["identity: local path + SHA-256"]
|
||||
H --> M["MediaFileMetadata"]
|
||||
```
|
||||
|
||||
服务内部是一条流水线,不拆成多个对外模块。URL 预检、sniff、probe 和 hash
|
||||
可以用私有 helper 实现,但对调用方只暴露候选输入和识别结果。
|
||||
|
||||
## 5. 数据契约
|
||||
|
||||
### 5.1 媒体候选
|
||||
|
||||
```ts
|
||||
type MediaLocator =
|
||||
| { kind: 'path'; path: string }
|
||||
| { kind: 'url'; url: string; headers?: Record<string, string> }
|
||||
| { kind: 'base64'; data: string }
|
||||
| { kind: 'data_uri'; dataUri: string }
|
||||
| { kind: 'bytes'; data: Uint8Array }
|
||||
| { kind: 'mcp_resource'; serverName: string; uri: string };
|
||||
|
||||
interface MediaCandidate {
|
||||
locator: MediaLocator;
|
||||
origin:
|
||||
| {
|
||||
kind: 'user';
|
||||
surface: 'tui' | 'non_interactive' | 'acp' | 'web_shell';
|
||||
}
|
||||
| { kind: 'tool'; toolName: string; callId: string }
|
||||
| {
|
||||
kind: 'policy';
|
||||
executionOrigin: 'fixed_policy';
|
||||
toolName: string;
|
||||
invocationId: string;
|
||||
policyId: string;
|
||||
stage: 'preprocessing' | 'transport_guard';
|
||||
}
|
||||
| {
|
||||
kind: 'policy';
|
||||
executionOrigin: 'model' | 'client';
|
||||
toolName: string;
|
||||
invocationId: string;
|
||||
};
|
||||
displayName?: string;
|
||||
declaredMimeType?: string;
|
||||
declaredSizeBytes?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`MediaCandidate` 表示“值得交给识别器验证的显式资源”,不表示已经确认是媒体。
|
||||
MCP adapter 必须在 `resource_link` 退化为文本之前保留它;HTTP(S) resource
|
||||
可归一为 URL,必须通过 MCP server 读取的资源使用 `mcp_resource` locator。
|
||||
|
||||
### 5.2 识别结果
|
||||
|
||||
```ts
|
||||
type MediaRecognitionResult =
|
||||
| { status: 'recognized'; resource: RecognizedMediaResource }
|
||||
| { status: 'not_media'; evidence: RecognitionEvidence }
|
||||
| {
|
||||
status: 'approval_required';
|
||||
request: LargeDownloadApproval;
|
||||
}
|
||||
| {
|
||||
status: 'failed';
|
||||
code:
|
||||
| 'invalid_source'
|
||||
| 'access_denied'
|
||||
| 'download_failed'
|
||||
| 'sniff_failed'
|
||||
| 'resource_limit';
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface RecognizedMediaResource {
|
||||
resourceId: string;
|
||||
metadata: MediaFileMetadata;
|
||||
}
|
||||
|
||||
interface RecognitionEvidence {
|
||||
declaredMimeType?: string;
|
||||
extensionMimeType?: string;
|
||||
detectedSignature?: string;
|
||||
reason: 'unsupported_signature';
|
||||
}
|
||||
|
||||
interface LargeDownloadApproval {
|
||||
sourceUrl: string;
|
||||
displayName?: string;
|
||||
thresholdBytes: number;
|
||||
knownSizeBytes?: number;
|
||||
downloadedBytes: number;
|
||||
canResume: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
`approval_required` 是正常控制流,不是错误。调用入口负责使用自身已有的确认机制
|
||||
询问用户;同意后以 `largeDownloadApproved` 选项重试同一候选,服务复用可续传的
|
||||
`.part`。没有交互能力的入口直接把该状态返回给上层,不得静默下载。
|
||||
|
||||
### 5.3 公共元数据
|
||||
|
||||
```ts
|
||||
interface MediaFileMetadata {
|
||||
schemaVersion: 1;
|
||||
ingestionConfigHash: string;
|
||||
mediaType: 'image' | 'audio' | 'video';
|
||||
displayName?: string;
|
||||
sizeBytes: number;
|
||||
declaredMimeType?: string;
|
||||
extensionMimeType?: string;
|
||||
detectedMimeType: string;
|
||||
container?: string;
|
||||
identity: {
|
||||
algorithm: 'sha256';
|
||||
contentHash: string;
|
||||
localPath: string;
|
||||
};
|
||||
source: {
|
||||
originalKind: MediaLocator['kind'];
|
||||
originalRef?: string;
|
||||
finalUrl?: string;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
};
|
||||
probe: {
|
||||
status: 'complete' | 'partial' | 'unavailable' | 'failed';
|
||||
backend?: string;
|
||||
backendVersion?: string;
|
||||
timeoutMs: number;
|
||||
probeSizeBytes?: number;
|
||||
analyzeDurationUs?: number;
|
||||
message?: string;
|
||||
};
|
||||
tokenEstimate: MediaTokenEstimate;
|
||||
metrics: MediaMetrics;
|
||||
technical: ImageMetadata | AudioMetadata | VideoMetadata;
|
||||
warnings: MediaRecognitionWarning[];
|
||||
}
|
||||
|
||||
interface MediaRecognitionWarning {
|
||||
code:
|
||||
| 'declared_mime_mismatch'
|
||||
| 'extension_mime_mismatch'
|
||||
| 'probe_incomplete';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface MediaTokenEstimate {
|
||||
status: 'estimated' | 'unavailable';
|
||||
estimatedTokenCount?: number;
|
||||
method?: 'dashscope_audio_7tps_v1' | 'dashscope_visual_32x32x2_v1';
|
||||
inputs?:
|
||||
| {
|
||||
mediaType: 'audio';
|
||||
durationMs: number;
|
||||
tokensPerSecond: 7;
|
||||
}
|
||||
| {
|
||||
mediaType: 'image' | 'video';
|
||||
width: number;
|
||||
height: number;
|
||||
frameCount: number;
|
||||
patchSize: 32;
|
||||
mergeFactor: 2;
|
||||
};
|
||||
unavailableReason?: string;
|
||||
}
|
||||
|
||||
interface MediaMetrics {
|
||||
durationMs?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
frameRate?: number;
|
||||
frameCount?: number;
|
||||
bitRate?: number;
|
||||
sampleRateHz?: number;
|
||||
channels?: number;
|
||||
}
|
||||
```
|
||||
|
||||
公共字段保证上层不必先理解来源格式。`resourceId` 是后续 policy 和模型引用资源
|
||||
的 opaque 标识;真实本地路径只在 Harness 内解析。`originalRef` 不保存 base64、
|
||||
data URI 正文或鉴权 header,只保存可安全展示的路径、URL 或 resource URI。
|
||||
|
||||
`ingestionConfigHash`、probe backend、版本和实际参数用于区分不同实验条件。图片
|
||||
probe 不使用 `probeSizeBytes` 或 `analyzeDurationUs` 时省略对应字段。
|
||||
|
||||
### 5.4 各模态技术元数据
|
||||
|
||||
初版字段集合如下:
|
||||
|
||||
```ts
|
||||
interface ImageMetadata {
|
||||
kind: 'image';
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
colorSpace?: string;
|
||||
hasAlpha?: boolean;
|
||||
animated: boolean;
|
||||
frameCount?: number;
|
||||
loopCount?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface AudioStreamMetadata {
|
||||
index: number;
|
||||
isDefault?: boolean;
|
||||
codec?: string;
|
||||
sampleRateHz?: number;
|
||||
channels?: number;
|
||||
channelLayout?: string;
|
||||
bitRate?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface AudioMetadata {
|
||||
kind: 'audio';
|
||||
durationMs?: number;
|
||||
bitRate?: number;
|
||||
streams: AudioStreamMetadata[];
|
||||
}
|
||||
|
||||
interface VideoStreamMetadata {
|
||||
index: number;
|
||||
isDefault?: boolean;
|
||||
codec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
bitRate?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface VideoMetadata {
|
||||
kind: 'video';
|
||||
durationMs?: number;
|
||||
bitRate?: number;
|
||||
videoStreams: VideoStreamMetadata[];
|
||||
audioStreams: AudioStreamMetadata[];
|
||||
}
|
||||
```
|
||||
|
||||
GIF、APNG、animated WebP 等仍归类为 `image`,通过 `animated`、
|
||||
`frameCount` 和可获取的 `durationMs` 表达动态属性,不提升为 video。
|
||||
|
||||
`metrics` 是 fixed policy 条件引擎使用的稳定扁平字段,不要求条件解析器理解 stream
|
||||
数组。图片直接使用图片 metadata;音频和视频的 primary stream 优先选择
|
||||
`isDefault = true`,否则选择 index 最小的 stream。视频 `width`、`height` 和
|
||||
`frameRate` 来自 primary video stream,`maxWidth` 和 `maxHeight` 是所有视频流的
|
||||
最大值;音频 `sampleRateHz` 和 `channels` 来自 primary audio stream。文件级
|
||||
duration/bitrate 优先使用容器值,缺失时再使用 primary stream 值。所有选择规则
|
||||
固定并写入测试,不能随 ffprobe 输出顺序变化。
|
||||
|
||||
## 6. 识别流程
|
||||
|
||||
### 6.1 来源解析与本地化
|
||||
|
||||
不同 locator 先解析成受管本地文件:
|
||||
|
||||
- `path`:经过现有路径访问控制,确认是可读取的普通文件;
|
||||
- `url`:先执行有上限的远端预检,确认可能是多模态资源后再下载;
|
||||
- `base64`、`data_uri`、`bytes`:显式解码并写入受管临时文件;decoded bytes
|
||||
超过 `omni.ingestion.localization.inline.maxDecodedBytes` 时返回
|
||||
`failed/resource_limit`;
|
||||
- `mcp_resource`:通过产生该引用的 MCP server 读取,得到 bytes 或 URL 后
|
||||
进入对应分支。
|
||||
|
||||
临时文件先写 `.part`,成功后原子重命名。解析结束后,后续步骤只接收本地路径,
|
||||
不再区分最初来源。
|
||||
|
||||
### 6.2 sniff
|
||||
|
||||
sniff 最多读取 `omni.ingestion.recognition.sniffMaxReadBytes`,并在预算内读取容器
|
||||
索引所需的少量区域,用 magic bytes 和容器签名判断:
|
||||
|
||||
- 是否属于 image、audio 或 video;
|
||||
- 精确 MIME 和容器;
|
||||
- 声明 MIME、扩展名与内容是否冲突。
|
||||
|
||||
sniff 不以文件扩展名作为最终结论。如果内容不属于支持的三种媒体,返回
|
||||
`not_media`;如果文件无法访问或内容损坏到无法判断,返回 `failed`。
|
||||
|
||||
### 6.3 probe
|
||||
|
||||
sniff 确认媒体类型后运行对应 probe:
|
||||
|
||||
- 图片使用现有 `sharp` 能力提取尺寸、方向、色彩、alpha 和动图信息;
|
||||
- 音频和视频通过一个内部 probe 接口调用 `ffprobe`,再把容器与 stream 输出
|
||||
归一为本设计的数据结构;
|
||||
- 图片、音频和视频分别使用 `omni.ingestion.metadata.extraction` 中的 timeout;
|
||||
- 音频和视频可以显式设置 `probeSizeBytes` 和 `analyzeDurationUs`,`null` 表示不向
|
||||
ffprobe 传入对应参数;
|
||||
- **`ffmpeg`/`ffprobe` 是本实验分支的硬性运行依赖**:启动时检测二进制可用性并
|
||||
记录版本,缺失时直接启动失败并给出安装指引,不提供无 ffprobe 的降级运行模式。
|
||||
`probe.status = unavailable` 仅保留用于表达单个文件的探测器异常,不再表示
|
||||
"环境缺少 ffprobe";
|
||||
- probe 部分字段失败时返回 `partial`,不能抹掉已经由 sniff 确认的类型。
|
||||
|
||||
### 6.4 Token 估算
|
||||
|
||||
识别服务在技术 metadata 完成后生成 `tokenEstimate`。估算完全以**发送给模型的
|
||||
原始媒体资源本身**为依据,不考虑 Provider 服务端的 resize、抽帧或其他预处理;
|
||||
这也是 URL 资源统一下载到本地的原因之一——本地文件就是估算与投递的唯一事实。
|
||||
初版采用需求方指定的 DashScope 估算方式,并统一向上取整:
|
||||
|
||||
```text
|
||||
audioTokens = ceil(durationMs / 1000 * 7)
|
||||
|
||||
visualTokens = ceil(width * height * frameCount / (32 * 32 * 2))
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- 音频固定按每秒 7 tokens 估算;
|
||||
- 静态图片使用 visual 公式且 `frameCount = 1`;
|
||||
- animated image 使用 metadata 中的实际 `frameCount`;
|
||||
- 视频的 `width`、`height` 来自 primary video stream;`frameCount` 优先使用
|
||||
容器/stream 声明的总帧数,缺失时按 `ceil(durationMs / 1000 * frameRate)` 推导;
|
||||
- 每个 policy 衍生物重新进入识别链路后,按衍生物自己的技术 metadata 重新估算,
|
||||
不继承原资源的数值。
|
||||
|
||||
原始长视频的估算值会显著大于 Provider 实际计费 token(服务端会抽帧),这是
|
||||
有意的保守上界:估算的用途是驱动 fixed policy 条件("资源太大 → 先处理"),
|
||||
而不是复现 Provider 账单。估算方法带版本号(`method` 字段),后续如需引入
|
||||
服务端口径的第二套估算,可作为新 method 并存,不覆盖本方法。
|
||||
|
||||
如果 `durationMs`、`width`、`height` 或 `frameRate`/`frameCount` 等必需字段缺失,
|
||||
返回 `status = unavailable` 并记录原因;不允许用猜测值填充。
|
||||
|
||||
`resource.estimatedTokenCount` 条件字段直接读取
|
||||
`tokenEstimate.estimatedTokenCount`,它是唯一的估算值存放位置,不在 `metrics`
|
||||
中重复投影。它是有公式版本和输入依据的估算值,不等于 Provider 最终返回的实际
|
||||
token usage。
|
||||
|
||||
### 6.5 identity 登记
|
||||
|
||||
服务在读取或下载文件时流式计算 SHA-256;无法与 sniff/probe 共用读取过程时,
|
||||
再单独顺序读取一次。最终身份锚点是:
|
||||
|
||||
```text
|
||||
localPath + sha256(content)
|
||||
```
|
||||
|
||||
路径、URL、mtime、size、ETag 和 Last-Modified 都可能变化,只能作为快速校验或
|
||||
来源信息,不能替代内容哈希。相同内容可以来自多个位置;内容哈希相同表示相同
|
||||
文件版本,来源记录仍分别保留。
|
||||
|
||||
识别成功后为当前 Harness 资源登记 opaque `resourceId`。该 ID 供后续 policy 和
|
||||
模型 Tool 引用,不替代 `localPath + contentHash` 的内部身份锚点。
|
||||
|
||||
## 7. URL 规则与可配置许可边界
|
||||
|
||||
URL 是唯一需要在"尚未形成完整本地文件"时先判断是否值得下载的来源。
|
||||
|
||||
即使投递层统一采用 DashScope 官方临时上传(见 Policy 编排设计 §10.3),外部
|
||||
URL 也**不透传给模型**,仍然必须先下载本地化。原因是本地字节是全链路的唯一
|
||||
事实源:
|
||||
|
||||
1. 技术 metadata 依赖完整本地文件(MP4 moov atom 常在文件尾,远程 Range 探测
|
||||
不可靠);
|
||||
2. token 估算完全基于原始资源的技术 metadata;
|
||||
3. policy 加工(裁剪、抽帧、音轨提取)必须操作本地字节;
|
||||
4. 完整 SHA-256 身份与 Memory 版本判断需要全部字节;
|
||||
5. oss URL 48h 过期后的重传字节只能来自本地对象库;
|
||||
6. 外部 URL 可能缺失 `Content-Length`/`Content-Type`、带鉴权或内容漂移,服务端
|
||||
拉取不可靠且实验不可复现;受管上传 URL 则始终可用。
|
||||
|
||||
### 7.1 有界预检
|
||||
|
||||
服务先执行 HEAD;如果服务端不支持,再执行带 Range 的有界 GET。预检最多读取
|
||||
`omni.ingestion.localization.url.preflightBytes`,并服从 `preflightTimeoutMs`,
|
||||
不把完整响应留在内存中,并尽量使用 `Accept-Encoding: identity`。记录:
|
||||
|
||||
- `Content-Length`;
|
||||
- `Content-Type`;
|
||||
- `Content-Disposition`;
|
||||
- `ETag`、`Last-Modified`;
|
||||
- redirect 后的 final URL;
|
||||
- 前缀 magic bytes。
|
||||
|
||||
预检阶段仍以 magic bytes 为主要证据。HTTP `Content-Type` 只作为声明信息。
|
||||
如果前缀能确认是非媒体,返回 `not_media`;如果证据不足,返回
|
||||
`failed/sniff_failed`。两种情况都不为了“试一试”而下载完整文件。
|
||||
|
||||
### 7.2 大小许可
|
||||
|
||||
许可阈值来自:
|
||||
|
||||
```text
|
||||
omni.ingestion.localization.url.approvalThresholdBytes
|
||||
```
|
||||
|
||||
默认值是 `100 MiB = 100 * 1024 * 1024 bytes`。为保持此前“超过 100 MiB 必须
|
||||
询问”的边界,实验配置可以调低但不能调高。
|
||||
|
||||
- `Content-Length > approvalThresholdBytes`:在完整下载前返回
|
||||
`approval_required`;
|
||||
- `Content-Length <= approvalThresholdBytes` 或缺失,但继续写入将使文件超过该
|
||||
阈值:在写入超限字节前暂停并返回 `approval_required`;
|
||||
- 用户拒绝:删除 `.part`,不产生本地资源;
|
||||
- 用户同意:从中断位置继续;服务端不支持 Range 时重新下载;
|
||||
- 整个过程按实际写入字节计数,不能只相信 HTTP header;
|
||||
- redirect 最多进行 `maxRedirects` 次;每一跳重新执行访问控制和大小判断;
|
||||
- 预检和完整下载分别使用配置中的超时,超时按下载失败返回。
|
||||
|
||||
用户授权的是当前资源的一次下载,不是对域名或后续所有大文件的永久授权。
|
||||
|
||||
### 7.3 下载后的权威识别
|
||||
|
||||
远端预检只用于决定是否本地化。下载完成后必须从本地文件重新执行完整的
|
||||
sniff、probe 和 hash,最终 metadata 不直接采用预检结论。
|
||||
|
||||
## 8. 两条现有链路的接入
|
||||
|
||||
### 8.1 用户输入链路
|
||||
|
||||
各输入面保留自己的语法与协议解析,在完成结构化解析后输出候选:
|
||||
|
||||
| 当前入口 | adapter 责任 | 统一触发位置 |
|
||||
| ------------------ | --------------------------------------------------- | ----------------------- |
|
||||
| TUI 附件与 `@file` | 将已解析出的真实文件引用变为 path candidate | `@` 命令解析完成后 |
|
||||
| 非交互输入 | 将 `readManyFiles` 已解析出的显式文件变为 candidate | 文件输入归一化完成后 |
|
||||
| ACP | 保留 image、audio、resource 和 resource link 的结构 | `#resolvePrompt` 完成后 |
|
||||
| Web Shell | 将上传控件或协议层的显式附件变为 candidate | 请求输入归一化完成后 |
|
||||
|
||||
入口不得对整段 prompt 做 URL/base64 猜测。普通文本保持普通文本。
|
||||
|
||||
### 8.2 工具返回链路
|
||||
|
||||
PR #7484 已经让内置工具、MCP 和 Extension 的模型可见结果先归一化为
|
||||
response parts,再由共享 helper 处理。Core scheduler 与 ACP `Session.runTool()`
|
||||
各有物理调用点,但都处理模型 ToolCall 的同一个逻辑结果边界。文件识别沿用这个
|
||||
架构:
|
||||
|
||||
1. 各来源 adapter 在既有转换过程中,同时产出 `mediaCandidates`;
|
||||
2. Core scheduler 与 ACP `Session.runTool()` 都在 normalized tool result
|
||||
形成后调用同一个识别 helper;
|
||||
3. `fixed_policy` 由 owning orchestrator 在同一 Tool 输出逻辑边界消费并调用该
|
||||
helper,不再进入上述公共漏斗;
|
||||
4. model/client MediaPolicyTool 的原始 producer artifacts 由公共漏斗中的 policy
|
||||
bridge 分支转换为候选,不再由通用 artifact adapter 重复提取;hook artifacts
|
||||
仍按普通显式候选处理;
|
||||
5. 候选为空时直接跳过,不运行 sniff/probe;
|
||||
6. 识别结果登记为 `RecognizedMediaResource`,同时生成配置控制的模型可见 metadata
|
||||
投影;
|
||||
7. 本文不改变媒体 `responseParts` 如何序列化给 Provider,后续由 policy 编排和
|
||||
delivery 设计消费该资源与投影。
|
||||
|
||||
具体来源映射:
|
||||
|
||||
| 来源 | 候选来源 | 不能做的事 |
|
||||
| --------- | --------------------------------------------- | ------------------------------------- |
|
||||
| Builtin | 明确媒体 artifact、typed part | 枚举所有 `resultFilePaths` 并逐个识别 |
|
||||
| MCP | image、audio、blob、resource、`resource_link` | 先把 link 变成文本再从文本反推 |
|
||||
| Extension | 统一 tool result 中的 typed part 或 artifact | 在 extension 分支复制识别器 |
|
||||
| Skill | Skill/工具显式发布的媒体 artifact | 扫描 Skill Markdown 中的任意链接 |
|
||||
|
||||
现有 `processToolResultImages` 仍承担 Vision Bridge 的既有职责。文件识别与它共享
|
||||
“归一化后统一处理”的位置,但不能混入其模型上传或图像转写逻辑。
|
||||
|
||||
### 8.3 Metadata extraction 与 model visibility
|
||||
|
||||
每个识别入口都只生成一份完整内部 metadata。`metadata.modelVisibility` 从该事实对象生成
|
||||
模型可见投影,不反向控制 probe:
|
||||
|
||||
- model visibility 字段数组为空时,只隐藏模型 metadata,不关闭内部提取;
|
||||
- fixed policy 始终读取完整内部 metadata;
|
||||
- probe 或 token 估算所需字段不可得时保留对应状态,供 Fixed Policy condition
|
||||
产生 `unavailable`;
|
||||
- `resourceId` 和 `mediaType` 始终保留在模型可见资源信封;
|
||||
- `localPath` 和鉴权 header 不允许进入 model visibility;
|
||||
- 首版 `source` 只用于内部 provenance,不提供 source visibility 配置;
|
||||
- 新 policy artifact 重新进入识别服务后,使用同一套 model visibility 配置。
|
||||
|
||||
## 9. 失败语义
|
||||
|
||||
| 场景 | 结果 |
|
||||
| ---------------------------------- | ------------------------------------------- |
|
||||
| 候选内容不是图片、音频或视频 | `not_media` |
|
||||
| 文件不存在、权限拒绝、不是普通文件 | `failed` |
|
||||
| URL 预检确认不是媒体 | `not_media`,不完整下载 |
|
||||
| URL 预检证据不足 | `failed/sniff_failed`,不完整下载 |
|
||||
| URL 已知或实际大小超过配置许可阈值 | `approval_required` |
|
||||
| 用户拒绝大文件下载 | 终止并清理 `.part` |
|
||||
| inline decoded bytes 超过配置上限 | `failed/resource_limit` |
|
||||
| sniff 成功、probe 不可用 | `recognized` + `probe.status = unavailable` |
|
||||
| sniff 成功、probe 只得到部分字段 | `recognized` + `probe.status = partial` |
|
||||
| probe 超过配置 timeout | 保留身份,probe 标为 partial 或 unavailable |
|
||||
| 声明 MIME/扩展名与内容不一致 | 采用 detected MIME,并记录 warning |
|
||||
| 同一候选被多个入口重复提交 | 允许按内容哈希复用识别结果 |
|
||||
|
||||
单个候选失败不应让同批其他候选丢失结果。`recognizeAll` 保持输入顺序,并为每个
|
||||
候选返回独立状态。
|
||||
|
||||
## 10. 安全与资源约束
|
||||
|
||||
- 复用 Qwen Code 现有的文件访问、workspace trust、网络访问和用户确认机制;
|
||||
- 只读取普通文件,不读取 device、FIFO 或 socket;
|
||||
- URL 每次 redirect 后重新校验目标,不允许通过 redirect 绕过网络规则;
|
||||
- 鉴权 header 仅用于当前读取,不写入 metadata、日志或内容身份;
|
||||
- base64、data URI 和 bytes 只接受结构化字段,不尝试从任意文本中提取;
|
||||
- 解码、预检和下载均使用流式或有界缓冲,禁止把大文件整体复制到内存;
|
||||
- 临时文件只能在成功原子重命名后暴露,失败和拒绝路径清理 `.part`;
|
||||
- 内容哈希缓存若后续实现,只能用 size、mtime、inode 等作为命中提示,命中失效后
|
||||
必须重新计算,不能把可变属性当成稳定身份。
|
||||
|
||||
## 11. 与当前代码的对应关系
|
||||
|
||||
本设计是对现有链路的收敛,不新建平行产品:
|
||||
|
||||
- `packages/core/src/utils/fileUtils.ts` 当前的路径解析、`FileType` 和本地内容读取
|
||||
继续作为文件入口,但媒体最终类型从扩展名/MIME 判断迁移到统一 sniff;
|
||||
- `packages/cli/src/ui/components/InputPrompt.tsx` 与
|
||||
`packages/cli/src/ui/hooks/atCommandProcessor.ts` 保留 TUI/非交互输入语法,
|
||||
只在解析完成后产生候选;
|
||||
- `packages/cli/src/acp-integration/session/Session.ts` 保留 ACP 协议解析,
|
||||
避免 resource link 在识别前丢失结构;
|
||||
- `packages/core/src/tools/mcp-tool.ts` 和 `mcp-resource-content.ts` 负责把 MCP
|
||||
typed content 转为候选,不再把声明 MIME 当作识别结论;
|
||||
- `packages/core/src/tools/tools.ts` 的 `ToolArtifact` 是工具显式媒体资源的主要
|
||||
承载方式;通用 `resultFilePaths` 继续服务原有文件规则,不被扩大为媒体扫描器;
|
||||
- `packages/cli/src/config/settingsSchema.ts` 和 `packages/cli/src/config/config.ts`
|
||||
加载顶层 `omni.ingestion`,执行专用结构与范围校验后传入 Core `Config`;
|
||||
- `packages/core/src/config/config.ts` 保存不可变的 normalized Omni 配置并提供只读
|
||||
getter;识别结果记录 resolved ingestion config hash;
|
||||
- `packages/core/src/core/coreToolScheduler.ts` 中 `convertToFunctionResponse`
|
||||
之后,以及 ACP `Session.runTool()` 形成 response parts 之后,是同一逻辑触发点在
|
||||
两条执行链路中的物理接入位置;二者必须调用同一个识别 helper;
|
||||
- `packages/core/src/services/visionBridge` 保持既有职责,不承载通用文件识别。
|
||||
|
||||
识别成功后的固定 policy、模型 Tool、衍生物回流与 DashScope 投递由
|
||||
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)
|
||||
继续定义。
|
||||
|
||||
建议把新服务放在:
|
||||
|
||||
```text
|
||||
packages/core/src/services/media-recognition/
|
||||
```
|
||||
|
||||
目录内部可以按 locator 解析、sniff 和 probe 组织私有文件,但只对外导出服务、
|
||||
候选类型和结果类型,避免形成多个需要调用方自行编排的模块。
|
||||
|
||||
## 12. 可验证性与验收标准
|
||||
|
||||
### 12.1 统一识别
|
||||
|
||||
- 同一媒体分别以 path、URL、base64、data URI 和 bytes 输入,最终
|
||||
`mediaType`、`detectedMimeType`、技术元数据和 SHA-256 一致;
|
||||
- 将图片改成错误扩展名并声明错误 MIME,仍按 magic bytes 识别,并产生冲突告警;
|
||||
- GIF/APNG/animated WebP 返回 `mediaType = image` 且 `animated = true`;
|
||||
- 普通文本文件和伪装成媒体的文件返回 `not_media`。
|
||||
|
||||
### 12.2 触发点
|
||||
|
||||
- TUI、非交互、ACP 的显式媒体输入均在输入归一化后进入同一个服务;
|
||||
- Builtin、MCP、Extension 和 Skill 的显式媒体结果均在
|
||||
normalized tool result 形成后进入同一个服务;
|
||||
- 候选为空的工具结果不运行 sniff 或 probe;
|
||||
- 工具文本中出现 URL、路径或 base64 时不触发识别;
|
||||
- MCP `resource_link` 在退化为文本前被保留为结构化候选;
|
||||
- 不通过 `resultFilePaths` 扫描工具产生的所有文件。
|
||||
|
||||
### 12.3 URL 与大文件
|
||||
|
||||
- 默认配置下,已知 101 MiB 的媒体 URL 在完整下载前请求一次用户许可;
|
||||
- 将许可阈值调低后,下载行为使用新阈值且 resolved config hash 发生变化;
|
||||
- 缺少或伪报 `Content-Length` 的响应在继续写入将超过配置阈值时暂停并请求许可;
|
||||
- 拒绝后 `.part` 被清理;允许后可继续,Range 不可用时能安全重启;
|
||||
- 非媒体 URL 只进行有界预检,不完整下载;
|
||||
- redirect 后重新执行安全和大小检查;
|
||||
- 下载结果使用本地完整文件重新 sniff/probe/hash。
|
||||
|
||||
### 12.4 配置与 metadata 投影
|
||||
|
||||
- inline decoded bytes 超过 `maxDecodedBytes` 时返回 `failed/resource_limit`;
|
||||
- sniff 读取不超过 `sniffMaxReadBytes`;
|
||||
- probe 使用配置的 timeout、probeSizeBytes 和 analyzeDurationUs,并记录实际值;
|
||||
- 音频 10 秒的 `estimatedTokenCount` 为 70;
|
||||
- 静态图片和视频严格按原始资源技术 metadata 及 `32 * 32 * 2` 公式向上取整;
|
||||
- 视频缺失容器总帧数时按 `duration * frameRate` 推导 frameCount;
|
||||
- 缺少 duration/width/height/frameRate 等必需字段时 token estimate 标为
|
||||
unavailable,不伪造数值;
|
||||
- `estimatedTokenCount` 只存在于 `tokenEstimate` 一处,`metrics` 不重复投影;
|
||||
- model visibility allowlist 为空时,fixed policy 可用的内部 metadata 不减少;
|
||||
- `resourceId` 和 `mediaType` 始终可见,`localPath` 和鉴权 header 始终不可见;
|
||||
- 不支持的 model visibility 字段、非法超时和高于 100 MiB 的下载许可阈值在启动时失败。
|
||||
- scope 合并时 model visibility 字段数组整体替换,未信任 workspace 的 ingestion 配置不
|
||||
生效。
|
||||
|
||||
### 12.5 降级
|
||||
|
||||
- 启动时缺少 `ffmpeg`/`ffprobe` 直接启动失败并给出明确报错,不进入无 probe 的
|
||||
降级运行;
|
||||
- 单个损坏媒体的 probe 失败不影响同批其他候选;
|
||||
- 无交互入口遇到大文件时返回 `approval_required`,不自行授权。
|
||||
|
||||
## 13. 待实现阶段确认的细节
|
||||
|
||||
以下问题不影响本架构边界,在进入实现计划时再确定:
|
||||
|
||||
- 初版完整支持的 image/audio/video magic bytes 与容器矩阵;
|
||||
- `ffmpeg`/`ffprobe` 的具体分发方式(系统依赖 + 启动检查,或随实验分支捆绑
|
||||
`ffmpeg-static` 一类静态二进制);两种方式都必须满足"缺失即启动失败";
|
||||
- 内容哈希与 probe 结果的缓存实现。
|
||||
|
||||
受管本地文件的目录布局、staging/quarantine、保留周期和垃圾回收由
|
||||
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md) 单独定义。
|
||||
|
||||
这些选择不得改变已经确定的约束:**显式候选、统一漏斗、内容证据优先、基于
|
||||
原始资源的可追溯 token 估算,以及内部 metadata 与模型可见字段分离**。
|
||||
@@ -0,0 +1,961 @@
|
||||
# Omni 多模态 Memory 架构设计
|
||||
|
||||
## 状态
|
||||
|
||||
- 状态:Draft
|
||||
- 范围:Qwen Code 实验分支中的图片、音频、视频文件级 Memory
|
||||
- 前置设计:
|
||||
[多模态文件识别与元数据提取架构设计](./2026-07-29-multimodal-file-recognition-and-metadata.md)、
|
||||
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)
|
||||
- 配套设计:
|
||||
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md)
|
||||
- 基线:`origin/main`,核对至 `2ce9da85bd99`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
文件识别阶段已经把用户输入、工具结果、URL、base64 和 bytes 归一为带有完整
|
||||
SHA-256 与技术 metadata 的媒体资源;Policy 阶段又可以从这些资源产生音轨、切片、
|
||||
关键帧、转写、OCR、caption 和 summary 等结果。
|
||||
|
||||
如果这些事实只存在于当前 Tool 返回或当前模型上下文中,后续调用无法稳定回答:
|
||||
|
||||
- 当前文件已经提取过哪些 metadata;
|
||||
- 哪些衍生物来自哪个文件版本、哪个 policy 和哪段范围;
|
||||
- 一段转写、OCR 或 summary 覆盖了文件的哪些部分;
|
||||
- 当前问题是否已有可复用结果,还是仍需调用某个 policy;
|
||||
- 原文件发生变化后,哪些旧结果已经不应默认召回。
|
||||
|
||||
因此,本设计在现有 Qwen Code 识别服务、Policy Tool 和请求构造链路上增加一个
|
||||
文件级的结构化 Memory。它不是新的产品、独立平台或 Agent 自主记忆系统;它只把
|
||||
Harness 已经确定的文件事实和成功 policy 结果组织成可追溯、可召回的文件内图。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 只在两个确定的 Harness 生命周期边界收集 Memory:`FileRecognized` 与
|
||||
`OmniPolicySucceeded`;
|
||||
- 建立文件、文件版本、policy 执行、媒体衍生物和文本结果之间的文件内关联;
|
||||
- 保留来源、完整内容哈希、处理参数、作用范围、覆盖度和 producer lineage;
|
||||
- 对成功 policy 的全部有效输出进行原子登记,不留下半套可召回结果;
|
||||
- 支持 Agent 主动调用 Tool 召回,以及 Harness 通过 side query 被动召回;
|
||||
- 两种召回模式复用同一查询服务和返回协议,并通过 `omni.memory.recall.mode`
|
||||
切换;
|
||||
- 默认只召回当前文件版本的记录,文件变化后自动隔离旧版本结果;
|
||||
- 允许后续 policy 直接使用召回到的既有媒体衍生物,而不向模型暴露真实本地路径;
|
||||
- 记录 resolved Omni 配置、Tool 版本和最终参数,支持实验复现与训练数据追溯。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
初版不设计:
|
||||
|
||||
- `MediaTurnCompleted` 或其他轮次结束后的总结触发;
|
||||
- `MediaTurnTrace`,以及“模型在本轮实际看过什么”的完整轨迹;
|
||||
- 从主 Agent 最终回答中抽取结论并写入 Memory;
|
||||
- 允许 Agent 通过 Tool 直接创建、修改或删除 Memory;
|
||||
- Agent 驱动的 memory extraction、dream 或 consolidation;
|
||||
- 跨文件语义关联、相似文件合并或全库内容搜索;
|
||||
- `/learn` command 的任何接入;
|
||||
- computation 调度;
|
||||
- 具体存储后端、长期保留周期和垃圾回收实现。
|
||||
|
||||
本设计中的 side query 只选择已经存在的记录,不能生成新结论并写回 Memory。
|
||||
|
||||
## 3. 核心决策
|
||||
|
||||
### 3.1 初版只有两个收集触发点
|
||||
|
||||
初版只允许以下两类事实进入媒体 Memory:
|
||||
|
||||
1. `FileRecognized`:统一识别器已经确认媒体类型、计算完整 SHA-256,并产出可用的
|
||||
文件 metadata;
|
||||
2. `OmniPolicySucceeded`:一个 MediaPolicyTool 已成功执行,所有返回产物已经完成
|
||||
descriptor、路径、类型、内容和 lineage 校验。
|
||||
|
||||
这里的“触发点”是语义生命周期边界,不要求新增全局 EventBus。实现可以是识别服务
|
||||
和 policy orchestrator 对同一个 `MediaMemoryService` 的显式方法调用:
|
||||
|
||||
```ts
|
||||
await mediaMemory.recordFileRecognized(payload);
|
||||
await mediaMemory.commitPolicySucceeded(payload);
|
||||
```
|
||||
|
||||
这样可以沿用现有调用链、事务和错误传播,不建立另一套异步消息平台。
|
||||
|
||||
### 3.2 Harness 独占写入,Agent 只读
|
||||
|
||||
Memory 的写入事实必须来自可验证的 Harness 事件,而不是模型判断:
|
||||
|
||||
- Agent 可以调用 MediaPolicyTool;Tool 成功后由 Harness 触发
|
||||
`OmniPolicySucceeded`,间接增加 Memory;
|
||||
- Agent 可以调用召回 Tool 读取既有记录;
|
||||
- Agent 不能提交任意文本让 Memory 保存;
|
||||
- side query 只能返回候选记录 ID,不能生成新 Memory 条目;
|
||||
- 主 Agent 的回答、推理或猜测不自动进入 Memory。
|
||||
|
||||
因此,初版不需要 `MediaTurnTrace`。只有未来要保存“本轮采用了哪些证据、模型形成了
|
||||
什么结论”时,才需要引入轮次级 trace 和第三个收集触发点。
|
||||
|
||||
### 3.3 初版只建立文件内图
|
||||
|
||||
每个原始文件形成一个独立 root graph。它的 metadata、policy 执行、衍生媒体和文本
|
||||
结果都挂在这个 root 下;初版不在两个 root file 之间建边。
|
||||
|
||||
即使两个文件的完整 SHA-256 相同,也只能复用底层识别或 policy 缓存,不能合并
|
||||
它们的 `fileId` 或来源关系。这样可以保留“用户从哪里得到这个文件、在哪个上下文中
|
||||
使用它”的独立事实,避免内容相同但权限、来源或语义不同的资源被错误混为一体。
|
||||
|
||||
### 3.4 文件身份与内容版本分离
|
||||
|
||||
`fileId` 表示一个逻辑文件,`fileVersionId` 表示该逻辑文件的一份确定字节内容。
|
||||
|
||||
初版只在以下场景确认是同一个逻辑文件:
|
||||
|
||||
- 上游携带已知的稳定 `fileRef`,或者同一 project 内再次出现相同来源类型和规范化
|
||||
locator(例如同一个本地路径或同一个 URL);
|
||||
- URL、base64 或 bytes 本地化过程显式把来源和受管本地文件登记为同一资源;
|
||||
- policy 产物通过 `parentRef`、`rootFileId` 和 `producedBy` 明确建立 lineage;
|
||||
- 同一受管资源在当前系统内被再次解析,且携带既有持久引用。
|
||||
|
||||
不同路径、不同 URL,或没有稳定 locator/lineage 的独立工具结果,即使名称、大小、
|
||||
ETag 或 SHA-256 相同,也作为另一个 `File` 处理。locator 只确定逻辑 File,不能证明
|
||||
其内容版本没有变化。
|
||||
|
||||
在已经确认的同一 `fileId` 内,只有完整文件 SHA-256 相同才能确认是同一版本:
|
||||
|
||||
- SHA-256 相同:复用现有 `FileVersion`;
|
||||
- SHA-256 不同:创建新版本,并把它设为当前版本;
|
||||
- URL、文件名、大小、mtime、ETag、声明 MIME 和部分哈希都不能单独证明版本相同。
|
||||
|
||||
### 3.5 Metadata 是版本属性,不强制拆成节点
|
||||
|
||||
技术 metadata 直接保存在 `FileVersion` 上即可。只有具有独立来源、范围或 producer
|
||||
语义的 policy 结果才成为独立节点。这样既能查询宽高、时长、流信息和 probe 状态,
|
||||
又不会把每个标量字段膨胀成图节点。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U["用户输入或普通工具结果"] --> R["统一媒体识别"]
|
||||
P["MediaPolicyTool 原始产物"] --> R
|
||||
R --> F{"资源来源"}
|
||||
F -- "user / tool" --> FR["FileRecognized 直接提交"]
|
||||
F -- "policy" --> ST["按 invocation 暂存识别结果"]
|
||||
|
||||
PT["Policy Tool 执行成功"] --> V["校验全部 required/returned outputs"]
|
||||
ST --> V
|
||||
V --> PS["OmniPolicySucceeded 原子提交"]
|
||||
|
||||
FR --> G["文件内 Media Memory Graph"]
|
||||
PS --> G
|
||||
|
||||
AQ["Agent 主动调用 Recall Tool"] --> RS["共享 Recall Service"]
|
||||
SQ["Harness side query 选择已有 entryId"] --> RS
|
||||
G --> RS
|
||||
RS --> RR["统一 RecallResult"]
|
||||
RR --> AQ
|
||||
RR --> SQ
|
||||
```
|
||||
|
||||
写链路只由两个触发点驱动。主动召回和 side-query 召回是读链路,不构成新的收集
|
||||
触发点。
|
||||
|
||||
## 5. 文件内逻辑数据模型
|
||||
|
||||
### 5.1 概念图
|
||||
|
||||
```text
|
||||
File
|
||||
└── FileVersion
|
||||
├── technical metadata
|
||||
├── PolicyExecution
|
||||
│ ├── final arguments / input scope / config hash
|
||||
│ └── output references
|
||||
├── DerivedMedia (另一个带 lineage 的 File/FileVersion)
|
||||
│ └── subsequent PolicyExecution
|
||||
└── PolicyResult
|
||||
├── transcript
|
||||
├── OCR
|
||||
├── caption
|
||||
└── summary
|
||||
```
|
||||
|
||||
媒体衍生物在物理模型中仍使用 `File + FileVersion`,从而复用统一识别、版本和
|
||||
metadata 契约;`DerivedMedia` 只是它在父文件图中的角色。文本结果使用
|
||||
`PolicyResult`,不伪装成媒体文件。
|
||||
|
||||
### 5.2 持久 ID 与 session resourceId
|
||||
|
||||
当前 Policy 设计中的 `resourceId` 是会话内 opaque handle,用于让模型调用 Tool,
|
||||
不能作为跨 session 的持久主键。Memory 使用独立 ID:
|
||||
|
||||
```ts
|
||||
type MediaFileId = string;
|
||||
type MediaFileVersionId = string;
|
||||
type MediaMemoryEntryId = string;
|
||||
type PolicyExecutionId = string;
|
||||
```
|
||||
|
||||
召回某个仍可访问的媒体文件或衍生物时,Harness 把持久 `fileVersionId` 重新绑定到
|
||||
当前 session 的 `MediaResourceRegistry`,再返回新的或已存在的 `resourceId`。
|
||||
模型只看到 `resourceId` 和持久 evidence ref,不看到本地路径。
|
||||
|
||||
### 5.3 File 与 FileVersion
|
||||
|
||||
以下接口是逻辑契约,不预先绑定 SQLite、JSON 或其他存储格式:
|
||||
|
||||
```ts
|
||||
interface MediaFileRecord {
|
||||
fileId: MediaFileId;
|
||||
rootFileId: MediaFileId;
|
||||
origin: 'user' | 'tool' | 'policy';
|
||||
currentVersionId: MediaFileVersionId;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MediaFileVersionRecord {
|
||||
fileVersionId: MediaFileVersionId;
|
||||
fileId: MediaFileId;
|
||||
sha256: string;
|
||||
mediaType: 'image' | 'audio' | 'video';
|
||||
metadata: MediaFileMetadata;
|
||||
source: MediaSourceProvenance;
|
||||
recognition: {
|
||||
ingestionConfigHash: string;
|
||||
detectorVersion: string;
|
||||
probeBackend?: string;
|
||||
probeStatus: 'complete' | 'partial' | 'unavailable';
|
||||
};
|
||||
parentVersionId?: MediaFileVersionId;
|
||||
producedByExecutionId?: PolicyExecutionId;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
`source` 保留来源类型和经过脱敏的 locator/protocol 信息。鉴权 header、token 和真实
|
||||
受管路径不进入模型可见协议。
|
||||
|
||||
### 5.4 PolicyExecution
|
||||
|
||||
```ts
|
||||
interface MediaPolicyExecutionRecord {
|
||||
executionId: PolicyExecutionId;
|
||||
invocationId: string;
|
||||
sourceVersionId: MediaFileVersionId;
|
||||
rootFileId: MediaFileId;
|
||||
executionOrigin:
|
||||
| {
|
||||
kind: 'fixed_policy';
|
||||
policyId: string;
|
||||
stage: 'preprocessing' | 'transport_guard';
|
||||
}
|
||||
| { kind: 'model' | 'client' };
|
||||
toolName: string;
|
||||
toolVersion: string;
|
||||
finalArguments: Record<string, unknown>;
|
||||
inputScope: MediaScope;
|
||||
omniConfigHash: string;
|
||||
outputRefs: MediaMemoryEntryId[];
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Memory 记录的是通过 Tool 原生 schema、`defaultArguments`、`lockedArguments` 和
|
||||
runtime resolver 之后的最终参数,不只记录用户原始配置或模型原始参数。
|
||||
|
||||
### 5.5 Scope、channel 与 coverage
|
||||
|
||||
同一结果必须明确“它描述了文件的哪一部分”和“覆盖了哪些信息通道”:
|
||||
|
||||
```ts
|
||||
interface MediaScope {
|
||||
temporal?: { startMs: number; endMs: number };
|
||||
spatial?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
unit: 'normalized';
|
||||
};
|
||||
frameRange?: { start: number; end: number };
|
||||
streamIndexes?: number[];
|
||||
audioChannels?: number[];
|
||||
}
|
||||
|
||||
type MediaChannel =
|
||||
| 'technical_metadata'
|
||||
| 'visual'
|
||||
| 'acoustic'
|
||||
| 'speech_text'
|
||||
| 'onscreen_text';
|
||||
|
||||
interface MediaCoverage {
|
||||
mode: 'complete' | 'continuous' | 'sampled' | 'partial' | 'summary';
|
||||
scope: MediaScope;
|
||||
sampleCount?: number;
|
||||
sampleRate?: number;
|
||||
}
|
||||
```
|
||||
|
||||
空 `MediaScope` 表示整个文件版本。示例:
|
||||
|
||||
- 完整音频转写:`speech_text + complete + {}`;
|
||||
- 视频 15s 到 30s 的切片:`visual/acoustic + continuous + temporal`;
|
||||
- 每 10 秒抽一帧:`visual + sampled + temporal + sampleRate`;
|
||||
- 图片局部 OCR:`onscreen_text + partial + spatial`;
|
||||
- 全片 summary:`visual/acoustic + summary + {}`。
|
||||
|
||||
### 5.6 统一 Policy 输出记录
|
||||
|
||||
Memory 不解析 `llmContent` 寻找结果。每个可持久化输出必须来自
|
||||
`PolicyArtifactBatch`,并由 Tool descriptor 和 bridge 归一为:
|
||||
|
||||
```ts
|
||||
interface NormalizedPolicyOutput {
|
||||
outputId: MediaMemoryEntryId;
|
||||
kind: 'derived_media' | 'policy_result';
|
||||
role: string;
|
||||
artifactRef?: {
|
||||
storage: 'managed' | 'workspace';
|
||||
managedId?: string;
|
||||
workspacePath?: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
inlineText?: string;
|
||||
/** 有损/表达变换输出随附的处理与降质披露文本(见 Policy 设计 3.2)。 */
|
||||
disclosure?: string;
|
||||
scope: MediaScope;
|
||||
channels: MediaChannel[];
|
||||
coverage: MediaCoverage;
|
||||
parentVersionId: MediaFileVersionId;
|
||||
producedByExecutionId: PolicyExecutionId;
|
||||
}
|
||||
```
|
||||
|
||||
初版至少需要稳定表达这些 role:
|
||||
|
||||
- 文本结果:`transcript`、`ocr`、`caption`、`summary`;
|
||||
- 媒体结果:`keyframe`、`clip`、`extracted_audio`;
|
||||
- 其他实验产物由 MediaPolicyTool descriptor 声明稳定 role,不能依赖展示标题猜测。
|
||||
|
||||
媒体结果必须使用 artifact 引用并重新经过文件识别。小型 UTF-8 文本可以内联保存;
|
||||
超过 `collection.maxInlineTextBytes` 的文本必须由 policy 输出受管文本 artifact。两者都
|
||||
必须带 scope、channel、coverage 和 producer,不能只存一段无来源文本。
|
||||
|
||||
## 6. 收集触发点一:FileRecognized
|
||||
|
||||
### 6.1 触发条件
|
||||
|
||||
只有识别结果满足以下条件时才产生 `FileRecognized` payload:
|
||||
|
||||
- `mediaType` 已确定为 image、audio 或 video;
|
||||
- 完整文件 SHA-256 已计算;
|
||||
- 本地化和安全检查已完成;
|
||||
- 公共 metadata 可用;
|
||||
- probe 即使降级,也有明确的 `complete`、`partial` 或 `unavailable` 状态。
|
||||
|
||||
`not_media`、`approval_required`、下载失败、hash 失败或无法建立受控引用的候选不写入
|
||||
Memory;它们只进入识别日志或用户可见错误。
|
||||
|
||||
### 6.2 写入内容
|
||||
|
||||
一次成功触发至少登记:
|
||||
|
||||
- 逻辑 `File` 和当前 `FileVersion`;
|
||||
- 来源类型、显式 lineage 和本地化关系;
|
||||
- 完整 SHA-256;
|
||||
- detected/declared/extension MIME 与冲突告警;
|
||||
- 图片、音频或视频技术 metadata;
|
||||
- versioned token estimate、估算方法和实际估算输入;
|
||||
- probe 状态、后端和 resolved ingestion config hash;
|
||||
- 如果是 policy 媒体产物,则登记 parent、root 和 producer invocation。
|
||||
|
||||
### 6.3 普通来源直接提交
|
||||
|
||||
用户输入和普通工具结果没有等待中的 policy 事务。识别成功后可以立即、幂等地提交
|
||||
File 与 FileVersion。相同事件重试不会生成重复版本。
|
||||
|
||||
### 6.4 Policy 媒体产物先暂存
|
||||
|
||||
Policy Tool 可能声明多个必须同时成立的输出。例如一次视频处理同时产生音轨、
|
||||
关键帧和索引文件。不能在第一个媒体完成识别时就把它暴露为可召回节点,否则后续
|
||||
必需产物失败会留下孤儿或半套结果。
|
||||
|
||||
因此,policy-origin 的 `FileRecognized` 结果按 `invocationId` 进入 staging:
|
||||
|
||||
```text
|
||||
原始 Tool artifacts
|
||||
→ 每个媒体 artifact 识别 / hash / probe
|
||||
→ staging[invocationId]
|
||||
→ 全部 descriptor 与文本产物校验
|
||||
→ OmniPolicySucceeded 原子提交
|
||||
```
|
||||
|
||||
staging 不是第三个触发点,也不是持久可召回图;它只是
|
||||
`OmniPolicySucceeded` 事务的准备状态。
|
||||
|
||||
## 7. 收集触发点二:OmniPolicySucceeded
|
||||
|
||||
### 7.1 固定调用与模型调用共用
|
||||
|
||||
所有 policy 都以 Qwen Code Tool 实现;Fixed Policy 与 Transport Guard policy 也
|
||||
通过 `execute` 运行。因此,固定调用、模型 ToolCall 和 direct client 调用在成功后
|
||||
都进入同一个 `OmniPolicySucceeded` 逻辑边界,不为不同调用来源复制 Memory 写入代码。
|
||||
|
||||
### 7.2 成功门槛
|
||||
|
||||
只有同时满足以下条件才允许触发:
|
||||
|
||||
- Tool 返回没有 `error`,也没有取消或 timeout;
|
||||
- descriptor 中所有 required output 已出现;
|
||||
- 所有实际返回 output 都命中 descriptor;
|
||||
- 所有 artifact 都通过 managed/workspace path、文件存在性和权限校验;
|
||||
- 所有媒体 artifact 都已完成识别、完整 hash 和 probe 状态登记;
|
||||
- 所有文本 artifact 都可读取、编码有效且未越过对应资源预算;
|
||||
- 每个 output 的 role、scope、channels、coverage 和 producer 已归一化;
|
||||
- source version、最终参数、execution origin、policy ID、stage 和配置哈希都已确定。
|
||||
|
||||
Policy 文档定义的 delivery `include` 与 `retain` 只决定是否进入当前模型输入,不决定
|
||||
是否进入 Memory。一个成功 invocation 的有效输出都会被登记;`source: omit` 也只
|
||||
影响投递,不删除 source 的 Memory。
|
||||
|
||||
### 7.3 原子提交内容
|
||||
|
||||
一次事务同时写入:
|
||||
|
||||
1. `PolicyExecution`;
|
||||
2. source/root/version 关系;
|
||||
3. staging 中已经识别的媒体衍生文件与版本;
|
||||
4. transcript、OCR、caption、summary 等 policy-native 文本结果;
|
||||
5. execution 到全部 output 的 producer edge;
|
||||
6. parent、root、scope、channel、coverage 和配置 provenance。
|
||||
|
||||
任一步失败都不提交本 invocation 的 active Memory。已产生的文件按 Policy 设计进入
|
||||
quarantine/cleanup;失败记录可以进入独立运行日志,但不能伪装成可召回 Memory。
|
||||
|
||||
### 7.4 禁止从模型文本反推产物
|
||||
|
||||
以下内容不能成为 `OmniPolicySucceeded` 的输出来源:
|
||||
|
||||
- `llmContent` 中提到的路径、URL 或摘要;
|
||||
- `returnDisplay` 中的 Markdown;
|
||||
- PostToolUse hook 追加的 artifact;
|
||||
- 对任意返回对象进行递归扫描发现的字段。
|
||||
|
||||
Memory 只消费 Policy 文档定义的原始 `PolicyArtifactBatch`。这样能证明 producer,
|
||||
也不会把 hook 或 UI 产物错误关联到 MediaPolicyTool。
|
||||
|
||||
## 8. 图关系与查询边界
|
||||
|
||||
初版需要支持以下关系:
|
||||
|
||||
| 关系 | 含义 |
|
||||
| ----------------- | -------------------------------------- |
|
||||
| `HAS_VERSION` | File 拥有不可变 FileVersion |
|
||||
| `CURRENT_VERSION` | File 当前默认召回的版本 |
|
||||
| `DERIVED_FROM` | 衍生媒体版本来自某个父版本 |
|
||||
| `PRODUCED_BY` | 衍生媒体或 PolicyResult 由某次执行产生 |
|
||||
| `EXECUTED_ON` | PolicyExecution 的输入文件版本 |
|
||||
| `HAS_OUTPUT` | PolicyExecution 的结构化输出 |
|
||||
|
||||
所有遍历必须被 `rootFileId` 限制。查询一个文件可以沿 lineage 查看它的祖先、后代、
|
||||
执行和结果,但不能自动跳到另一个 root graph。相同 hash 的缓存映射不是图关系。
|
||||
|
||||
## 9. Recall 设计
|
||||
|
||||
### 9.1 一个 Recall Service,两种触发方式
|
||||
|
||||
主动与被动召回共享:
|
||||
|
||||
- 同一个项目作用域和权限检查;
|
||||
- 同一个 file/version 解析器;
|
||||
- 同一个查询、过滤和预算逻辑;
|
||||
- 同一个 `MediaMemoryRecallResult`;
|
||||
- 同一个 current-version 默认规则;
|
||||
- 同一个持久 fileVersion 到 session resourceId 的绑定器。
|
||||
|
||||
两者的差异仅在于谁发起查询:
|
||||
|
||||
- `active`:Agent 调用 `omni_recall_media_memory` Tool;
|
||||
- `sideQuery`:Harness 在构造相关模型请求前运行 side query,自动选择已有记录。
|
||||
|
||||
配置 `mode` 用于实验切换。`active` 模式不自动注入,`sideQuery` 模式不向模型暴露
|
||||
主动召回 Tool,避免一次实验同时混入两种策略。
|
||||
|
||||
### 9.2 主动召回
|
||||
|
||||
主动 Tool 最小输入包括:
|
||||
|
||||
```ts
|
||||
interface MediaMemoryRecallRequest {
|
||||
resourceIds: string[];
|
||||
query: string;
|
||||
kinds?: Array<'metadata' | 'derived_media' | 'policy_result' | 'execution'>;
|
||||
roles?: string[];
|
||||
scope?: MediaScope;
|
||||
includeHistoricalVersions?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`resourceIds` 必须属于当前 session 已授权资源。服务先解析为持久 file/version,再只在
|
||||
对应 root graph 内查询。模型不能用任意路径、hash 或 fileId 扫描整个项目。
|
||||
|
||||
召回返回的可用媒体衍生物会绑定出当前 session 的 `resourceId`,Agent 随后可把它传给
|
||||
另一个 MediaPolicyTool。召回本身不复制文件、不创建新 File,也不触发 policy。
|
||||
|
||||
#### 9.2.1 "按路径/内容 hash 查询"的一致解读
|
||||
|
||||
issue #8188 同一条验收项里写了"按路径/内容 hash 查询"与"返回中永不暴露真实本地路径"。
|
||||
按本设计 §5.2/§15 的路径隔离不变量,二者的一致解读是**按调用方分层**:
|
||||
|
||||
| 调用方 | 允许的入口 | 理由 |
|
||||
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 模型(active Tool / passive selector) | 仅 session `resourceId` | 句柄是不可伪造的能力凭证:模型只能问本会话真的投递给它的媒体。放开路径入参会让媒体文件里的注入内容驱使模型探测任意路径,而召回返回的是转写正文——典型 confused-deputy 提权;即使 miss,"有/无记忆"本身也是存在性泄露 |
|
||||
| Harness(受管管线内部) | 路径 / 内容 hash | 不经模型,无提权面。已由 `findBindingBySha256` 提供并被反应式降质阶梯使用 |
|
||||
|
||||
因此"系统能按路径/hash 找到记忆"这一能力是满足的,只是不向模型暴露。
|
||||
|
||||
代价与已知缺口:句柄只在投递时签发,而投递要求文件存在——所以在新 session 中,被
|
||||
删除或移动的文件无法再获得句柄,其记忆暂时不可达。补法不是放开模型的路径入参,而是
|
||||
在**授权层**补:让 `@` 引用一个"记忆里有、磁盘上没了"的文件也能凭记录的
|
||||
`fileRef`+`sha256` 签发句柄(用户的 `@` 引用即授权)。留作后续阶段。
|
||||
|
||||
### 9.3 被动 side-query 召回
|
||||
|
||||
被动召回只处理当前请求已经显式引用并完成识别的文件。它不从普通文本猜路径,也不
|
||||
跨文件搜索项目中的其他媒体。
|
||||
|
||||
流程为:
|
||||
|
||||
```text
|
||||
当前请求的显式 resourceIds
|
||||
→ 解析为当前 FileVersion
|
||||
→ 从对应文件内图生成有界 candidate manifest
|
||||
→ runSideQuery(JSON schema)
|
||||
→ side query 只返回 candidate entryId
|
||||
→ 校验 ID 必须属于 manifest
|
||||
→ Harness 按统一协议物化结果并注入主模型请求
|
||||
```
|
||||
|
||||
candidate manifest 可以包含 entry ID、kind、role、scope、channels、coverage、简短
|
||||
description 和 producer 摘要,但不把原始媒体、完整大文本、本地路径或密钥发送给
|
||||
selector。
|
||||
|
||||
side query 不允许返回自由文本结论,也不能要求创建新条目。未知 ID、跨 root ID、
|
||||
超出 `maxSelectedEntries` 或 schema 不合法的结果整体拒绝。
|
||||
|
||||
为了保证实验条件确定,选中结果必须在相关主模型请求发送前完成注入;不能在模型已
|
||||
开始回答后再把迟到结果塞进后续 ToolResult turn。超时或 selector 失败时本次按空召回
|
||||
继续,并记录明确的失败原因和配置哈希。
|
||||
|
||||
### 9.4 最小返回协议
|
||||
|
||||
```ts
|
||||
interface MediaMemoryRecallResult {
|
||||
status: 'hit' | 'partial' | 'miss';
|
||||
files: Array<{
|
||||
fileId: MediaFileId;
|
||||
fileVersionId: MediaFileVersionId;
|
||||
current: boolean;
|
||||
mediaType: 'image' | 'audio' | 'video';
|
||||
}>;
|
||||
entries: Array<{
|
||||
entryId: MediaMemoryEntryId;
|
||||
kind: 'metadata' | 'derived_media' | 'policy_result' | 'execution';
|
||||
role?: string;
|
||||
content?: string;
|
||||
/** content 是 memory 所存文本的前缀时为 true(被 recall.maxTextChars 截断)。
|
||||
* coverage 讲的是"处理过什么",所以一条被截断的转写仍合法地报 complete;
|
||||
* 没有这个标记,模型看到前缀 + 完整覆盖 + 无 gap,就会对它从未读到的后半段
|
||||
* 音频作答。默认值本身就会撞上:maxTextChars(24000 字符)小于收集期界
|
||||
* (65536 字节),任何长转写都在读取期被切。 */
|
||||
contentTruncated?: boolean;
|
||||
resourceId?: string;
|
||||
scope: MediaScope;
|
||||
channels: MediaChannel[];
|
||||
coverage: MediaCoverage;
|
||||
evidenceRefs: Array<{
|
||||
fileVersionId: MediaFileVersionId;
|
||||
executionId?: PolicyExecutionId;
|
||||
}>;
|
||||
provenance: {
|
||||
toolName?: string;
|
||||
toolVersion?: string;
|
||||
policyId?: string;
|
||||
stage?: 'preprocessing' | 'transport_guard';
|
||||
omniConfigHash: string;
|
||||
};
|
||||
}>;
|
||||
gaps: Array<{
|
||||
scope: MediaScope;
|
||||
channels: MediaChannel[];
|
||||
reason: 'not_processed' | 'partial_coverage' | 'artifact_unavailable';
|
||||
}>;
|
||||
nextPolicyActions?: Array<{
|
||||
toolName: string;
|
||||
resourceId: string;
|
||||
arguments: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
/** 匹配总数,仅当条目预算把列表截短时出现。没有它,一页被截断的结果与穷尽结果
|
||||
* 无法区分:真实审计在 limit: 12 下读到 6 段 clip,就得出"从未抽过关键帧",
|
||||
* 而库里有 72 条——读的人没有说谎,它只是无从知道自己看到的是一页。 */
|
||||
matchedEntries?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`partial` 表示已有相关结果,但请求范围或 channel 仍存在缺口。`nextPolicyActions` 只
|
||||
提供可执行建议,由 Agent 决定是否调用;side query 和 Recall Service 都不能自动执行
|
||||
这些 Tool。
|
||||
|
||||
### 9.5 Current-version-first
|
||||
|
||||
默认查询只返回当前 `FileVersion` 及其派生图。历史版本记录可以为 provenance 保留,
|
||||
但只有主动请求或配置允许时才进入候选。
|
||||
|
||||
如果当前版本没有结果、旧版本有结果,默认返回 `miss` 或带明确历史提示的 `partial`,
|
||||
不能把旧结果当成当前文件事实。这样文件更新后不会静默复用失效 transcript、OCR、
|
||||
关键帧或 summary。
|
||||
|
||||
## 10. 顶层 Omni 配置
|
||||
|
||||
Memory 配置直接放在现有实验配置的 `omni.memory` 下,不并入 Qwen Code 当前的
|
||||
managed auto-memory 配置,也不增加独立配置文件、环境变量或 CLI flag。初版没有
|
||||
`omni.enabled` 或 `memory.enabled`;收集始终按两个确定触发点执行,默认 recall 模式
|
||||
为 `active`。
|
||||
|
||||
以下 JSONC 展示初版需要的细粒度实验控制。每个字段都在 scope 合并、默认补齐和
|
||||
校验后进入 resolved Omni config hash。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Omni 实验配置的唯一顶层入口;不增加总开关。
|
||||
"omni": {
|
||||
// 配置结构版本,用于记录实验并兼容后续 schema 调整。
|
||||
"schemaVersion": 1,
|
||||
|
||||
// 文件级多模态 Memory 的收集与召回配置。
|
||||
"memory": {
|
||||
// 两个固定收集触发点共用的持久内容边界。
|
||||
"collection": {
|
||||
// 文本不超过该字节数时可直接存入 PolicyResult;更大的文本必须由
|
||||
// policy 产出受管 text artifact,不能静默截断后保存。
|
||||
"maxInlineTextBytes": 65536,
|
||||
},
|
||||
|
||||
// 控制已收集记录如何暴露给模型,不影响底层事实收集。
|
||||
"recall": {
|
||||
// active 由 Agent 调用 Recall Tool;sideQuery 由 Harness 自动选择并注入。
|
||||
"mode": "active",
|
||||
|
||||
// 单次统一 RecallResult 最多返回的 entry 数量。
|
||||
"maxEntries": 12,
|
||||
|
||||
// 单次返回给主模型的文本内容总字符预算;超过时按确定顺序裁剪并报告 gap。
|
||||
"maxTextChars": 24000,
|
||||
|
||||
// 默认允许召回的节点类型;高优先级 settings scope 对该数组整体替换。
|
||||
"kinds": ["metadata", "derived_media", "policy_result", "execution"],
|
||||
|
||||
// false 表示默认只召回当前文件版本;true 用于显式历史对照实验。
|
||||
"includeHistoricalVersions": false,
|
||||
|
||||
// Agent 主动调用 Recall Tool 时的额外资源边界。
|
||||
"active": {
|
||||
// 一次 ToolCall 最多查询多少个当前 session 资源,防止无界批量扫描。
|
||||
"maxFilesPerCall": 8,
|
||||
},
|
||||
|
||||
// mode 为 sideQuery 时使用的 selector 参数。
|
||||
"sideQuery": {
|
||||
// null 表示沿用 runSideQuery 的 fast-model 优先选择;字符串可固定实验模型。
|
||||
"model": null,
|
||||
|
||||
// selector 必须在该时间内返回;超时后本次空召回并继续主请求。
|
||||
"timeoutMs": 30000,
|
||||
|
||||
// 发送给 selector 的候选 entry 上限;候选仍只来自当前显式文件。
|
||||
"maxCandidateEntries": 100,
|
||||
|
||||
// selector 最多可以返回的合法 entryId 数量,且不能大于 maxEntries。
|
||||
"maxSelectedEntries": 12,
|
||||
|
||||
// side query 的最大请求尝试次数;1 最便于控制实验成本和延迟。
|
||||
"maxAttempts": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 10.1 配置校验
|
||||
|
||||
启动时至少校验:
|
||||
|
||||
- `mode` 只能为 `active` 或 `sideQuery`;
|
||||
- 所有数量、文本和 timeout 预算必须为正数;
|
||||
- `maxSelectedEntries <= maxEntries <= maxCandidateEntries`;
|
||||
- `kinds` 只能包含已知节点类型且不能为空;
|
||||
- `model` 为 `null` 或非空字符串;
|
||||
- 未信任 workspace 的 Omni 配置不参与合并;
|
||||
- 数组整体替换,不做 concat 或 union。
|
||||
|
||||
与前两篇设计一致,scope 优先级为
|
||||
`SystemDefaults < User < trusted Workspace < System`。无效配置启动失败,不静默回退到
|
||||
另一种召回模式。
|
||||
|
||||
## 11. 版本、失效与复用
|
||||
|
||||
### 11.1 文件变化
|
||||
|
||||
同一逻辑文件得到不同完整 SHA-256 时:
|
||||
|
||||
1. 创建新的不可变 `FileVersion`;
|
||||
2. 更新 `CURRENT_VERSION`;
|
||||
3. 旧版本及其执行和产物保留为历史 provenance;
|
||||
4. 默认 recall 排除旧版本;
|
||||
5. 新版本必须重新运行需要的 policy,不能只按路径或名称复用旧结果。
|
||||
|
||||
### 11.2 不合并不同文件
|
||||
|
||||
两个独立 `fileId` 即使 SHA-256 一致,也保留两个 File 和两套来源。允许复用的是:
|
||||
|
||||
- sniff/probe/hash 的计算缓存;
|
||||
- 确定性 policy 结果及其底层 artifact;
|
||||
- 受管文件的物理存储块。
|
||||
|
||||
复用时每个 File 仍保留自己的 root、source 和 lineage 引用,不能把一个文件的
|
||||
权限或 provenance 泄漏给另一个文件。
|
||||
|
||||
#### 11.2.1 三层身份:为什么"不合并"与"不重复建节点"并不矛盾
|
||||
|
||||
issue #8188 的验收项写作"内容相同的两个文件不重复建节点(复用同一底层身份)",
|
||||
与本节标题字面冲突。二者实际落在**不同层**,实现同时满足:
|
||||
|
||||
| 层 | 键 | 承载 | 满足 |
|
||||
| ----------- | ------------------ | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| Content | `sha256` | 物理对象(`objects/sha256/…`)、可复用的计算结果与 policy 派生物 | #8188「不重复建节点(复用同一底层身份)」 |
|
||||
| File | locator | `CURRENT_VERSION`、source、provenance、权限/workspace 归属 | 本节「不合并不同文件」、#8189 版本隔离 |
|
||||
| FileVersion | `(fileId, sha256)` | 二者的连接,不可变 | 11.1 版本链 |
|
||||
|
||||
关键约束:**身份键不能是内容**。若 `fileId = hash(content)`,则"内容变化"直接
|
||||
产生另一个 `fileId`,11.1 的版本链(新 FileVersion → 更新 `CURRENT_VERSION` →
|
||||
旧版本留作历史 → 默认排除旧版本)与 #8189 验收项"修改文件内容后旧衍生物不默认
|
||||
召回"**都无法表达**——编辑文件会凭空出现无关节点,改回去则历史成环。因此逻辑文件
|
||||
必须由跨内容稳定的 locator 定键。
|
||||
|
||||
于是"不重复付费"由 content 层承担:同字节的第二个文件不重跑任何工具(见 11.3 的
|
||||
复用键与 `reusedExecutionId`),不重复存储字节(共享同一 `objects/` 对象),只额外
|
||||
写几行属于自己的廉价元数据行。
|
||||
|
||||
派生物同样按 `(rootFileId, objectPath)` 定键而非仅 `objectPath`:两个 root 派生出
|
||||
逐字节相同的产物时,若共享一个 File 节点,该节点的 `rootFileId` 只能属于先创建者,
|
||||
既泄漏 lineage 又让第二个 root 的有界遍历(§8)够不到它。
|
||||
|
||||
### 11.3 Policy 结果复用键
|
||||
|
||||
可复用 policy 结果的复用键必须与 11.2 的跨文件复用语义一致,因此以**内容身份**
|
||||
而不是 File 作用域的 version ID 为基础:
|
||||
|
||||
```text
|
||||
source content sha256
|
||||
+ tool name and implementation version
|
||||
+ tool settings hash(影响结果的 Tool 级配置)
|
||||
+ schema 校验后的 final arguments
|
||||
+ input scope
|
||||
+ resolved Omni config hash 中影响结果的部分
|
||||
```
|
||||
|
||||
`fileVersionId` 是 File 作用域的主键,两个独立 File 的 version ID 必然不同;如果
|
||||
把它放进复用键,11.2 允许的跨文件确定性复用将永远无法命中。以 sha256 为键则
|
||||
同一份字节在任何 File 中出现都能复用底层计算,同时每个 File 仍写入自己的
|
||||
PolicyExecution 与 provenance(记录 cache hit 与 `reusedExecutionId`),不共享
|
||||
图节点。
|
||||
|
||||
相同 `invocationId` 重放必须幂等;独立 cache hit 可以记录一次明确的
|
||||
`reusedExecutionId`/cache-hit 运行事实,但不能复制 FileVersion 或 PolicyResult
|
||||
节点。精确 cache key 的字段裁剪在实现计划中确定,不能牺牲上述 provenance。
|
||||
|
||||
## 12. 事务与失败语义
|
||||
|
||||
| 场景 | Memory 行为 |
|
||||
| ------------------------------------------- | ------------------------------------------------------ |
|
||||
| 普通文件识别成功 | 幂等提交 File/FileVersion |
|
||||
| probe 降级但类型、hash 和基础 metadata 可用 | 提交并记录 partial/unavailable |
|
||||
| 下载拒绝、识别失败或非媒体 | 不写 Memory,只记录运行状态 |
|
||||
| Policy Tool 返回 error、timeout 或取消 | 不触发 `OmniPolicySucceeded` |
|
||||
| required output 缺失 | 整个 invocation 不提交 |
|
||||
| 返回 artifact 未命中 descriptor | 整个 invocation 不提交 |
|
||||
| 某个媒体 artifact 识别失败 | 丢弃该 invocation 的全部 staging 结果 |
|
||||
| 文本 artifact 无法读取或编码非法 | 整个 invocation 不提交 |
|
||||
| policy-origin staging 完成但进程中断 | 恢复时清理或重放,不对 recall 可见 |
|
||||
| 相同事件重复投递 | 通过 event/invocation ID 幂等,不重复建节点 |
|
||||
| side query 超时或返回非法 ID | 本次空召回,主请求继续并记录原因 |
|
||||
| 主动 Recall Tool 参数或权限非法 | Tool 返回结构化错误,不扩大查询范围 |
|
||||
| backing artifact 已清理或不可读 | 返回 `artifact_unavailable` gap,不返回失效 resourceId |
|
||||
|
||||
## 13. 存储与作用域要求
|
||||
|
||||
初版逻辑 store 必须满足:
|
||||
|
||||
- 以 project/workspace 为隔离边界,不跨 workspace 自动召回;
|
||||
- 对 `FileRecognized` 提供幂等 upsert;
|
||||
- 对 `OmniPolicySucceeded` 提供多记录原子事务;
|
||||
- 支持按 `fileId`、current `fileVersionId`、root、role、scope 和 channel 查询;
|
||||
- active graph 记录不能引用 staging 或 quarantined artifact;
|
||||
- 持久引用与真实路径分离,返回协议不泄漏受管目录;
|
||||
- artifact 生命周期不得早于仍引用它的 active Memory 记录;
|
||||
- 可以保留历史版本,但默认索引必须把它们与 current version 分开。
|
||||
|
||||
本文不提前选择 SQLite、JSONL 或其他后端。后端选择必须证明上述事务、索引和恢复
|
||||
语义,而不是反过来改变已确定的数据边界。受管文件的物理目录布局、staging 与
|
||||
quarantine 的落盘形式、保留周期和垃圾回收由
|
||||
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md) 定义;本节的
|
||||
"artifact 生命周期不得早于 active Memory 记录"是该设计必须满足的强约束。
|
||||
|
||||
## 14. 与当前 Qwen Code 源码的对应关系
|
||||
|
||||
本设计是现有 Qwen Code 能力上的增量开发:
|
||||
|
||||
- 文件识别设计新增的 `MediaRecognitionService` 在成功结果形成后调用
|
||||
`recordFileRecognized`;policy-origin 结果携带 invocation ID 进入 staging;
|
||||
- Policy 设计新增的 `PolicyArtifactBatch` 是 `OmniPolicySucceeded` 唯一产物入口,
|
||||
不能消费合并后的 hook artifacts;
|
||||
- `packages/core/src/tools/tools.ts` 当前 `ToolArtifact` 继续承载文件、URL、managed
|
||||
ID、MIME、大小和 metadata;Memory bridge 在 descriptor 约束下把它归一为
|
||||
`NormalizedPolicyOutput`;
|
||||
- `packages/core/src/core/turn.ts` 当前 `ToolCallResponseInfo.artifacts` 只有合并后语义,
|
||||
继续按 Policy 设计增加仅供内部使用的 `policyArtifacts` channel;
|
||||
- `packages/core/src/core/coreToolScheduler.ts` 在原始 Tool 成功、hook artifact 合并前
|
||||
捕获 policy 输出;固定调用和模型调用都复用这一成功边界;
|
||||
- `packages/cli/src/acp-integration/session/Session.ts` 在 ACP 自己的 Tool executor 中
|
||||
捕获相同 batch,再调用同一个 core Memory service;
|
||||
- `packages/core/src/utils/sideQuery.ts#runSideQuery()` 已支持 JSON schema、超时信号、
|
||||
model override 和结果校验,可以直接作为 passive selector 基础;
|
||||
- `packages/core/src/memory/relevanceSelector.ts` 已验证“给模型有界 manifest、只允许
|
||||
返回 manifest 内 ID”的 selector 模式,可以复用该约束方式,但不能复用其 Markdown
|
||||
文件数据模型;
|
||||
- `packages/core/src/core/client.ts` 当前 managed auto-memory 已有 recall 取消、请求
|
||||
构造和 prompt 注入经验;媒体 side query 复用生命周期原则,但必须在关联主请求发送
|
||||
前完成,不能采用迟到后再注入 ToolResult turn 的机会式语义;
|
||||
- `packages/cli/src/config/settingsSchema.ts`、`packages/cli/src/config/config.ts` 与
|
||||
`packages/core/src/config/config.ts` 继续加载顶层 `omni.memory` 并提供不可变 getter;
|
||||
- 当前 `packages/core/src/memory/` 的 managed auto-memory 以 Markdown、Agent
|
||||
extraction/dream 和用户/项目主题文件为核心,不作为结构化 media graph 的存储格式,
|
||||
也不参与本设计写入。
|
||||
|
||||
建议把新增能力作为一个 Qwen Code Core service 放在:
|
||||
|
||||
```text
|
||||
packages/core/src/services/media-memory/
|
||||
```
|
||||
|
||||
目录内部可以包含 types、store、collector 和 recall,但对外只暴露一个
|
||||
`MediaMemoryService` 以及 Recall Tool 所需的类型。它不是新的 daemon、MCP server、
|
||||
进程、插件协议或独立配置系统。
|
||||
|
||||
## 15. 安全与权限
|
||||
|
||||
- 写入者只接受识别服务和 policy orchestrator 的 typed payload;
|
||||
- Agent 与 side query 都没有写接口;
|
||||
- active recall 只接受当前 session 已授权的 `resourceId`;
|
||||
- side query 只处理当前请求显式引用的文件;
|
||||
- project/workspace 隔离、workspace trust 和现有文件权限继续生效;
|
||||
- 不向模型、selector、日志或 telemetry 暴露鉴权 header、token 或受管本地路径;
|
||||
- 历史版本、quarantine 和已清理 artifact 不自动重新绑定为 session resource;
|
||||
- 同 hash 缓存复用不能绕过来源权限和 workspace 隔离。
|
||||
|
||||
## 16. Observability 与实验复现
|
||||
|
||||
每次收集至少记录:
|
||||
|
||||
- 触发类型、event/invocation ID 和幂等结果;
|
||||
- file/root/version ID 与 hash 的脱敏引用;
|
||||
- source、parent、producer、tool、policy 和 execution origin;
|
||||
- Tool 版本、final arguments hash、scope、channels 和 coverage;
|
||||
- ingestion/processing/memory resolved config hash;
|
||||
- staging、提交、回滚、cache hit 和 artifact availability 状态;
|
||||
- 耗时、节点数、输出数和错误类别。
|
||||
|
||||
每次召回至少记录:
|
||||
|
||||
- mode、请求文件数、候选数、选中数、返回数;
|
||||
- hit/partial/miss、gap 类型和历史版本过滤数量;
|
||||
- selector model、timeout、attempts、耗时和失败原因;
|
||||
- 注入文本字符数与绑定出的 session resource 数;
|
||||
- resolved Omni config hash。
|
||||
|
||||
Telemetry 默认不记录原始媒体、transcript/OCR 正文、真实路径或完整 Tool 参数值;需要
|
||||
复现时使用 hash 和本地结构化执行记录关联。
|
||||
|
||||
## 17. 验收标准
|
||||
|
||||
### 17.1 收集边界
|
||||
|
||||
- 用户、普通 Tool、URL 和 inline 媒体识别成功后只通过 `FileRecognized` 入库;
|
||||
- 固定与模型 policy 成功后只通过 `OmniPolicySucceeded` 入库;
|
||||
- 不存在 `MediaTurnCompleted`、Agent write Tool 或 final-answer extraction 写路径;
|
||||
- `llmContent`、`returnDisplay` 和普通文本中的路径不会被解析为 Memory 产物;
|
||||
- `/learn` 不出现在实现接入点中。
|
||||
|
||||
### 17.2 文件与版本
|
||||
|
||||
- 同一显式 fileRef、相同完整 SHA-256 重复识别不会产生重复版本;
|
||||
- 同一 fileRef 内容改变后创建新版本,默认召回不返回旧结果;
|
||||
- 两个独立来源即使完整 SHA-256 相同,也保留两个 file/root graph;
|
||||
- URL 到受管本地文件的本地化 lineage 保持同一个 fileId;
|
||||
- 名称、大小、mtime、ETag 或部分 hash 相同不能合并版本。
|
||||
|
||||
### 17.3 Policy 原子性
|
||||
|
||||
- 多输出 policy 只有全部 required/returned artifacts 校验通过才一次性入图;
|
||||
- 任一媒体识别或文本读取失败时,不出现部分衍生物或孤立执行节点;
|
||||
- include、retain 和 source omit 不改变事实收集结果;
|
||||
- PostToolUse hook artifact 不会被登记为 policy 自身输出;
|
||||
- 相同 invocation 重放幂等,cache hit 不复制结果节点。
|
||||
|
||||
### 17.4 主动召回
|
||||
|
||||
- `active` 模式只暴露 Recall Tool,不自动运行 side query;
|
||||
- Tool 只能查询当前 session resource 对应的文件内图;
|
||||
- 可用衍生媒体返回新的或复用的 session resourceId,不返回真实路径;
|
||||
- status、entries、gaps、coverage、evidence 和 provenance 字段完整;
|
||||
- Agent 不能通过 Recall Tool 写入或修改 Memory。
|
||||
|
||||
### 17.5 被动召回
|
||||
|
||||
- `sideQuery` 模式不暴露主动 Recall Tool;
|
||||
- 候选只来自当前请求显式引用文件的 current-version graph;
|
||||
- selector 只能返回 manifest 中的 entryId,非法或跨 root ID 整体拒绝;
|
||||
- selector 不接收原始媒体、大文本或本地路径;
|
||||
- 结果在相关主请求前注入;timeout 时空召回且不阻断主请求;
|
||||
- side query 不创建任何新 File、PolicyResult 或 Agent conclusion。
|
||||
|
||||
### 17.6 配置与隔离
|
||||
|
||||
- `omni.memory` 没有 enabled 总开关;
|
||||
- mode、预算、数组替换和 workspace trust 合并行为可验证;
|
||||
- 不同 project/workspace 的 Memory 不会互相召回;
|
||||
- 相同输入、配置、Tool 版本和参数可以从记录完整复现收集与召回条件。
|
||||
|
||||
## 18. 实现阶段仍需确定的细节
|
||||
|
||||
以下是实现方案选择,不改变本文已经确定的需求边界:
|
||||
|
||||
1. `File`、`FileVersion`、`PolicyExecution` 和 `PolicyResult` 的精确持久 schema 与
|
||||
schema migration 方式;
|
||||
2. `NormalizedPolicyOutput.role` 的初版完整枚举及自定义 role 命名规则;
|
||||
3. store 后端、索引布局、事务和崩溃恢复实现;
|
||||
4. side-query candidate 的确定性预排序、description 生成和 token 预算;
|
||||
5. 历史版本、孤立受管文件、quarantine 与 cache 的 retention/GC;
|
||||
6. cache hit 是否单独保存轻量 execution 记录,以及 provenance 的精确引用方式。
|
||||
|
||||
这些选择不得改变已经确认的约束:**只有 FileRecognized 与
|
||||
OmniPolicySucceeded 两个收集触发点、Harness 独占写入、文件内图、完整 SHA-256
|
||||
版本判断、policy 原子提交、active/sideQuery 共用只读协议,以及默认只召回当前
|
||||
版本**。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,281 @@
|
||||
# Omni 受管媒体存储设计
|
||||
|
||||
## 状态
|
||||
|
||||
- 状态:Draft
|
||||
- 范围:Qwen Code 实验分支中 Omni 多模态资源与中间产物的物理存储管理
|
||||
- 关联设计:
|
||||
[多模态文件识别与元数据提取架构设计](./2026-07-29-multimodal-file-recognition-and-metadata.md)、
|
||||
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
|
||||
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)
|
||||
|
||||
## 1. 背景
|
||||
|
||||
前三篇设计反复引用"受管本地文件 / Omni 管理目录 / managed storage",但没有任何
|
||||
一篇定义它的物理形态。它至少要同时承载四类文件:
|
||||
|
||||
1. URL 下载与 inline 解码的本地化结果(识别设计 §6.1、§7);
|
||||
2. Policy Tool 的衍生产物:音轨、切片、关键帧、降采样图、转写文本(Policy 设计
|
||||
§6.2);
|
||||
3. Policy 执行失败后被隔离的 quarantined 产物(Policy 设计 §9);
|
||||
4. 被 Memory active 记录长期引用、需要跨 session 存活的衍生物(Memory 设计
|
||||
§13:_artifact 生命周期不得早于仍引用它的 active Memory 记录_)。
|
||||
|
||||
第 4 条决定了这不能是一个"会话结束即清理的临时目录":Memory 是跨 session 的,
|
||||
它引用的媒体文件也必须跨 session 存活。本设计定义目录布局、写入协议、生命周期
|
||||
与垃圾回收,作为其余三篇的公共物理底座。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 为四类文件定义统一的、按 project 隔离的目录布局;
|
||||
- 用内容寻址(SHA-256)保证对象不可变、天然去重,与 Memory 设计 11.3 的
|
||||
content-hash 复用键对齐;
|
||||
- 定义 staging → 提交 / 隔离的原子写入协议,与 `OmniPolicySucceeded` 的事务
|
||||
边界一致;
|
||||
- 定义崩溃恢复:启动时清理半成品,不留下 Memory 可见的孤儿引用;
|
||||
- 定义 GC:以 Memory active 记录为根的 mark-and-sweep,加保留期与容量预算;
|
||||
- 配置进入 `omni.storage`,纳入 resolved Omni config hash。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不设计 Memory 图数据本身的存储后端(Memory 设计 §13 单独决定);
|
||||
- 不改变现有 session artifact store 的职责(它仍服务 UI metadata);
|
||||
- 不提供跨 project 共享、远端同步或多机存储;
|
||||
- 不做加密存储;实验分支按本地开发机信任模型处理。
|
||||
|
||||
## 3. 位置与隔离
|
||||
|
||||
存储根目录固定为 project 作用域:
|
||||
|
||||
```text
|
||||
<projectRoot>/.qwen/omni/
|
||||
```
|
||||
|
||||
- 与 Memory 设计 §13 的"以 project/workspace 为隔离边界"一致:不同 project 的
|
||||
对象、staging 与 quarantine 互不可见,GC 也只在本 project 内运行;
|
||||
- 目录创建时写入自忽略文件(`.qwen/omni/.gitignore` 内容为 `*`),保证媒体
|
||||
对象不会进入版本控制;
|
||||
- Omni 写入链路只在 trusted workspace 激活;未信任 workspace 中不创建该目录,
|
||||
也不执行任何 fixed policy(与 Policy 设计 §12.1 的 trust 合并语义一致);
|
||||
- 目录权限 `0700`;内部只允许普通文件与目录,发现 symlink 一律拒绝并告警。
|
||||
|
||||
## 4. 目录布局
|
||||
|
||||
```text
|
||||
.qwen/omni/
|
||||
├── .gitignore # 内容为 *,自忽略
|
||||
├── objects/ # 内容寻址对象库(不可变,已提交内容的唯一存放地)
|
||||
│ └── sha256/
|
||||
│ └── ab/
|
||||
│ └── ab3f…e9.mp4 # <sha256 前 2 位>/<完整 sha256>.<按检测 MIME 的扩展名>
|
||||
├── downloads/ # URL 本地化进行中的 .part 文件
|
||||
│ └── <downloadId>.part
|
||||
├── staging/ # policy invocation 的工作目录(提交前)
|
||||
│ └── <invocationId>/
|
||||
│ ├── out-audio.wav
|
||||
│ └── out-keyframe-01.jpg
|
||||
└── quarantine/ # 失败 invocation 被隔离的产物(可调试、有界)
|
||||
└── <invocationId>/
|
||||
```
|
||||
|
||||
### 4.1 objects:内容寻址对象库
|
||||
|
||||
- 对象以完整内容 SHA-256 命名,写入后不可变;扩展名取自统一识别的
|
||||
`detectedMimeType`,便于 ffmpeg/ffprobe 与人工调试直接识别;
|
||||
- 同一份字节在任何来源出现(两个 File 内容相同、policy 结果 cache hit)都只
|
||||
存一份物理对象——这正是 Memory 设计 11.2/11.3"跨 File 复用底层 artifact、
|
||||
不合并图节点"的物理实现:File/lineage/provenance 属于 Memory 图,字节属于
|
||||
对象库;
|
||||
- `ToolArtifact.managedId` 与 `artifactRef.managedId` 即对象键
|
||||
(`sha256:<hash>`),对模型与协议层是 opaque ID,真实路径不外泄;
|
||||
- 对象写入协议:先写同目录 `.tmp` 文件,流式计算 hash,完成后原子 rename 到
|
||||
最终 hash 路径;目标已存在(去重命中)时直接丢弃 `.tmp`。
|
||||
|
||||
### 4.2 downloads:URL 本地化
|
||||
|
||||
- 识别设计 §7 的 `.part` 文件统一放在此处,`downloadId` 为随机标识;
|
||||
- 下载完成、重新 sniff/probe/hash 之后按 4.1 协议晋升到 `objects/`,`.part`
|
||||
删除;
|
||||
- 大小许可(100 MiB 门槛)中断的 `.part` 保留以支持续传,但受 §6 的启动清理
|
||||
与保留期约束。
|
||||
|
||||
### 4.3 staging:policy 工作目录
|
||||
|
||||
- FixedPolicyOrchestrator / Scheduler 在每次 MediaPolicyTool invocation 前创建
|
||||
`staging/<invocationId>/`,并把它作为 Tool 唯一允许的输出目录注入;
|
||||
- Tool 写完产物返回后,产物在 staging 内完成识别、hash 与 descriptor 校验
|
||||
(Memory 设计 §6.4 的 staging 语义);
|
||||
- `OmniPolicySucceeded` 提交顺序固定为:**先把全部产物晋升到 `objects/`,再
|
||||
提交 Memory 事务**。这样 active Memory 记录引用的对象必然已存在;反向顺序
|
||||
可能在崩溃时留下"Memory 指向不存在文件"的坏引用。晋升成功但 Memory 提交
|
||||
失败时留下的是无引用孤儿对象,由 GC 回收,无正确性风险;
|
||||
- 提交或隔离后删除该 invocation 的 staging 目录。
|
||||
|
||||
### 4.4 quarantine:失败隔离
|
||||
|
||||
- invocation 失败(Tool error、产物校验失败、部分 required 缺失)时,整个
|
||||
staging 目录移动到 `quarantine/<invocationId>/`,保留原始文件名与一份
|
||||
`reason.json`(失败原因、tool、policyId、时间)供实验调试;
|
||||
- quarantine 永不被识别、不进入 Memory、不能被召回或重新绑定为 session
|
||||
resource(Memory 设计 §15);
|
||||
- 受独立的保留期与容量预算约束(§7 配置),超限时按最旧优先删除。
|
||||
|
||||
## 5. 写入者与职责边界
|
||||
|
||||
| 写入者 | 允许写入的区域 | 说明 |
|
||||
| ------------------- | -------------------------------------------------------- | ------------------------------- |
|
||||
| 识别服务(本地化) | `downloads/`、`objects/` | URL 下载、inline 解码落盘与晋升 |
|
||||
| Policy Tool | 仅注入的 `staging/<invocationId>/` | 不允许写 objects 或 workspace |
|
||||
| Orchestrator/bridge | `objects/`、`quarantine/` | 晋升与隔离,唯一的状态迁移者 |
|
||||
| Memory / Recall | 只读 `objects/` | 通过 managedId 解析,不写入 |
|
||||
| GC | 删除 `objects/`、`downloads/`、`staging/`、`quarantine/` | 见 §6 |
|
||||
|
||||
用户原始输入的本地文件**不复制进对象库**:它们留在原位置,Memory 以
|
||||
`localPath + sha256` 记录身份(识别设计 §6.5)。只有"Qwen Code 自己生成或
|
||||
下载的字节"才进入 `objects/`。这避免大视频在磁盘上双份存放;代价是用户文件
|
||||
被移动/删除后 Recall 返回 `artifact_unavailable` gap,这是 Memory 设计 §12 已
|
||||
定义的正常路径。
|
||||
|
||||
## 6. 生命周期与垃圾回收
|
||||
|
||||
### 6.1 启动恢复(已实现,`omni/recovery.ts`)
|
||||
|
||||
每次启动(或首次触碰 Omni 链路时)执行一次恢复扫描。实现相对初稿的修正——
|
||||
多进程共存迫使"删除所有 staging"退化为宽限期语义:
|
||||
|
||||
1. 删除 `staging/` 下**超过宽限期(1h)**的目录——宽限期必须长于策略工具
|
||||
最长超时(配置校验强制 `runtime.timeoutMs` 低于宽限期),否则第二个 CLI
|
||||
进程的恢复扫描会把另一个进程正在转码的工作目录删掉。晋升半成品 `.tmp`
|
||||
同理(1h 宽限);
|
||||
2. 删除超过保留窗口(48h)的 `downloads/*.part`;无续传逻辑,窗口纯为事后
|
||||
检查中断下载留的调试期;
|
||||
3. 按保留天数与容量预算清理 `quarantine/`(超预算最旧优先);
|
||||
4. 抽样校验 `objects/` 对象名与实际 hash 一致(每次至多 3 个、单个 ≤64MB,
|
||||
避免在首次投递前的内联扫描里 hash 多 GB 视频);发现损坏对象删除时**级联
|
||||
清理降质缓存**(`policy-cache.json` 中以它为源或产物的条目),防止缓存
|
||||
命中一个永远无法投递的对象。
|
||||
|
||||
### 6.2 mark-and-sweep GC(S6 落点)
|
||||
|
||||
- **根集合**:Memory store 中全部记录引用的 managedId——`entries[].artifactRef.managedId`
|
||||
与 `versions[].source.locator`(`protocol: 'managed'`,tool/URL 来源媒体的
|
||||
身份锚,见 Memory 设计 §11.2.1 实现注记)两处都算,含仅被 provenance 引用的
|
||||
历史版本对象——加上当前进程 MediaResourceRegistry 正在使用的对象;
|
||||
- **清扫对象**:不在根集合中、且自晋升起超过 `retentionDays` 的对象;保留期
|
||||
兼作宽限期,保证"晋升成功但 Memory 提交失败"的孤儿和跨进程 race 不被立刻
|
||||
误删;
|
||||
- **触发时机**:启动恢复后异步执行;超过 `maxTotalBytes` 时提前触发,仍超限
|
||||
则从最旧的无引用对象继续删除;**有引用对象永不删除**,即使超预算——此时
|
||||
告警并置"停止新衍生物"标志,由 orchestrator 消费(等价于 Policy 设计 §8.4
|
||||
的预算停止语义);
|
||||
- **级联**:对象删除联动清理其 `upload-cache.json` 条目与降质缓存条目
|
||||
(复用恢复扫描已实现的级联,见 §6.1 第 4 条);反向不成立;
|
||||
- GC 与 Memory 的一致性:先从 Memory 快照确认无引用,再删除文件;不存在
|
||||
"先删文件再改记录"的窗口。快照读取失败时 GC 整体跳过(fail-closed:
|
||||
读不到根集合就不删任何东西)。
|
||||
|
||||
## 7. 配置
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"omni": {
|
||||
// 受管媒体存储配置;全部纳入 resolved Omni config hash。
|
||||
"storage": {
|
||||
// 无引用对象自晋升起的保留天数;到期后可被 GC 清扫。
|
||||
"retentionDays": 14,
|
||||
// objects 区域的总容量软预算;超限提前触发 GC 并告警。
|
||||
"maxTotalBytes": 21474836480,
|
||||
// 中断下载 .part 的续传保留窗口(小时),超期启动时清理。
|
||||
"partRetentionHours": 48,
|
||||
// 失败隔离区配置。
|
||||
"quarantine": {
|
||||
// 隔离产物保留天数,供实验调试。
|
||||
"retentionDays": 7,
|
||||
// 隔离区容量上限,超限按最旧优先删除。
|
||||
"maxBytes": 5368709120,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
校验:所有数值必须为正;`maxTotalBytes` 不得小于 transport guard 单媒体上限的
|
||||
10 倍(防止配置出一个装不下正常实验产物的库)。scope 合并沿用 Omni 统一语义。
|
||||
|
||||
## 8. 上传缓存
|
||||
|
||||
投递层统一采用 DashScope 官方临时上传(Policy 设计 §10.3),本存储为其维护
|
||||
上传缓存——对象字节到 48h 有效 oss:// URL 的映射:
|
||||
|
||||
```text
|
||||
uploads: (sha256, model) → {
|
||||
ossUrl: "oss://dashscope-instant/…",
|
||||
uploadedAt: string,
|
||||
expiresAt: string, // uploadedAt + urlTtlHours(默认 47h,对官方 48h 留余量)
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 缓存 key 包含 model:官方文档声明文件与模型绑定(实测更宽松,但按文档保守
|
||||
处理),换模型触发重传;
|
||||
- 缓存 miss 或过期时,upload service 从 `objects/` 读取字节重传,写回新条目;
|
||||
- **oss URL 只存在于上传缓存**,Memory、执行记录与任何持久协议都只引用
|
||||
sha256——URL 是投递缓存,不是身份;
|
||||
- 对象被 GC 删除时,联动删除其全部 uploads 条目;反向不成立(缓存过期不影响
|
||||
对象);
|
||||
- 缓存的持久化形态与 Memory store 同级决定(同一后端或独立轻量表均可),但
|
||||
必须在进程重启后存活——否则每次重启都重传全部媒体;
|
||||
- 上传缓存不参与对象引用计数:uploads 条目不能阻止 GC。
|
||||
|
||||
## 9. 失败语义
|
||||
|
||||
| 场景 | 行为 |
|
||||
| ---------------------------- | ------------------------------------------------------------------------ |
|
||||
| 对象晋升时目标 hash 已存在 | 去重命中,丢弃临时文件,复用现有对象 |
|
||||
| 晋升成功、Memory 提交失败 | 孤儿对象留在 objects,GC 宽限期后回收 |
|
||||
| 晋升中途崩溃 | 残留 `.tmp` 启动时清理;不存在半个可见对象 |
|
||||
| staging 目录在提交前崩溃残留 | 启动时整目录删除,Memory 无引用 |
|
||||
| 对象文件被外部删除/损坏 | Recall 返回 `artifact_unavailable` gap;抽样校验发现损坏时同样降级并告警 |
|
||||
| quarantine 超预算 | 最旧优先删除,只影响调试可见性 |
|
||||
| objects 超预算且全部有引用 | 告警并拒绝新衍生物产生,不删除有引用对象 |
|
||||
| 上传缓存条目指向已 GC 对象 | 条目联动删除;投递时按 miss 处理 |
|
||||
| 缓存未过期但服务端已失效 | 投递收到 Provider 错误后使该条目失效并重传一次;仍失败则 fail closed |
|
||||
| 发现 symlink 或非普通文件 | 拒绝该路径并告警,不跟随 |
|
||||
|
||||
## 10. 与现有机制的关系
|
||||
|
||||
- **session artifact store**(daemon-session-artifacts):继续承担 UI 展示与
|
||||
session 级 metadata,其 session-scoped ID 与本存储无关;Omni artifact 的
|
||||
UI 展示仍可经现有 `artifacts` 通道,但物理文件归本存储管理;
|
||||
- **`ToolArtifact.storage: 'managed'`**:Omni 场景下 `managedId` 语义固定为
|
||||
本存储的对象键;
|
||||
- **workspace 文件**:Policy Tool 不写 workspace;`storage: 'workspace'` 的
|
||||
artifact 仅用于用户显式要求落到工作区的场景,不受本存储 GC 管理;
|
||||
- **Memory store**:引用关系的唯一权威;本存储不维护独立引用计数或 manifest,
|
||||
避免双账本漂移。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- 同一字节内容从两个来源进入,`objects/` 只存一份,两个 File 的 Memory 记录
|
||||
各自完整;
|
||||
- policy 多产物提交是"全部晋升 + 一次 Memory 事务",杀进程后重启不存在
|
||||
Memory 指向缺失文件的引用;
|
||||
- 失败 invocation 的产物完整出现在 `quarantine/` 且带 reason.json,不可被召回;
|
||||
- 启动恢复清理 staging/.tmp/过期 .part,不触碰有引用对象;
|
||||
- GC 只清扫无引用且超保留期的对象;把 Memory 记录删除后对应对象在保留期后
|
||||
被回收;
|
||||
- 超容量预算时告警并停止新衍生物,有引用对象仍在;
|
||||
- `.qwen/omni/` 不进入 git status;未信任 workspace 不创建该目录;
|
||||
- 模型与协议层可见的只有 `sha256:<hash>` 形式的 managedId,任何返回中不出现
|
||||
真实路径。
|
||||
|
||||
## 12. 实现阶段确认的细节
|
||||
|
||||
- 抽样完整性校验的比例与频率;
|
||||
- `downloads/` 续传元数据(ETag/offset)的存放形式;
|
||||
- GC 与多进程并发(同一 project 开多个 Qwen Code 实例)的锁策略——初版可用
|
||||
目录锁 + "GC 只在持锁进程执行";
|
||||
- 对象库是否需要按媒体类型分区统计(实验报表用途)。
|
||||
@@ -0,0 +1,178 @@
|
||||
# Omni 策略工具扩展与 memory 条件命名空间
|
||||
|
||||
## 状态
|
||||
|
||||
- 状态:Implemented(omni-experiment 分支)
|
||||
- 范围:Qwen Code 实验分支 omni harness 的策略工具集与条件编排能力
|
||||
- 前置设计:
|
||||
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
|
||||
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)
|
||||
- 需求来源:`omni-harness-gap-analysis.md`(2026-08-17,对照 omni harness 设计文档
|
||||
与 Qwen-MM-Plugins 插件集的差距分析)
|
||||
- 基线:`omni-experiment`,S1–S6 已合入(`e2e6541f01`)
|
||||
|
||||
## 1. 背景
|
||||
|
||||
差距分析对照 omni harness 设计文档(11 个工具)与 Qwen-MM-Plugins 插件集,
|
||||
确认 6 个设计文档工具已实现、6 个未实现,另有编排层与计费杠杆两类差距。
|
||||
本文档对应差距分析的优先级 1–5(❶–❼ + ❿⓫),即:
|
||||
|
||||
| # | 差距 | 本文对应 |
|
||||
| --- | --------------------------------------------------- | -------- |
|
||||
| ❶ | `omni_clip_image` 策略形态图片裁剪 | §3.1 |
|
||||
| ❷ | `omni_caption_image` VL 图片描述 | §3.2 |
|
||||
| ❸ | `omni_ocr_image` 图片 OCR | §3.2 |
|
||||
| ❹ | `omni_clip_audio` 音频时间裁剪 | §3.1 |
|
||||
| ❺ | `omni_caption_audio` 音频语义描述 | §3.2 |
|
||||
| ❻ | `omni_understand_video_segments` 长视频分段并行理解 | §3.3 |
|
||||
| ❼ | `memory.*` 条件命名空间 | §4 |
|
||||
| ❿ | patch-grid 对齐 smart_resize + token 预算档位 | §5 |
|
||||
| ⓫ | 动态 FPS 均匀采样 + 并行 seek 抽帧 | §5 |
|
||||
|
||||
优先级 6–7(grounding/segmentation、非 PDF 文档 visualize、video-memory 内容图、
|
||||
视频/语音生成、image_search、OSS 远程读取)按差距分析"视产品定位排期"不纳入
|
||||
本次范围。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### D1 模型调用类工具共用一个请求辅助模块
|
||||
|
||||
`omni_caption_image` / `omni_ocr_image` / `omni_caption_audio` /
|
||||
`omni_understand_video_segments` 四个工具的远端调用同构:OpenAI 兼容
|
||||
chat.completions、媒体 content part(image_url / video_url / input_audio
|
||||
data URI)+ 指令文本、SSE 流式组装。该公共面收敛为
|
||||
`omni/policy/tools/omni-chat-request.ts` 的 `requestOmniChatCompletion`,
|
||||
复用 transcribe-audio 的 `parseSseTranscript` SSE 解析;非 2xx 一律只回报
|
||||
`HTTP <status>`,上游 body 永不进入模型可见内容(与 transcribe-audio 一致)。
|
||||
|
||||
`omni_transcribe_audio` 自身的请求 helper 保留不动:它是纯音频 + 分片语义,
|
||||
迁到共享模块只会增加耦合。
|
||||
|
||||
### D2 文本产物沿用转写协议,memory role 用预留枚举
|
||||
|
||||
四个文本产出工具的产物结构与 §6.2 转写协议一致:严格 UTF-8 text/plain 文件
|
||||
产物 + `metadata.omniRole` 标注 + 强制披露。role 取值直接使用 memory 类型
|
||||
枚举中已预留的 `'caption'` / `'ocr'` / `'summary'`
|
||||
(`services/media-memory/types.ts` 的 `KnownMediaMemoryRole`),无需改动
|
||||
memory 的 channel/coverage 推导:`ocr → onscreen_text`、`caption/summary`
|
||||
按源模态落 `visual`/`acoustic`,coverage 默认为 `complete`。
|
||||
|
||||
### D3 裁剪工具的"反 no-op"与"角色标注"
|
||||
|
||||
`omni_clip_image` / `omni_clip_audio` 与 `omni_clip_video` 保持同一语义族:
|
||||
|
||||
- 时间/空间裁剪是**切取而非降质**:clip_audio 采样率/声道不动,clip_image
|
||||
输出 PNG(幸存像素不再被有损重编码二次伤害);
|
||||
- 全覆盖 no-op(整图/全时长"裁剪")在参数层或执行早期拒绝——否则只是
|
||||
白跑一次有损重编码,披露还会谎称丢弃了范围外内容;
|
||||
- 产物标 `omniRole: 'clip'`,memory 据此映射 `partial` coverage。
|
||||
|
||||
`omni_clip_image` 与模型直调的 `zoom_image` 是互补而非重复:zoom_image 用
|
||||
归一化坐标、不走 policy 管线;clip_image 用像素坐标、可被 fixedPolicies
|
||||
编排、产物入 memory 谱系。
|
||||
|
||||
### D4 长视频理解的分段规格
|
||||
|
||||
`omni_understand_video_segments` 按设计文档 §3.3 锁定两个上限:
|
||||
`maxParallelSegments` ≤ 8(并发上限)、`maxSegmentBytes` 默认 10MiB(inline
|
||||
上限)。分段重编码目标(360p / 450kbps 视频 + 32kbps 单声道 AAC)使 60s
|
||||
上限分段约 3.6MB,远低于 inline 天花板;仍超限的分段内联标注失败而不是
|
||||
发出必然失败的请求。时长不可探测时 fail-closed(无法规划分段);容器声称
|
||||
时长超 512 段上限时同样 fail-closed(与 transcribe-audio 同一理由:
|
||||
时长是攻击者可影响的元数据)。
|
||||
|
||||
### D5 memory.\* 命名空间的语义与求值时机
|
||||
|
||||
条件 DSL 新增第四命名空间 `memory.*`,六个 0/1 字段:
|
||||
`hasTranscript` / `hasOcr` / `hasCaption` / `hasSummary` / `hasKeyframes` /
|
||||
`hasClip`,语义为"该 item 版本的 memory 子图中已存在对应 role 的产物"。
|
||||
|
||||
关键设计点:
|
||||
|
||||
- **子图而非同版本**:`MediaMemoryService.collectVersionOutputRoles` 沿
|
||||
DERIVED_FROM 子图(root 有界,M §8)收集 role。§4.1 链条"视频 → 抽音轨 →
|
||||
转写"中,转写产物挂在派生音频版本下;只查本版本永远看不到它。
|
||||
- **三值语义不破**:memory 未开启、item 无绑定、存储不可读时整个命名空间
|
||||
缺失,`memory.*` 条件求值为 `unavailable`(由 `onConditionUnavailable`
|
||||
决定 skip/run),绝不静默当假——否则"无转写才转写"在记忆不可读时会
|
||||
退化成"永远转写"。
|
||||
- **惰性求值**:orchestrator 仅在某条 policy 的 `when` 引用 `memory.*`
|
||||
时(新增的 `conditionUsesNamespace` 判定)才做一次存储读取;不引用
|
||||
memory 的既有配置零开销。
|
||||
- **与执行级复用的分工**:`findReusableOutputs`(同文件+同指纹跳过重复
|
||||
执行)管"幂等",`memory.*` 管"按记忆状态走分支"。两者可叠加:条件
|
||||
no_match 时连复用记账都不发生。
|
||||
|
||||
### D6 patch-grid 对齐与 token 预算档位
|
||||
|
||||
新模块 `omni/smart-resize.ts`(移植插件 `shared/image.py` 的
|
||||
`smart_resize` / `budget_to_pixels`):
|
||||
|
||||
- 网格因子 28(Qwen-VL 族:14px patch × 2×2 merge),一个视觉 token 覆盖
|
||||
28² = 784 像素;与 `estimation.ts` raw-resource-v1 的 2048 px/token
|
||||
保守估计刻意不同——估计器宁多算保安全,预算器对准计量网格。
|
||||
- 档位:图片 small/normal/large = 256/1024/2048 tokens,视频逐帧
|
||||
80/256/1024;最小档同时作为 minPixels(小图上采样到网格,避免不足一个
|
||||
cell 的零碎计费)。
|
||||
- 接入点:`omni_downsample_image` 新增 `tokenBudget` 参数(设置后覆盖
|
||||
`maxDimension`,按显示尺寸——EXIF orientation ≥ 5 换轴——精确缩放到
|
||||
网格);`omni_extract_keyframes` 新增 `frameTokenBudget`(逐帧网格尺寸)。
|
||||
|
||||
### D7 动态 FPS 均匀采样
|
||||
|
||||
`omni_extract_keyframes` 新增 `strategy: 'uniform'`(默认 `scene` 不变,
|
||||
完全向后兼容):帧数 `clamp(窗口时长 × fps, 1, maxFrames)`,长视频自动
|
||||
降采样率;每个时间戳独立 `-ss` 精确寻址、并行 4 路抽取(插件
|
||||
`extract_frames_by_seeking` 形态);`startSec`/`endSec` 提供裁剪+采样一体。
|
||||
时长未知时回退既有 scene 路径。这是"480p/10fps 降不了计费 token"实测结论
|
||||
的直接对策:计量单位是 帧数×像素,fps 与逐帧像素预算才是有效杠杆。
|
||||
|
||||
## 3. 工具规格
|
||||
|
||||
### 3.1 裁剪
|
||||
|
||||
- `omni_clip_image`(image → PNG):`x`/`y`/`width`/`height` 像素坐标必填;
|
||||
EXIF 方向先于裁剪烘焙,坐标即用户所见;动图双闸拒绝(与 downsample/convert
|
||||
一致);role `clip`。
|
||||
- `omni_clip_audio`(audio → WAV/MP3/M4A):`startMs` ≥ 0、`durationMs` ≥
|
||||
1000(设计文档下限),默认到结尾;输入侧 `-ss`/`-t` + 重编码保证采样级
|
||||
精确;role `clip`。
|
||||
|
||||
### 3.2 模型理解(文本产物)
|
||||
|
||||
- `omni_caption_image`(image → 文本,role `caption`):`prompt` 可配
|
||||
(默认整体详细描述),`maxInputBytes` 默认 10MiB。
|
||||
- `omni_ocr_image`(image → 文本,role `ocr`):`language` 可选提示
|
||||
(默认自动检测),`prompt` 可覆盖默认 OCR 指令;解锁设计文档 §4.5 的
|
||||
文档/文字图片 OCR fixedPolicy。
|
||||
- `omni_caption_audio`(audio → 文本,role `caption`):与 transcribe-audio
|
||||
同构(分片并发 3、共享时间预算、退化检测、单段失败内联标注),prompt
|
||||
由参数/settings 提供,默认描述语音大意/音色/事件/情绪;与转写的分工是
|
||||
"理解 vs 逐字"。
|
||||
|
||||
三者共有运维参数 `model` / `baseUrl` / `apiKeyEnv`,均列入
|
||||
`operatorOnlyParams`(与 transcribe-audio 同一安全理由:端点与凭据选择
|
||||
必须运维控制)。
|
||||
|
||||
### 3.3 长视频理解
|
||||
|
||||
`omni_understand_video_segments`(video → 文本,role `summary`):
|
||||
`segmentSeconds` 默认 30(5–60)、`maxParallelSegments` 默认 8(上限 8)、
|
||||
`maxSegmentBytes` 默认 10MiB;逐段 `[MM:SS-MM:SS]` 标注拼装,单段失败内联
|
||||
标注,全段失败才整体报错。
|
||||
|
||||
## 4. 条件 DSL 扩展
|
||||
|
||||
`conditions.ts`:`MEMORY_CONDITION_FIELDS` + `memory` 上下文命名空间 +
|
||||
`conditionUsesNamespace`;`orchestrator.ts` 在 pass 起始快照中按 item 求值
|
||||
memory 命名空间;`service.ts` 新增 `collectVersionOutputRoles`(存储不可读
|
||||
返回 undefined → `unavailable`)。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- 单元/集成测试:每个新工具独立测试文件(descriptor 声明、参数校验、
|
||||
请求形态、披露文本、失败路径);`smart-resize` 网格/档位数学;conditions
|
||||
的 memory 三值语义;service 的子图 role 收集(含 §4.1 派生链);
|
||||
orchestrator 端到端:memory 门控 policy 首轮执行、次轮零调用。
|
||||
- 全量回归:`src/omni` + `src/services/media-memory` 45 文件 1017 测试通过;
|
||||
eslint 零告警;tsc 无新增错误。
|
||||
@@ -0,0 +1,61 @@
|
||||
# Integrating the Omni experiment into main
|
||||
|
||||
[简体中文](./omni-main-integration.zh-CN.md)
|
||||
|
||||
## Scope
|
||||
|
||||
Integrate the completed `omni-experiment` feature into the current main branch
|
||||
through `lazzy/omni-main-integration`. The source experiment is
|
||||
`a7ab939cc3624c1dfc1629015e0af50ec77ab4b5`; the initial main baseline is
|
||||
`eb3bcc62f1afd3438f42d1d05679d6f5084720f4`.
|
||||
|
||||
The feature includes media recognition and upload delivery, processing
|
||||
policies and tools, media memory and recall, storage governance, and separate
|
||||
upload/inference configuration. Existing experiment designs define these
|
||||
behaviors; this change adapts their implementation to main's current entry
|
||||
points without redesigning them.
|
||||
|
||||
## Integration decisions
|
||||
|
||||
- Create the integration branch from the experiment and merge main into it.
|
||||
Keep main's package versions, dependencies, runtime ownership, and public
|
||||
compatibility exports while carrying forward the experiment's additions.
|
||||
- Move media overflow recovery into the active `LlmChat` implementation.
|
||||
Keep `geminiChat.ts` as main's compatibility export, not a second runtime.
|
||||
- Apply query-preparation and cancellation state to `use-llm-stream.ts`, and
|
||||
retain main's current ACP, tool scheduling, recording, and UI contracts.
|
||||
- Defer processing-policy normalization until tools are warmed, including
|
||||
provisional workspace activation. Load the media reader through Config so
|
||||
the utility layer keeps its dependency boundary.
|
||||
- Apply media-policy visibility and execution provenance to CodeModeOnly.
|
||||
Preserve uploaded media, annotations, and tool output in the outer exec
|
||||
response, including hook context and later script failures. Export nested
|
||||
tool-result execution IDs from the active transcript branch without
|
||||
fabricating model-authored calls. Recognize the Omni recall record subtype.
|
||||
- Keep `omni.enabled` false by default. Preserve the existing environment
|
||||
opt-in and bare-mode override.
|
||||
- Retain the accepted global 250-image request cap and audio wire-format
|
||||
extensions. Existing users have no Omni-enabled historical sessions;
|
||||
expired Omni media retry behavior is outside this integration's scope.
|
||||
- Update both lockfiles for the experiment's additional test dependency and
|
||||
preserve the package exports and source aliases required by every consumer.
|
||||
|
||||
## Validation
|
||||
|
||||
The assessment already exercised the global CLI and exact experiment with
|
||||
isolated loopback controls. Extend that plan against the integrated bundle:
|
||||
ordinary text, file reads/writes, tool continuation, normal session resume,
|
||||
and the Omni-enabled delivery path. Use local protocol doubles for
|
||||
deterministic upload/inference and credential separation checks; report them
|
||||
separately from real-provider validation.
|
||||
|
||||
Build, typecheck, bundle, check both lockfiles and startup imports, and run
|
||||
focused core/CLI/media tests. Verify the conflict adaptations on main's
|
||||
actual entry points. Complete two clean self-audit passes and code review
|
||||
before submitting the single main-targeted PR.
|
||||
|
||||
## Boundaries
|
||||
|
||||
No default enablement, new provider policy, storage redesign, or additional
|
||||
feature PR is part of this integration. The PR does not authorize merging
|
||||
itself or enabling auto-merge. No design decisions remain open.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 将 Omni 实验集成到 main
|
||||
|
||||
[English](./omni-main-integration.md)
|
||||
|
||||
## 范围
|
||||
|
||||
通过 `lazzy/omni-main-integration` 将已经完成的 `omni-experiment` 功能集成到
|
||||
当前 main。实验源版本为 `a7ab939cc3624c1dfc1629015e0af50ec77ab4b5`,初始 main
|
||||
基线为 `eb3bcc62f1afd3438f42d1d05679d6f5084720f4`。
|
||||
|
||||
功能包括媒体识别与上传投递、处理策略及工具、媒体记忆与召回、存储治理,以及
|
||||
独立的上传和推理配置。已有实验设计定义这些行为;本次工作将实现适配到 main
|
||||
的当前入口,不重新设计功能。
|
||||
|
||||
## 集成决策
|
||||
|
||||
- 从实验分支建立集成分支,将 main 合入其中。在保留实验新增内容的同时,保留
|
||||
main 的包版本、依赖、运行时归属和公共兼容导出。
|
||||
- 将媒体超限恢复迁入实际使用的 `LlmChat` 实现。`geminiChat.ts` 保持 main 的
|
||||
兼容导出,不产生第二套运行时。
|
||||
- 将查询准备与取消状态适配到 `use-llm-stream.ts`,保留 main 当前的 ACP、工具
|
||||
调度、会话记录和 UI 契约。
|
||||
- 在工具预热后归一化处理策略,覆盖临时工作区激活路径。通过 Config 延迟加载
|
||||
媒体读取器,保持 utils 层的依赖边界。
|
||||
- 将媒体策略可见性和执行来源校验适配到 CodeModeOnly。外层 exec 保留上传媒体、
|
||||
标注及工具输出,覆盖 hook 追加上下文与后续脚本失败。导出时从当前转录分支
|
||||
提取嵌套工具结果的执行 ID,不伪造模型发出的调用;识别 Omni 召回记录类型。
|
||||
- 保持 `omni.enabled` 默认关闭,保留现有环境变量启用方式和 bare-mode 覆盖。
|
||||
- 保留已经接受的全局 250 张图片上限和音频请求格式扩展。现有用户没有启用过
|
||||
Omni 的历史会话,失效 Omni 媒体的重试行为不在本次集成范围内。
|
||||
- 为实验新增的测试依赖更新两份锁文件,保留各消费方需要的包导出和源码别名。
|
||||
|
||||
## 验证
|
||||
|
||||
前期评估已经通过隔离的 loopback 控制测试执行全局 CLI 和精确实验版本。对
|
||||
集成后的 bundle 扩展测试计划:普通文本、文件读写、工具续接、普通会话恢复和
|
||||
Omni 开启时的投递链路。使用本地协议替身确定性验证上传、推理及凭据分离,
|
||||
将这些结果与真实 provider 验证分别报告。
|
||||
|
||||
执行 build、typecheck、bundle、两份锁文件与启动导入检查,以及 core、CLI 和
|
||||
媒体相关的定向测试。在 main 的实际入口验证冲突适配。提交这一个面向 main
|
||||
的 PR 前,完成连续两轮无发现的自审及代码审查。
|
||||
|
||||
## 边界
|
||||
|
||||
本次集成不默认启用 Omni,不新增 provider 策略、重新设计存储或另开功能 PR。
|
||||
创建 PR 的授权不包含合并 PR 或开启 auto-merge。当前没有待定的设计决策。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Omni policy follow-up fixes
|
||||
|
||||
## Context
|
||||
|
||||
PR #10351 expands the Omni policy-tool pipeline. Its follow-up review found
|
||||
several contract mismatches in build coverage, media-memory channels, visual
|
||||
token budgets, and tool settings.
|
||||
|
||||
## Changes
|
||||
|
||||
- Keep audio in video clips so the derived video's acoustic channel remains
|
||||
truthful.
|
||||
- Derive caption and summary channels from the source media version.
|
||||
- Keep patch-grid resize results within the configured maximum pixel budget
|
||||
whenever the budget can hold at least one grid cell.
|
||||
- Make `softClipBudget` configurable, accept zero-valued scene thresholds,
|
||||
and validate image crops against post-EXIF-rotation dimensions.
|
||||
- Repair the new tests so they compile and isolate external media probing.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the directly affected core unit tests.
|
||||
- Run the core build and repository typecheck.
|
||||
- Review the complete diff twice after tests pass.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Omni upload and inference separation
|
||||
|
||||
## Problem
|
||||
|
||||
Omni delivery currently derives the temporary-upload endpoint, credential,
|
||||
model, activation gate, and upload-cache scope from the inference
|
||||
`ContentGeneratorConfig`. That makes Omni inactive when inference is routed to
|
||||
a self-hosted OpenAI-compatible endpoint, even when a valid DashScope upload
|
||||
channel is available.
|
||||
|
||||
Two model-facing gaps are also confirmed in normal use:
|
||||
|
||||
- media policy tools expose result artifacts to the client but omit the output
|
||||
file path from `llmContent`, so the model cannot reliably call `read_file` on
|
||||
a clip or other derivative;
|
||||
- the `read_file` declaration supports audio and video at runtime but does not
|
||||
advertise either modality.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add three optional settings under `omni.delivery.upload`:
|
||||
|
||||
- `baseUrl`: DashScope-compatible endpoint whose origin owns `/api/v1/uploads`;
|
||||
- `apiKeyEnv`: name of the environment variable containing the upload API key;
|
||||
- `model`: DashScope model identifier sent to the upload-policy endpoint.
|
||||
|
||||
If all three are absent, retain the existing behavior: a static
|
||||
DashScope-compatible inference configuration is also the upload configuration.
|
||||
If any dedicated upload setting is present, all three are required. Invalid or
|
||||
incomplete explicit configuration, a missing/empty key environment variable,
|
||||
or a non-DashScope upload endpoint is a startup configuration error. The raw
|
||||
key is never written to settings or diagnostics.
|
||||
|
||||
## Runtime boundary
|
||||
|
||||
The resolved upload configuration is the only source for:
|
||||
|
||||
- Omni delivery activation after the existing enabled/trust checks;
|
||||
- every `DashScopeUploader` construction, including reactive re-upload after a
|
||||
server token-limit rejection;
|
||||
- the upload model passed to `getPolicy` and the persistent upload-cache key;
|
||||
- the endpoint/credential cache-scope fingerprint.
|
||||
|
||||
Inference configuration remains unchanged and is used only by the model
|
||||
provider. No upload key is sent to the inference endpoint, and no inference key
|
||||
is sent to the upload endpoint.
|
||||
|
||||
## Model-visible tool results
|
||||
|
||||
Successful media policy tools include the absolute output path in
|
||||
`llmContent`, while retaining the relative artifact path used by fixed-policy
|
||||
staging and promotion. Fixed-policy results do not feed the model; direct
|
||||
model/client calls receive a path that is immediately usable by `read_file`.
|
||||
|
||||
The `read_file` declaration explicitly lists audio and video, qualified by the
|
||||
selected model's modality support. No new media wire format such as
|
||||
`audio_url` is introduced.
|
||||
|
||||
## Verification
|
||||
|
||||
- Dedicated DashScope upload configuration activates Omni while inference uses
|
||||
a bare-IP custom endpoint.
|
||||
- Legacy DashScope inference-only configuration remains active.
|
||||
- Invalid explicit upload configuration fails at startup.
|
||||
- Normal delivery and reactive degradation use the upload endpoint, key, and
|
||||
model; cache reuse is isolated from inference configuration changes.
|
||||
- Media policy tool success text contains an absolute readable output path.
|
||||
- The `read_file` schema names audio and video, and existing audio/video
|
||||
execution tests remain green.
|
||||
@@ -31,4 +31,5 @@ export default {
|
||||
'scheduled-tasks': 'Scheduled Tasks',
|
||||
goals: 'Goals',
|
||||
tips: 'Contextual Tips',
|
||||
'omni-media-policies': 'Omni Media Policies',
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// ============================================================================
|
||||
// Omni 固定策略(fixedPolicy)预设 —— 覆盖设计文档 4.1–4.9 全部 9 条
|
||||
// ----------------------------------------------------------------------------
|
||||
// 用法:把 "omni" 对象合并进工作区 .qwen/settings.json(或用户级
|
||||
// ~/.qwen/settings.json)。settings 解析支持 JSONC 注释。
|
||||
//
|
||||
// 说明:
|
||||
// - 本预设只占用 "omni.processing" 子树;omni.enabled 等其他键按需在
|
||||
// 你自己的 settings 里设置(omni 未启用时 fixedPolicies 不会运行)。
|
||||
// - 长链超时:4.1/4.4 的 ASR 链可能跑几十分钟(81 分钟电影 ≈ 28 个
|
||||
// 转写分段),务必一并拷贝 policyTools 里的 runtime.timeoutMs 放大项。
|
||||
// - 4.1–4.4 依赖 S7 的 memory.* 条件命名空间(子图可见性:抽音轨→转写
|
||||
// 的转写产物挂在派生音频版本下,对视频版本同样可见)。
|
||||
// - 执行顺序由 priority 保证(大者先跑):抽轨/预处理(90–100)→
|
||||
// 链内条件分支(70–90)→ 独立降级(50–60)。priority 只决定同一资源
|
||||
// 上的匹配顺序;跨模态互不影响。
|
||||
// - 已知限制:4.5 无法表达"判定为文档/文字类图片"(DSL 无图像内容
|
||||
// 分类字段),本预设实现为"大图才 OCR"(既省 token 又是最常见文档
|
||||
// 形态);需要别的口径时自行改 when。
|
||||
// ============================================================================
|
||||
{
|
||||
"omni": {
|
||||
"processing": {
|
||||
"fixedPolicies": {
|
||||
// ---- 4.1 长视频(>30min)抽音轨 → ASR(三步链,优先级 100→90→80)----
|
||||
// 设计:长视频的语音内容整体转为带时间戳的文本,模型不再"看"全片。
|
||||
// 链式触发:抽轨产物 origin 为 policy,由下一步接力;reprocessMedia
|
||||
// 让产物重新进入匹配。memory.hasTranscript 门控:已有 ASR 结果(包括
|
||||
// 模型此前自己转写的)就不再重复触发。
|
||||
"long-video-extract-audio": {
|
||||
"priority": 100,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user", "tool"],
|
||||
"when": [
|
||||
"all",
|
||||
[">", ["field", "resource.durationMs"], 1800000],
|
||||
["==", ["field", "memory.hasTranscript"], 0]
|
||||
],
|
||||
"toolName": "omni_extract_audio",
|
||||
"arguments": {
|
||||
"format": "wav",
|
||||
"sampleRateHz": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
"output": { "reprocessMedia": true, "source": "keep" }
|
||||
},
|
||||
// 音轨超过 ASR 入参上限(100MB)时先压码率;省略大音轨,小音轨接力。
|
||||
"long-video-audio-downsample": {
|
||||
"priority": 90,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["policy"],
|
||||
"when": [">", ["field", "resource.sizeBytes"], 104857600],
|
||||
"toolName": "omni_downsample_audio",
|
||||
"arguments": {
|
||||
"bitrateKbps": 24,
|
||||
"sampleRateHz": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
"output": { "reprocessMedia": true, "source": "omit" }
|
||||
},
|
||||
"long-video-transcribe": {
|
||||
"priority": 80,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["policy"],
|
||||
"when": ["<=", ["field", "resource.sizeBytes"], 104857600],
|
||||
"toolName": "omni_transcribe_audio",
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
|
||||
// ---- 4.4 长音频(>30min)直接 ASR(一步,与 4.1 共用转写条件)----
|
||||
"long-audio-transcribe": {
|
||||
"priority": 90,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["user", "tool"],
|
||||
"when": [
|
||||
"all",
|
||||
[">", ["field", "resource.durationMs"], 1800000],
|
||||
["==", ["field", "memory.hasTranscript"], 0]
|
||||
],
|
||||
"toolName": "omni_transcribe_audio",
|
||||
// source 保留:转写文本内联投递,原音频是否一并投递由你决定
|
||||
// (长音频通常体积也大,想省 token 就改 "omit" 只留转写)。
|
||||
"output": { "source": "keep" }
|
||||
},
|
||||
|
||||
// ---- 4.2 大图(宽或高>2000px)降采样 --------------------------------
|
||||
// tokenBudget 档位按计费 token 说话(small=256 tokens,按 28px
|
||||
// patch 网格对齐),比裸 maxDimension 更贴近模型计费语义。
|
||||
"large-image-downsample": {
|
||||
"priority": 60,
|
||||
"mediaTypes": ["image"],
|
||||
"origins": ["user", "tool", "policy"],
|
||||
"when": [
|
||||
"any",
|
||||
[">", ["field", "resource.width"], 2000],
|
||||
[">", ["field", "resource.height"], 2000]
|
||||
],
|
||||
"toolName": "omni_downsample_image",
|
||||
"arguments": { "tokenBudget": "normal" },
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
|
||||
// ---- 4.3 上下文紧张时抽关键帧 -------------------------------------
|
||||
// 视频本身装不进剩余上下文时,改用 8 帧小档位关键帧代替。
|
||||
"tight-context-keyframes": {
|
||||
"priority": 60,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user", "tool"],
|
||||
"when": [
|
||||
">",
|
||||
["field", "resource.estimatedTokenCount"],
|
||||
["field", "session.availableContextTokens"]
|
||||
],
|
||||
"onConditionUnavailable": "skip",
|
||||
"toolName": "omni_extract_keyframes",
|
||||
"arguments": { "maxFrames": 8, "frameTokenBudget": "small" },
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
|
||||
// ---- 4.5 文档/文字图片 OCR(已知限制:按大图近似判定)--------------
|
||||
"document-image-ocr": {
|
||||
"priority": 50,
|
||||
"mediaTypes": ["image"],
|
||||
"origins": ["user", "tool"],
|
||||
"when": [
|
||||
"all",
|
||||
[
|
||||
"any",
|
||||
[">", ["field", "resource.width"], 2000],
|
||||
[">", ["field", "resource.height"], 2000]
|
||||
],
|
||||
["==", ["field", "memory.hasOcr"], 0]
|
||||
],
|
||||
"toolName": "omni_ocr_image",
|
||||
// source 保留:OCR 文本进上下文的同时,图片仍由 4.2 降采样后投递,
|
||||
// 模型同时拿到"准确文字层 + 压缩视觉层"。
|
||||
"output": { "source": "keep" }
|
||||
},
|
||||
|
||||
// ---- 4.6 大视频(最长边>1920)降分辨率 ------------------------------
|
||||
"large-video-downscale": {
|
||||
"priority": 50,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user", "tool", "policy"],
|
||||
"when": [
|
||||
"any",
|
||||
[">", ["field", "resource.width"], 1920],
|
||||
[">", ["field", "resource.height"], 1920]
|
||||
],
|
||||
"toolName": "omni_downscale_video",
|
||||
"arguments": { "maxHeight": 480, "fps": 10 },
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
|
||||
// ---- 4.7/4.8/4.9 超限兜底(>10MB,与上游 inline 上限对齐)-----------
|
||||
// 与 transport guard 的关系:guard 是无条件的系统兜底(永远启用、
|
||||
// 不可删);这三条是更早、更聪明的预处理——图片按 token 档位降到
|
||||
// 小档,音频直接转写成文本,视频抽帧——比 guard 的机械降码率保留
|
||||
// 更多语义。处理后仍超限的由 guard 接力。
|
||||
"oversize-image-downsample": {
|
||||
"priority": 40,
|
||||
"mediaTypes": ["image"],
|
||||
"origins": ["user", "tool", "policy"],
|
||||
"when": [">", ["field", "resource.sizeBytes"], 10485760],
|
||||
"toolName": "omni_downsample_image",
|
||||
"arguments": { "tokenBudget": "small", "quality": 85 },
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
"oversize-audio-transcribe": {
|
||||
"priority": 40,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["user", "tool", "policy"],
|
||||
"when": [
|
||||
"all",
|
||||
[">", ["field", "resource.sizeBytes"], 10485760],
|
||||
["==", ["field", "memory.hasTranscript"], 0]
|
||||
],
|
||||
"toolName": "omni_transcribe_audio",
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
"oversize-video-keyframes": {
|
||||
"priority": 40,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user", "tool", "policy"],
|
||||
"when": [">", ["field", "resource.sizeBytes"], 10485760],
|
||||
"toolName": "omni_extract_keyframes",
|
||||
"arguments": { "maxFrames": 16, "frameTokenBudget": "normal" },
|
||||
"output": { "source": "omit" }
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 工具级配套:ASR 链的超时放大(30 分钟不足以转写长片)----------
|
||||
// transcribe 的 settings.maxInputBytes 放大到 100MB,与链内 downsample
|
||||
// 分支的 100MB 阈值一致(默认 10MB 会在第二步就拒绝)。
|
||||
//
|
||||
// modelAccess.enabled: 默认所有 policy 工具对模型隐藏(只能被
|
||||
// fixedPolicies 触发)。打开后模型可以基于已投递媒体的 resourceId
|
||||
// 自行调用——即"渐进式理解":先看降级产物,需要细节时自己裁剪/
|
||||
// 抽帧/转写更高保真证据。系统提示会列出已开放的工具。
|
||||
// 只开放"取证"类工具;纯降级工具(downsample/downscale)保持
|
||||
// operator-only,模型没有主动降低证据质量的理由。
|
||||
"policyTools": {
|
||||
"omni_transcribe_audio": {
|
||||
"settings": { "maxInputBytes": 104857600 },
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_extract_audio": {
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_downsample_audio": {
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
},
|
||||
"omni_clip_video": {
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_clip_audio": {
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_extract_keyframes": {
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_understand_video_segments": {
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_ocr_image": {
|
||||
"modelAccess": { "enabled": true }
|
||||
},
|
||||
"omni_caption_image": {
|
||||
"modelAccess": { "enabled": true }
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 链式派生预算:默认 1GiB/根对长片抽轨偏紧,放宽到 4GiB ---------
|
||||
"limits": { "maxDerivedBytesPerRoot": 4294967296 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
# Omni 媒体策略编排
|
||||
|
||||
> 实验特性:仅在 omni 实验分支可用。媒体临时上传仍使用 DashScope 通道;推理既可使用 DashScope OpenAI 兼容模式,也可通过独立上传配置接入自部署的 OpenAI 兼容 Omni 模型。配置格式可能随实验推进调整,不承诺向后兼容。
|
||||
|
||||
当你把图片、音频、视频交给多模态模型时,原始文件往往过大(一部 1080p 电影约 1GB)或过长(81 分钟的音轨),直接投递要么超出上传通道限制,要么烧掉整个上下文窗口。**媒体策略编排**让你用配置声明"什么媒体、满足什么条件时、用哪个工具、降质成什么样再投递",整条流水线是:
|
||||
|
||||
```
|
||||
识别媒体 → 匹配固定策略 → 派生降质产物(staging) → 内容寻址入库(objects) → 携带披露投递给模型
|
||||
```
|
||||
|
||||
三条铁律贯穿全程:
|
||||
|
||||
1. **有损必披露**——任何降质产物送达模型时,紧邻的文本部件必须携带 `【媒体降质】`(或 `【媒体省略】`/`【媒体转写】`)标记,说明丢了什么;
|
||||
2. **配置错误启动即失败**——策略引用不存在的工具、条件写错字段、矛盾的组合,一律在启动时报 `OmniPolicyConfigError`,绝不静默降级;
|
||||
3. **条件不可判定不等于假**——探测不到的元数据字段产生 `unavailable` 三值结果,按策略声明的 `onConditionUnavailable` 处理,永不悄悄当作 `false`。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
在项目 `.qwen/settings.json`(或用户级 `~/.qwen/settings.json`)中:
|
||||
|
||||
```json
|
||||
{
|
||||
"omni": {
|
||||
"enabled": true,
|
||||
"processing": {
|
||||
"fixedPolicies": {
|
||||
"big-image-downsample": {
|
||||
"mediaTypes": ["image"],
|
||||
"when": [">", ["field", "resource.width"], 3000],
|
||||
"toolName": "omni_downsample_image",
|
||||
"arguments": { "maxDimension": 1568, "quality": 75 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
之后任何一张宽度超过 3000px 的图片(来自 `@` 引用、工具结果或 URL 摄取)都会先被降采样,再连同一条 `【媒体降质】原 4096×3072/8.2MB → 1568×1176/…` 披露投递给模型;宽度不超标的图片则原样直达。
|
||||
|
||||
零配置时 `fixedPolicies` 为空,预处理完全不运行——只有强制的传输守卫(见下文)在媒体超出上传通道限制时兜底。
|
||||
|
||||
### 自定义推理与 DashScope 上传解耦
|
||||
|
||||
当 Omni 推理走自部署地址、媒体仍需上传到 DashScope 时,配置独立上传通道:
|
||||
|
||||
```json
|
||||
{
|
||||
"omni": {
|
||||
"enabled": true,
|
||||
"delivery": {
|
||||
"upload": {
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"apiKeyEnv": "DASHSCOPE_API_KEY",
|
||||
"model": "qwen3.5-omni-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`apiKeyEnv` 填环境变量名,不填明文 key;启动 Qwen Code 前需确保该变量已有值。三个字段必须同时设置,否则启动失败。上传 endpoint/key/model 只用于 `getPolicy`、文件上传和上传缓存;自部署推理的 endpoint/key 仍按原有模型配置使用,两套凭据不会交叉发送。
|
||||
|
||||
如果三个字段都不设置,则保留旧行为:当前推理配置本身必须是带静态 API key 的 DashScope 兼容配置,并同时作为上传配置。
|
||||
|
||||
> **预设模板**:设计文档 4.1–4.9 的 9 条策略(长视频 ASR 链、大图降采样、上下文紧张抽帧、长音频转写、文档图 OCR、大视频降分辨率、三模态 >10MB 超限兜底)有一份开箱即用的预设 [`omni-fixed-policies-preset.json`](./omni-fixed-policies-preset.json)——把其中的 `omni` 对象合并进 settings.json 即可,策略已按 priority 排好执行顺序(链式派生先于条件分支,超限兜底最后),并附带 ASR 链所需的超时与预算放大。该预设由 `packages/core/src/omni/policy/preset-validation.test.ts` 持续验证可正常归一化。
|
||||
|
||||
---
|
||||
|
||||
## 配置总览
|
||||
|
||||
```
|
||||
omni.enabled 总开关
|
||||
omni.processing.fixedPolicies.<id> 预处理策略(本文主角)
|
||||
omni.processing.transportGuard.policies.<id> 传输守卫策略(有系统默认,可覆盖不可删除)
|
||||
omni.processing.transportGuard.maxUploadFileBytes 单文件上传上限(≤ 1GiB)
|
||||
omni.processing.policyTools.<toolName> 工具级设置 / 超时 / 模型可见性
|
||||
omni.processing.limits 每根资源的派生预算
|
||||
omni.delivery.upload.baseUrl DashScope 上传 endpoint
|
||||
omni.delivery.upload.apiKeyEnv 上传 key 的环境变量名
|
||||
omni.delivery.upload.model 获取上传 policy 使用的模型
|
||||
omni.delivery.upload.urlTtlHours 上传 URL 缓存时长(≤ 48)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## fixedPolicies:固定策略
|
||||
|
||||
每条策略是 `策略 id → 配置对象` 的一个键值对。id 需匹配 `^[A-Za-z0-9][A-Za-z0-9._-]*$`;值为 `null` 可删除(墓碑)更低作用域配置合并进来的同名策略。**未知键一律报错**,不会被忽略。
|
||||
|
||||
| 键 | 类型 / 取值 | 默认 | 说明 |
|
||||
| ------------------------ | ------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `priority` | 有限数字 | `0` | 执行顺序:priority 降序,同分按 id 升序。 |
|
||||
| `mediaTypes` | `("image"\|"video"\|"audio")[]` | 必填 | 策略适用的媒体模态,非空。 |
|
||||
| `origins` | `("user"\|"tool"\|"policy")[]` | `["user","tool"]` | 接受哪些来源的媒体:用户 `@` 引用 / 工具结果 / 其他策略的派生物。 |
|
||||
| `when` | 条件表达式数组 | 不设(总是匹配) | 见下节 DSL。 |
|
||||
| `onConditionUnavailable` | `"skip"` \| `"run"` | `"skip"` | 条件字段探测不到时跳过还是照跑(`"abortTurn"` 为设计保留值,当前配置会被拒绝)。 |
|
||||
| `toolName` | 字符串 | 必填 | 必须是已注册的媒体策略工具(见工具一览);被 `tools.disabled` 排除的工具视为未注册。 |
|
||||
| `arguments` | 对象 | `{}` | 传给工具的固定参数,按工具的 `settingsSchema` 校验;`inputPath`/`outputDir`/`resourceId` 由编排器逐次注入,配置它们会报错。 |
|
||||
| `maxRunsPerLineage` | 正整数 | `1` | 同一资源谱系上该策略最多执行几次,防循环。 |
|
||||
| `onFailure` | `"continue"` \| `"abort"` | `"continue"` | 工具失败后继续匹配后续策略,还是中止本根资源的处理。 |
|
||||
| `output.reprocessMedia` | 布尔 | `false` | 派生物以 `policy` 来源重新进入策略匹配(策略链)。 |
|
||||
| `output.source` | `"keep"` \| `"omit"` | `"omit"` | 源媒体是保留在投递集中,还是被派生物替代。 |
|
||||
| `output.artifacts` | 选择器映射 | `{ "*": "include" }` | 多产物工具的分流:`include` 进投递集,`retain` 只入库不投递。 |
|
||||
|
||||
### output.artifacts 选择器
|
||||
|
||||
选择器有三种形态,匹配优先级 `role:` > `kind:` > `*`;配置了映射但没有任何选择器命中的产物按 `retain` 处理:
|
||||
|
||||
```json
|
||||
"output": {
|
||||
"artifacts": {
|
||||
"role:transcript": "include",
|
||||
"kind:image": "retain",
|
||||
"*": "include"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `kind:<image|video|audio|file>` —— 按产物类别;
|
||||
- `role:<token>` —— 按工具声明的产物角色(如转写工具的 `transcript`);
|
||||
- `*` —— 兜底。
|
||||
|
||||
启动校验会核对每个选择器确实是该工具能产出的类别/角色——永远匹配不到的选择器是配置错误,不是静默空操作。
|
||||
|
||||
---
|
||||
|
||||
## when 条件 DSL
|
||||
|
||||
条件是 Mapbox 风格的表达式数组:`[操作符, ...操作数]`。
|
||||
|
||||
```json
|
||||
[
|
||||
"all",
|
||||
[">", ["field", "resource.durationMs"], 1200000],
|
||||
["!", ["<", ["field", "resource.sizeBytes"], 1048576]]
|
||||
]
|
||||
```
|
||||
|
||||
- **比较**:`>` `>=` `<` `<=` `==` `!=`,恰好两个操作数,各为 `["field", "<命名空间.字段>"]` 引用或字面量(数字/字符串/布尔);排序类操作符要求数字。
|
||||
- **组合**:`["all", …]`(与)、`["any", …]`(或)、`["!", <expr>]`(非),可任意嵌套。
|
||||
- 不支持任意代码、JSONPath 或嵌套取值子表达式。
|
||||
|
||||
### 可用字段
|
||||
|
||||
| 命名空间 | 字段 | 含义 |
|
||||
| ----------- | --------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `resource.` | `sizeBytes` | 文件字节数 |
|
||||
| | `durationMs` | 时长(毫秒,音/视频) |
|
||||
| | `width` / `height` | 像素宽高 |
|
||||
| | `maxWidth` / `maxHeight` | `width` / `height` 的同值别名 |
|
||||
| | `frameRate` / `frameCount` | 帧率 / 总帧数 |
|
||||
| | `bitRate` | 比特率 |
|
||||
| | `sampleRateHz` / `channels` | 采样率 / 声道数 |
|
||||
| | `estimatedTokenCount` | 该资源的预估 token 消耗 |
|
||||
| `request.` | `totalEstimatedMediaTokens` | 当前调度 pass 中该资源及其派生物的预估 token 总量(按资源逐个计算,非跨资源合计) |
|
||||
| `session.` | `contextWindowTokens` | 模型上下文窗口 |
|
||||
| | `promptTokenCount` | 当前 prompt 已占 token |
|
||||
| | `reservedOutputTokens` | 预留输出 token |
|
||||
| | `availableContextTokens` | 剩余可用上下文 |
|
||||
| `memory.` | `hasTranscript` | 该文件 memory 子图中已有转写产物(1/0) |
|
||||
| | `hasOcr` | 已有 OCR 产物(1/0) |
|
||||
| | `hasCaption` / `hasSummary` | 已有 caption / 汇总产物(1/0) |
|
||||
| | `hasKeyframes` / `hasClip` | 已有关键帧 / 裁剪片段产物(1/0) |
|
||||
|
||||
`memory.*` 字段回答"这个文件的媒体记忆里**已经处理过什么**"(按产物 role 统计,覆盖派生链——如"抽音轨→转写"的转写产物挂在派生音频版本下,查询视频版本同样可见)。典型用法是 §4.1/4.4 的"memory 中无完整 ASR 结果才转写":`["==", ["field", "memory.hasTranscript"], 0]`。记忆未开启、资源无记忆绑定或存储不可读时,整个命名空间为 `unavailable`(不会静默当假),由 `onConditionUnavailable` 决定行为。
|
||||
|
||||
### 三值语义
|
||||
|
||||
字段探测不到(图片没有 `durationMs`、探针失败等)时,比较结果是 `unavailable` 而非 `false`。组合器用强 Kleene 逻辑传播:`all` 里只要有确定的假就是假,`any` 里只要有确定的真就是真,否则 `unavailable` 上浮,最终由该策略的 `onConditionUnavailable` 决定跳过(默认)还是执行,运行记录会写明缺失字段。
|
||||
|
||||
---
|
||||
|
||||
## 策略链:reprocessMedia + origins
|
||||
|
||||
`output.reprocessMedia: true` 让派生物带着 `policy` 来源重新进入匹配,由声明了 `origins: ["policy", …]` 的下游策略接力。典型形态(电影音轨三级流水线):
|
||||
|
||||
```
|
||||
extract-audio (origins:[user], reprocessMedia:true, source:keep)
|
||||
└─▶ audio-downsample (origins:[policy], when: >100MB, reprocessMedia:true, source:omit)
|
||||
└─▶ transcribe (origins:[policy], when: ≤100MB, source:omit)
|
||||
```
|
||||
|
||||
启动校验会拒绝"惰性 reprocessMedia":某策略声明了 `reprocessMedia`,但同一集合里没有任何策略接受 `policy` 来源——派生物将永远无处可去,这是矛盾配置。
|
||||
|
||||
防失控的多重护栏:`maxRunsPerLineage`(单策略单谱系次数)、`limits.maxLineageDepth`(谱系深度)、`limits.maxPolicyRunsPerRoot`(单根总次数)。
|
||||
|
||||
---
|
||||
|
||||
## policyTools:工具级设置与模型可见性
|
||||
|
||||
```json
|
||||
"policyTools": {
|
||||
"omni_transcribe_audio": {
|
||||
"settings": { "maxInputBytes": 33554432, "chunkSeconds": 300 },
|
||||
"runtime": { "timeoutMs": 1800000 },
|
||||
"modelAccess": {
|
||||
"enabled": true,
|
||||
"defaultArguments": { "chunkSeconds": 180 },
|
||||
"lockedArguments": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`settings`** —— 该工具的默认可调参数,按工具 `settingsSchema` 校验(如转写的 `maxInputBytes`、关键帧的 `sceneThreshold`)。
|
||||
- **`runtime.timeoutMs`** —— 单次调用的墙钟超时。必须小于 staging 清扫宽限窗(3600000ms = 1 小时),否则崩溃恢复清扫可能删掉仍在运行的调用的暂存目录,启动时直接报错。
|
||||
- **`modelAccess`** —— 默认所有策略工具**对模型不可见**,只能由固定策略驱动。逐工具打开:
|
||||
- `enabled: true` —— 工具进入模型的工具列表,同时被写进系统提示的媒体引导小节(见"披露与渐进式理解");
|
||||
- `defaultArguments` —— 注入每次调用的默认参数,模型可覆盖;
|
||||
- `lockedArguments` —— 锁定参数:从模型可见 schema 中剥除,模型执意传入会被硬性拒绝(可重试错误),执行时由 harness 注入。同一键出现在 defaults 和 locked 中是矛盾配置;
|
||||
- `parameterSchema` —— 可选的可见 schema 投影,只允许收窄原生 schema,不能引入新属性。
|
||||
- 运维专用参数(`baseUrl`、`apiKeyEnv` 等)永远不会出现在模型可见 schema 中。
|
||||
|
||||
---
|
||||
|
||||
## 策略工具一览
|
||||
|
||||
十四个内置媒体策略工具(前八个基于 ffmpeg/sharp,后六个含模型调用),产物均携带强制披露:
|
||||
|
||||
| 工具 | 输入 → 输出 | 关键参数(默认) | 典型用途 |
|
||||
| -------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `omni_downsample_image` | image → JPEG | `maxDimension` 1568、`quality` 75、`tokenBudget`(256/1024/2048 tokens 档位,吸附 28px patch 网格) | 超大截图降采样;`tokenBudget` 直接按计费 token 说话 |
|
||||
| `omni_convert_image` | image → JPEG/PNG/WebP | `format` "jpeg"、`quality` 90 | 冷门格式转通用格式 |
|
||||
| `omni_clip_image` | image → PNG 区域 | `x`/`y`/`width`/`height`(像素,必填) | 裁剪指定矩形区域(policy 形态,区别于模型直调的 `zoom_image`) |
|
||||
| `omni_caption_image` | image → 文本描述 | `prompt`(默认整体描述);运维专用:`model`、`baseUrl`、`apiKeyEnv` | VL 模型把视觉内容转成文本进上下文(role: caption) |
|
||||
| `omni_ocr_image` | image → 文本 | `language`(默认自动)、`prompt`(默认提取全部可见文字);运维专用:`model` 等 | OCR 提取图片文字(role: ocr),解锁"文档/文字图片 OCR"固定策略 |
|
||||
| `omni_downscale_video` | video → MP4 | `maxHeight` 480、`fps` 10、`crf` 28 | 分辨率/帧率双降 |
|
||||
| `omni_clip_video` | video → MP4 片段 | `startSec` 0、`durationSec`(默认到结尾)、`crf` 23 | 截取时间区间 |
|
||||
| `omni_extract_keyframes` | video → 多张 JPEG | `maxFrames` 8、`sceneThreshold` 0.2、`maxDimension` 768、`strategy` "scene"/"uniform"、"fps"、"startSec"/`endSec`、`frameTokenBudget` | **全片分桶采样**(默认);`uniform` 为时长自适应动态帧率均匀采样(并行 seek 抽帧),长视频自动降采样率 |
|
||||
| `omni_understand_video_segments` | video → 文本理解 | `segmentSeconds` 30(5–60)、`maxParallelSegments` 8、`maxSegmentBytes` 10MiB、`prompt`;运维专用:`model` 等 | **长视频分段并行理解**:固定时长切片重编码(≤10MB)→ 并行 Omni 逐段 caption → 汇总 `[MM:SS-MM:SS]` 文本(role: summary) |
|
||||
| `omni_extract_audio` | video → WAV/MP3/M4A | `format` "wav"、`sampleRateHz` 16000、`channels` 1 | 抽出音轨供转写 |
|
||||
| `omni_downsample_audio` | audio → M4A | `bitrateKbps` 64、`sampleRateHz` 16000、`channels` 1 | 压音频体积 |
|
||||
| `omni_clip_audio` | audio → WAV/MP3/M4A 片段 | `startMs`、`durationMs`(≥1000,默认到结尾)、`format` "m4a" | 音频时间裁剪,与视频裁剪对称 |
|
||||
| `omni_transcribe_audio` | audio → 文本转写 | `chunkSeconds` 180、`maxInputBytes` 10MiB;运维专用:`model`、`baseUrl`、`apiKeyEnv` | **长音频分段转写**:超过 `chunkSeconds` 自动切段(并发 3、共享时间预算),按 `[MM:SS-MM:SS]`/`[H:MM:SS-…]` 拼装;单段失败内联标注不整体失败;自动检测并截断 ASR 重复退化 |
|
||||
| `omni_caption_audio` | audio → 文本描述 | `prompt`(默认语义描述)、`chunkSeconds` 180;运维专用:`model` 等 | Omni 模型理解音频:语音大意、音色、事件、情绪(非逐字转写;长音频同样分段)(role: caption) |
|
||||
|
||||
多产物工具(关键帧)在一次调用事务里逐帧提升为独立产物;转写/caption/OCR/汇总产物走文本通道(`【媒体转写】` 形态)而非媒体通道,memory role 分别为 `transcript`/`caption`/`ocr`/`summary`。
|
||||
|
||||
模型直接调用这些工具时,成功结果会列出每个产物的绝对路径,模型可立即用 `read_file` 读取剪辑、音频、关键帧或转写结果。
|
||||
|
||||
---
|
||||
|
||||
## transportGuard:传输守卫
|
||||
|
||||
预处理是纯实验语义,守卫是**强制安全网**:当最终投递集仍超出传输限制(如 DashScope 单文件 100MB/1GiB 上限)时,守卫策略对超限媒体执行降质重试,并在披露中标注 `(服务端输入超限,第 N 次降质重试)`,最多 `limits.maxTransportPasses` 轮;仍不达标则以 `【媒体省略】` 通知替代媒体本身。
|
||||
|
||||
系统默认三条守卫(可整条覆盖、**不可删除**):
|
||||
|
||||
```json
|
||||
"transportGuard": {
|
||||
"policies": {
|
||||
"image-downsample": { "mediaTypes": ["image"], "toolName": "omni_downsample_image" },
|
||||
"video-downscale": { "mediaTypes": ["video"], "toolName": "omni_downscale_video" },
|
||||
"audio-downsample": { "mediaTypes": ["audio"], "toolName": "omni_downsample_audio" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
守卫策略的三条特殊约束(启动校验强制):合并后的守卫集必须覆盖 image/video/audio 三模态;不得声明 `when`(触发时机就是"超限"本身);`output.source` 必须为 `"omit"`(超限源不能留在投递集里)。
|
||||
|
||||
---
|
||||
|
||||
## limits:派生预算
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
| ------------------------ | ----- | ----------------------------------------- |
|
||||
| `maxConcurrentResources` | 1 | 并发处理的根资源数 |
|
||||
| `reservedOutputTokens` | 8192 | 为模型输出预留的 token(唯一允许为 0 的键) |
|
||||
| `maxLineageDepth` | 8 | 策略链最大深度 |
|
||||
| `maxPolicyRunsPerRoot` | 64 | 单根资源的策略执行总次数上限 |
|
||||
| `maxArtifactsPerRoot` | 256 | 单根资源的产物总数上限 |
|
||||
| `maxDerivedBytesPerRoot` | 1 GiB | 单根资源派生字节预算(电影场景建议调大) |
|
||||
| `maxTransportPasses` | 3 | 传输守卫降质重试轮数 |
|
||||
|
||||
已提交的投递不受预算追溯影响;预算耗尽只阻止新的派生。
|
||||
|
||||
---
|
||||
|
||||
## 披露与渐进式理解
|
||||
|
||||
投递给模型的每个降质产物都紧邻一条披露文本,三种标记:
|
||||
|
||||
- `【媒体降质】<文件>:原 … → …,<丢失说明>` —— 有损派生物(截取、降采样、抽帧、降码率);
|
||||
- `【媒体转写】<文件>:[时间段] 文本…` —— 以文本替代媒体本体的转写;
|
||||
- `【媒体省略】<文件>:…` —— 完全无法投递时的占位通知。
|
||||
|
||||
同时,系统提示会注入 **Media Delivery (Progressive Understanding)** 小节,向模型说明:降质投递是"概览而非全部内容"、禁止在未获取证据的时间段上外推结论,并列出恰好那些 `modelAccess.enabled` 的工具,引导模型在需要更高保真证据时**主动调用工具取证**(裁剪某个片段、对某区间重新抽帧、转写某段音频)。没有任何工具开放时,该小节改为要求模型明确说明证据缺口。
|
||||
|
||||
## 缓存与存储
|
||||
|
||||
- **内容寻址库** `.qwen/omni/objects/sha256/<xx>/<hash>.<ext>` —— 所有派生产物按内容哈希入库,同输入同策略永不重复派生(降质缓存键 = 源哈希 + 策略身份);
|
||||
- **上传缓存** —— DashScope 临时上传的 `oss://` URL 按文件哈希、上传模型、上传 endpoint/key 隔离,并在 `urlTtlHours`(≤ 48h)内复用;只修改推理地址不会导致重复上传;
|
||||
- **隔离区** `quarantine/<invocationId>/` —— 失败派生的暂存残留连同 `reason.json` 移入隔离区,按保留天数和体积预算惰性清扫;崩溃留下的未提交 staging 超过 1 小时宽限窗后被回收。
|
||||
|
||||
---
|
||||
|
||||
## 完整案例:81 分钟电影拉片
|
||||
|
||||
目标:给 Qwen Code 一部 1080p 电影(《Breaking Surface》2020,81 分钟,986MB MKV)和一句"制作拉片网页"的 prompt,让模型在**从未看过全片**的情况下产出覆盖全片的交互式分析页面。
|
||||
|
||||
### 策略设计
|
||||
|
||||
一部电影拆成三路证据,全部在预处理阶段自动完成:
|
||||
|
||||
1. **听觉全覆盖** —— 抽音轨 → 体积超 100MB 就先压码率 → 分段转写成带时间戳的全文台词;
|
||||
2. **视觉全覆盖(稀疏)** —— 16 张关键帧按全片分桶采样,每帧披露带绝对时间戳;
|
||||
3. **视觉细节(稠密,仅开头)** —— 截取前 10 分钟片段,给模型一段"高保真样本"感受影片质感。
|
||||
|
||||
### 工作区 `.qwen/settings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"omni": {
|
||||
"enabled": true,
|
||||
"processing": {
|
||||
"fixedPolicies": {
|
||||
"movie-extract-audio": {
|
||||
"priority": 100,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user"],
|
||||
"toolName": "omni_extract_audio",
|
||||
"arguments": {
|
||||
"format": "wav",
|
||||
"sampleRateHz": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
"output": { "reprocessMedia": true, "source": "keep" }
|
||||
},
|
||||
"movie-audio-downsample": {
|
||||
"priority": 90,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["policy"],
|
||||
"when": [">", ["field", "resource.sizeBytes"], 104857600],
|
||||
"toolName": "omni_downsample_audio",
|
||||
"arguments": {
|
||||
"bitrateKbps": 24,
|
||||
"sampleRateHz": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
"output": { "reprocessMedia": true, "source": "omit" }
|
||||
},
|
||||
"movie-transcribe": {
|
||||
"priority": 80,
|
||||
"mediaTypes": ["audio"],
|
||||
"origins": ["policy"],
|
||||
"when": ["<=", ["field", "resource.sizeBytes"], 104857600],
|
||||
"toolName": "omni_transcribe_audio",
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
"movie-clip-opening": {
|
||||
"priority": 70,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user"],
|
||||
"when": [">", ["field", "resource.durationMs"], 1200000],
|
||||
"toolName": "omni_clip_video",
|
||||
"arguments": { "startSec": 0, "durationSec": 600 },
|
||||
"output": { "source": "omit" }
|
||||
},
|
||||
"movie-keyframes": {
|
||||
"priority": 60,
|
||||
"mediaTypes": ["video"],
|
||||
"origins": ["user"],
|
||||
"toolName": "omni_extract_keyframes",
|
||||
"arguments": { "maxFrames": 16, "maxDimension": 960 },
|
||||
"output": { "source": "keep" }
|
||||
}
|
||||
},
|
||||
"policyTools": {
|
||||
"omni_extract_audio": {
|
||||
"modelAccess": { "enabled": true },
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
},
|
||||
"omni_downsample_audio": { "runtime": { "timeoutMs": 1800000 } },
|
||||
"omni_transcribe_audio": {
|
||||
"settings": { "maxInputBytes": 33554432 },
|
||||
"modelAccess": { "enabled": true },
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
},
|
||||
"omni_clip_video": {
|
||||
"modelAccess": { "enabled": true },
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
},
|
||||
"omni_extract_keyframes": {
|
||||
"modelAccess": { "enabled": true },
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
},
|
||||
"omni_downscale_video": {
|
||||
"modelAccess": { "enabled": true },
|
||||
"runtime": { "timeoutMs": 1800000 }
|
||||
}
|
||||
},
|
||||
"limits": { "maxDerivedBytesPerRoot": 4294967296 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置要点逐条对应前文机制:
|
||||
|
||||
- **优先级排布**(100→60)保证抽音轨先于抽帧,音频链按"抽出→压缩→转写"接力;
|
||||
- **策略链**:`movie-extract-audio` 的 WAV 音轨(约 150MB)带 `policy` 来源重新匹配——超 100MB 命中 `movie-audio-downsample` 压成 24kbps M4A(约 14MB),再次匹配命中 `movie-transcribe`;
|
||||
- **`source` 取舍**:音轨和中间产物全部 `omit`(模型不需要听原始音频),关键帧 `keep` 源视频让开头片段与关键帧共存;
|
||||
- **`when` 双向分流**:`>100MB` 与 `≤100MB` 互斥,压缩前后各走一条路,不会重复转写;
|
||||
- **`modelAccess` 全开 + 引导小节**:模型可以对任意区间自行 clip/keyframes/transcribe 补证据;
|
||||
- **预算调大**:986MB 源片的派生总量(WAV 音轨 + 片段 + 帧)远超默认 1GiB,`maxDerivedBytesPerRoot` 提到 4GiB;
|
||||
- **转写 `maxInputBytes` 提到 32MiB**:容纳压缩后 14MB 的 M4A。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
qwen "请对 @tt10081762_1080p.mkv 做一次完整拉片,产出 lapian.html:电影简介、海报、主角剪影 SVG、可交互时间轴、关键事件列表及截图……" \
|
||||
--approval-mode yolo
|
||||
```
|
||||
|
||||
### 实测结果(qwen3.5-omni-plus,真实 DashScope API)
|
||||
|
||||
预处理阶段(首轮几分钟,之后全部缓存命中秒回):
|
||||
|
||||
- 音频链:4882s 视频 → WAV → 24kbps M4A → **28 段转写、17725 字、0 段失败**,时间戳从 `[0:00:00-0:02:54]` 连续覆盖到 `[1:18:28-1:21:22]`;
|
||||
- 16 张关键帧时间戳 `@ 152.6s → @ 4729.5s`,均匀铺满全片;
|
||||
- 开头 600s 片段一段。
|
||||
|
||||
首个模型请求实际送达:1 段视频 + 16 张图 + 19 条披露 + 内联全文转写,披露样例:
|
||||
|
||||
```
|
||||
【媒体降质】tt10081762_1080p.mkv:原 4882s → 片段 [0s–600s] 600s,片段外内容全部丢弃
|
||||
【媒体降质】tt10081762_1080p.mkv:原视频 4882s/1920×808 → 关键帧 7/16 @ 1830.8s,静态抽帧(全片分桶采样),时间连续性丢失
|
||||
【媒体降质】tt10081762_1080p.mkv:原 4882s 音频 → 分 28 段转写文本 17725 字,语气/音色/非语音信息丢失,识别可能有误
|
||||
【媒体转写】tt10081762_1080p.mkv:[0:00:00-0:02:54] Nej! Kom hit! …
|
||||
```
|
||||
|
||||
传输守卫也被真实触发:600s 片段(128.8MB)遭服务端拒收后自动两级降质重试(`480p/24.1MB → 360p/13MB`),披露追加 `(服务端输入超限,第 N 次降质重试)`。
|
||||
|
||||
模型侧行为印证了渐进式理解引导:基于转写和关键帧建立全片骨架后,模型**主动发起了 18 次媒体工具调用**(7 次 clip、5 次 extract_audio、4 次 keyframes、2 次 transcribe,零错误)对关键场景补充取证,最终产出的 `lapian.html` 时间轴覆盖 `00:00 → 81:22` 全片。
|
||||
|
||||
### 已知边界
|
||||
|
||||
- 大体量投递对上传通道和 API 速率敏感:上传超时会以 `【媒体省略】`/失败通知降级(模型可靠工具自救),连续多个大请求可能触发 DashScope 速率保护——重试即可,预处理与上传缓存保证重跑近乎零成本;
|
||||
- 转写基于 ASR,专有名词与多语言混杂段落可能有误,披露文本已固定声明"识别可能有误";
|
||||
- 关键帧是稀疏采样,单帧之间的动态信息永久丢失——这正是给模型开放 `omni_clip_video` 的意义。
|
||||
@@ -243,6 +243,7 @@ async function main() {
|
||||
import { existsSync } from 'node:fs';
|
||||
const prefix = '@qwen-code/qwen-code-core/';
|
||||
const named = new Map([
|
||||
['omni', 'omni/index.ts'], ['omniPolicyCollection', 'omni/policy/model-call-collection.ts'],
|
||||
['transcriptRecords', 'utils/transcript-records.ts'], ['envVarResolver', 'utils/envVarResolver.ts'],
|
||||
['goalWire', 'goals/goal-wire.ts'], ['memoryScopes', 'memory/scopes.ts'],
|
||||
['subSessionConstants', 'tools/sub-session-constants.ts'], ['toolWriteOrigin', 'services/tool-write-origin.ts'],
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
// (`../../packages/cli/src/...`), so those files get checked here too
|
||||
// and must resolve their own imports.
|
||||
"@qwen-code/qwen-code-core": ["../packages/core/src/index.ts"],
|
||||
"@qwen-code/qwen-code-core/omni": ["../packages/core/src/omni/index.ts"],
|
||||
"@qwen-code/qwen-code-core/omniPolicyCollection": [
|
||||
"../packages/core/src/omni/policy/model-call-collection.ts"
|
||||
],
|
||||
"@qwen-code/qwen-code-core/subSessionConstants": [
|
||||
"../packages/core/src/tools/sub-session-constants.ts"
|
||||
],
|
||||
|
||||
Generated
+1
@@ -28100,6 +28100,7 @@
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yauzl": "^2.9.1",
|
||||
"msw": "^2.3.4",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -713,6 +713,9 @@ describe('Session', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Self-hosted CI runners export QWEN_RUNTIME_DIR; it outranks
|
||||
// Storage.setRuntimeBaseDir, breaking the runtime-pinning assertions.
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', '');
|
||||
originalProcessGuardMode =
|
||||
process.env['QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD'];
|
||||
process.env['QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD'] = 'shadow';
|
||||
@@ -1112,6 +1115,7 @@ describe('Session', () => {
|
||||
} else {
|
||||
process.env['QWEN_CODE_SERVE'] = originalServeStamp;
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
// Reset global runtime base dir state to prevent state leakage between tests
|
||||
core.Storage.setRuntimeBaseDir(null);
|
||||
// Clear session reference to allow garbage collection
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { evaluateMediaPolicyToolCall } from '@qwen-code/qwen-code-core/omni/policy/model-access.js';
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync, statSync } from 'node:fs';
|
||||
@@ -13203,6 +13205,34 @@ export class Session implements SessionContext {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Media-policy modelAccess gate (mirrors CoreToolScheduler) ----
|
||||
// Every ACP-originated call is a model call: there is no in-process
|
||||
// fixed_policy caller on this path, so the origin is pinned rather
|
||||
// than read from the (untrusted) protocol payload.
|
||||
const mediaPolicyGate = evaluateMediaPolicyToolCall({
|
||||
config: this.config,
|
||||
tool,
|
||||
args,
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
if (mediaPolicyGate.outcome === 'reject') {
|
||||
return earlyErrorResponse(
|
||||
new Error(mediaPolicyGate.message),
|
||||
toolName,
|
||||
{
|
||||
status: 'error',
|
||||
executionStatus: 'not_started',
|
||||
errorType:
|
||||
mediaPolicyGate.reason === 'invalid_params'
|
||||
? ToolErrorType.INVALID_TOOL_PARAMS
|
||||
: ToolErrorType.EXECUTION_DENIED,
|
||||
recordInvalidToolParams:
|
||||
mediaPolicyGate.reason === 'invalid_params',
|
||||
},
|
||||
);
|
||||
}
|
||||
args = mediaPolicyGate.args;
|
||||
|
||||
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
|
||||
const isTodoWriteTool = tool.name === ToolNames.TODO_WRITE;
|
||||
// Core exposes TodoWriteTool as a type only. The bundle's keepNames
|
||||
@@ -14784,6 +14814,46 @@ export class Session implements SessionContext {
|
||||
: toolResult.error
|
||||
? 'error'
|
||||
: 'success';
|
||||
// ACP runs its own tool executor, so it must capture the policy
|
||||
// artifact batch itself and call the SAME core memory boundary
|
||||
// the scheduler and the fixed-policy orchestrator use (memory
|
||||
// design M §17). Captured from the tool's OWN result, before any
|
||||
// PostToolUse hook artifacts are merged below — hook artifacts
|
||||
// must never impersonate policy outputs. Never throws; a
|
||||
// collection failure cannot affect the tool result (D12).
|
||||
if (
|
||||
status === 'success' &&
|
||||
!nestedPermissionCancelled &&
|
||||
tool.mediaPolicyDescriptor &&
|
||||
toolResult.artifacts &&
|
||||
toolResult.artifacts.length > 0
|
||||
) {
|
||||
// Deep subpath, not the root or `omni` barrel: both are
|
||||
// statically imported across the CLI, so re-exporting this
|
||||
// through either drags the whole policy graph — and, via
|
||||
// iconvHelper's top-level iconv-lite import, its ~550 KB of
|
||||
// encoding tables — into the ACP agent's static closure
|
||||
// (scripts/check-serve-fast-path-bundle.js enforces that).
|
||||
const { collectModelPolicyCall } = await import(
|
||||
'@qwen-code/qwen-code-core/omniPolicyCollection'
|
||||
);
|
||||
await collectModelPolicyCall({
|
||||
config: this.config,
|
||||
batch: {
|
||||
toolName,
|
||||
invocationId: callId,
|
||||
// Same pin as the modelAccess gate above: every
|
||||
// ACP-originated call is a model call. Recording 'client'
|
||||
// here made the PolicyExecution provenance contradict the
|
||||
// gate that admitted the very same call.
|
||||
executionOrigin: { kind: 'model' },
|
||||
artifacts: toolResult.artifacts,
|
||||
},
|
||||
descriptor: tool.mediaPolicyDescriptor,
|
||||
args,
|
||||
signal: activeToolAbortSignal ?? abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
if (isTrustedTodoWriteTool && !toolResult.error) {
|
||||
this.todoStopGuard.observeTodoWrite(
|
||||
@@ -14905,6 +14975,22 @@ export class Session implements SessionContext {
|
||||
}
|
||||
}
|
||||
|
||||
// Omni second normalization trigger point (parity with
|
||||
// CoreToolScheduler.processToolResultImages, design §8.2):
|
||||
// inline tool-result media becomes oss:// fileData before the
|
||||
// vision bridge runs, which then skips the converted parts.
|
||||
if (this.config.isOmniEnabled?.()) {
|
||||
// Dynamic import: keeps omni out of the ACP static closure
|
||||
// (serve fast-path bundle-closure CI check).
|
||||
const { processToolResultOmniMedia } = await import(
|
||||
'@qwen-code/qwen-code-core/omni'
|
||||
);
|
||||
responseParts = await processToolResultOmniMedia(
|
||||
responseParts,
|
||||
this.config,
|
||||
activeToolAbortSignal,
|
||||
);
|
||||
}
|
||||
const visionBridgeNotices: string[] = [];
|
||||
responseParts = await bridgeToolResultImages({
|
||||
config: this.config,
|
||||
|
||||
@@ -6249,6 +6249,63 @@ describe('loadCliConfig skills.directories', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig omni settings key validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.argv = ['node', 'script.js'];
|
||||
});
|
||||
|
||||
it('accepts known nested omni keys (storage, memory, processing maps)', async () => {
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
omni: {
|
||||
delivery: {
|
||||
upload: {
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKeyEnv: 'DASHSCOPE_API_KEY',
|
||||
model: 'qwen3.5-omni-plus',
|
||||
},
|
||||
},
|
||||
storage: { retentionDays: 7, maxTotalBytes: 1024 * 1024 * 1024 },
|
||||
memory: { recall: { mode: 'active' } },
|
||||
processing: {
|
||||
// Free-form map: policy names are user-defined — the walk must
|
||||
// stop at map nodes instead of rejecting every name.
|
||||
fixedPolicies: {
|
||||
'my-policy': { priority: 10, toolName: 'omni_extract_audio' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Settings;
|
||||
|
||||
await expect(loadCliConfig(settings, argv)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects an unknown key directly under omni', async () => {
|
||||
const argv = await parseArguments();
|
||||
const settings = {
|
||||
omni: { storag: { retentionDays: 7 } },
|
||||
} as unknown as Settings;
|
||||
|
||||
await expect(loadCliConfig(settings, argv)).rejects.toThrow(
|
||||
/unknown key\(s\) under "omni".*"storag"/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown NESTED key with its full path', async () => {
|
||||
// The deletion-controlling knobs live at omni.storage.* — a typo
|
||||
// there must fail loud instead of leaving the GC on defaults.
|
||||
const argv = await parseArguments();
|
||||
const settings = {
|
||||
omni: { storage: { retentionDay: 7 } },
|
||||
} as unknown as Settings;
|
||||
|
||||
await expect(loadCliConfig(settings, argv)).rejects.toThrow(
|
||||
/unknown key\(s\) under "omni\.storage".*"retentionDay"/s,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig skills.disabledLevels', () => {
|
||||
beforeEach(() => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
SchemaValidator,
|
||||
type ConfigParameters,
|
||||
type MCPServerConfig,
|
||||
type OmniPolicyToolsSettings,
|
||||
type SkillLevel,
|
||||
type WebSearchSettings,
|
||||
MAX_SUBAGENT_DEPTH_LIMIT,
|
||||
@@ -59,6 +60,7 @@ import { resolveAcpChannelFallback } from './acp-channel-fallback.js';
|
||||
import { normalizeDisabledToolList } from './normalizeDisabledTools.js';
|
||||
import type { LoadedSettings, Settings } from './settings.js';
|
||||
import { loadSettings, SettingScope } from './settings.js';
|
||||
import { getSettingsSchema } from './settingsSchema.js';
|
||||
import { resolveHookSettingsForConfig } from './hook-settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
@@ -1341,6 +1343,70 @@ export function buildDisabledSkillNamesProvider(
|
||||
return () => resolveSkillSettings(loadedSettings).disabledNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject unknown keys directly under `omni`.
|
||||
*
|
||||
* The generic settings loader only scans TOP-LEVEL keys, and only writes a
|
||||
* debug line — so a nested typo is caught by nothing: `omni.memoryy` or
|
||||
* `omni.processingg` leaves `settings.omni?.memory` / `?.processing`
|
||||
* undefined, every downstream normalizer sees "not configured" and returns
|
||||
* defaults, and the session silently runs with the operator's entire
|
||||
* configuration discarded (probe: `omni.memoryy.recall.mode = sideQuery`
|
||||
* still registered the active-mode recall tool). The omni namespace's
|
||||
* declared stance is that a misconfiguration must fail loud, so this
|
||||
* mirrors the nested checks its own normalizers already perform.
|
||||
*
|
||||
* The allowed set is derived from the settings schema rather than
|
||||
* hardcoded, so it cannot drift as the namespace grows.
|
||||
*/
|
||||
function assertKnownOmniSettingKeys(settings: Settings): void {
|
||||
const omni = settings.omni;
|
||||
if (omni === undefined || omni === null || typeof omni !== 'object') return;
|
||||
const schemaOmni = getSettingsSchema()['omni'] as
|
||||
| { properties?: Record<string, unknown> }
|
||||
| undefined;
|
||||
// No schema properties resolved (unexpected): stay silent rather than
|
||||
// rejecting every valid key.
|
||||
if (Object.keys(schemaOmni?.properties ?? {}).length === 0) return;
|
||||
|
||||
// Walk nested object settings too: the deletion-controlling knobs live
|
||||
// at `omni.storage.*`, and a typo there would silently leave the GC on
|
||||
// defaults. Free-form map nodes (fixedPolicies, policyTools — schema
|
||||
// nodes without `properties`) stop the walk: their keys are
|
||||
// user-defined.
|
||||
const walk = (
|
||||
value: unknown,
|
||||
schemaNode: { properties?: Record<string, unknown> } | undefined,
|
||||
label: string,
|
||||
): void => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
const props = schemaNode?.properties;
|
||||
if (!props || Object.keys(props).length === 0) return;
|
||||
const allowed = new Set(Object.keys(props));
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid settings: unknown key(s) under "${label}": ` +
|
||||
`${unknown.map((k) => `"${k}"`).join(', ')}. ` +
|
||||
`Allowed: ${[...allowed].sort().join(', ')}. ` +
|
||||
`An unrecognized ${label} entry would be silently ignored, ` +
|
||||
`leaving the session running with defaults instead of your ` +
|
||||
`configuration.`,
|
||||
);
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
walk(
|
||||
child,
|
||||
props[key] as { properties?: Record<string, unknown> } | undefined,
|
||||
`${label}.${key}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
walk(omni, schemaOmni, 'omni');
|
||||
}
|
||||
|
||||
export function buildEnabledSkillNamesProvider(
|
||||
loadedSettings: LoadedSettings,
|
||||
): () => ReadonlySet<string> {
|
||||
@@ -1573,6 +1639,7 @@ export async function loadCliConfig(
|
||||
},
|
||||
enabledSkillNamesProvider?: () => ReadonlySet<string>,
|
||||
): Promise<Config> {
|
||||
assertKnownOmniSettingKeys(settings);
|
||||
const provisionalWorkspace = hostPolicy?.provisionalWorkspace === true;
|
||||
const debugMode = isDebugMode(argv);
|
||||
if (debugMode && process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
|
||||
@@ -2404,6 +2471,36 @@ export async function loadCliConfig(
|
||||
publicBaseUrl: settings.artifact?.oss?.publicBaseUrl,
|
||||
}
|
||||
: undefined,
|
||||
omniEnabled: settings.omni?.enabled ?? false,
|
||||
omniMaxUploadFileBytes:
|
||||
settings.omni?.processing?.transportGuard?.maxUploadFileBytes,
|
||||
omniMaxEstimatedTokens:
|
||||
settings.omni?.processing?.transportGuard?.maxEstimatedTokens,
|
||||
omniMaxDurationSeconds:
|
||||
settings.omni?.processing?.transportGuard?.maxDurationSeconds,
|
||||
omniUrlDownloadMaxFileBytes:
|
||||
settings.omni?.ingestion?.localization?.url?.maxFileBytes,
|
||||
omniUploadUrlTtlHours: settings.omni?.delivery?.upload?.urlTtlHours,
|
||||
omniUploadBaseUrl: settings.omni?.delivery?.upload?.baseUrl,
|
||||
omniUploadApiKeyEnv: settings.omni?.delivery?.upload?.apiKeyEnv,
|
||||
omniUploadModel: settings.omni?.delivery?.upload?.model,
|
||||
omniPolicyTools: settings.omni?.processing?.policyTools as
|
||||
| OmniPolicyToolsSettings
|
||||
| undefined,
|
||||
omniFixedPolicies: settings.omni?.processing?.fixedPolicies as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
omniTransportGuardPolicies: settings.omni?.processing?.transportGuard
|
||||
?.policies as Record<string, unknown> | undefined,
|
||||
omniProcessingLimits: settings.omni?.processing?.limits as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
omniQuarantineRetentionDays:
|
||||
settings.omni?.storage?.quarantine?.retentionDays,
|
||||
omniQuarantineMaxBytes: settings.omni?.storage?.quarantine?.maxBytes,
|
||||
omniStorageRetentionDays: settings.omni?.storage?.retentionDays,
|
||||
omniStorageMaxTotalBytes: settings.omni?.storage?.maxTotalBytes,
|
||||
omniMemory: settings.omni?.memory as Record<string, unknown> | undefined,
|
||||
emitToolUseSummaries: settings.experimental?.emitToolUseSummaries ?? true,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
locale: resolveLocaleForExtensions(settings),
|
||||
|
||||
@@ -4380,6 +4380,756 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
omni: {
|
||||
type: 'object',
|
||||
label: 'Omni Multimodal',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Omni multimodal experiment (omni-experiment branch): upload-based ' +
|
||||
'media delivery via the DashScope temporary upload channel. Requires ' +
|
||||
'ffmpeg/ffprobe on PATH when enabled.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Omni Media Delivery',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the omni media pipeline. Media files (video, image, ' +
|
||||
'audio) referenced with @ — and media served from @https:// ' +
|
||||
'URLs — are recognized (ffprobe), stored content-addressed ' +
|
||||
'under .qwen/omni/objects/, uploaded through the DashScope ' +
|
||||
'temporary upload channel, and delivered as oss:// URLs instead ' +
|
||||
'of inline base64. Only active for DashScope-compatible ' +
|
||||
'endpoints. Can also be enabled via QWEN_CODE_ENABLE_OMNI=1.',
|
||||
showInDialog: true,
|
||||
},
|
||||
processing: {
|
||||
type: 'object',
|
||||
label: 'Omni Processing',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Media policy processing: fixed-policy orchestration, transport ' +
|
||||
'guard, per-root derivation limits, and policy tool overrides.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
limits: {
|
||||
type: 'object',
|
||||
label: 'Omni Processing Limits',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Per-invocation derivation budgets. Exceeding a budget stops ' +
|
||||
'further derivation for that root resource (already committed ' +
|
||||
'artifacts stand).',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxConcurrentResources: {
|
||||
type: 'number',
|
||||
label: 'Max Concurrent Resources',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 1,
|
||||
description:
|
||||
'Number of media resources processed by policies in ' +
|
||||
'parallel within one request.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'number', minimum: 1, default: 1 },
|
||||
},
|
||||
reservedOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Reserved Output Tokens',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 8192,
|
||||
description:
|
||||
'Tokens reserved for model output when computing ' +
|
||||
'session.availableContextTokens for when-conditions.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 8192,
|
||||
},
|
||||
},
|
||||
maxLineageDepth: {
|
||||
type: 'number',
|
||||
label: 'Max Lineage Depth',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 8,
|
||||
description:
|
||||
'Maximum derivation chain length from a root resource.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'number', minimum: 1, default: 8 },
|
||||
},
|
||||
maxPolicyRunsPerRoot: {
|
||||
type: 'number',
|
||||
label: 'Max Policy Runs Per Root',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 64,
|
||||
description:
|
||||
'Maximum policy invocations attributable to one root ' +
|
||||
'resource within a single orchestrator run.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'number', minimum: 1, default: 64 },
|
||||
},
|
||||
maxArtifactsPerRoot: {
|
||||
type: 'number',
|
||||
label: 'Max Artifacts Per Root',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 256,
|
||||
description:
|
||||
'Maximum derived artifacts attributable to one root ' +
|
||||
'resource within a single orchestrator run.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
default: 256,
|
||||
},
|
||||
},
|
||||
maxDerivedBytesPerRoot: {
|
||||
type: 'number',
|
||||
label: 'Max Derived Bytes Per Root',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 1073741824,
|
||||
description:
|
||||
'Byte budget for derived artifacts per root resource ' +
|
||||
'within a single orchestrator run. Defaults to 1 GiB.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
default: 1073741824,
|
||||
},
|
||||
},
|
||||
maxTransportPasses: {
|
||||
type: 'number',
|
||||
label: 'Max Transport Passes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 3,
|
||||
description:
|
||||
'Maximum transport-guard policy passes per resource before ' +
|
||||
'the media is removed with an explicit omission note.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'number', minimum: 1, default: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
fixedPolicies: {
|
||||
type: 'object',
|
||||
label: 'Omni Fixed Policies',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, Record<string, unknown> | null>,
|
||||
description:
|
||||
'User fixed policies keyed by policy id. There are no ' +
|
||||
'built-in default policies: nothing runs unless configured ' +
|
||||
'here. Across settings scopes entries merge by id ' +
|
||||
'(whole-entry replacement); a null entry tombstones a policy ' +
|
||||
'from a lower-priority scope. Validated and normalized at ' +
|
||||
'startup.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
transportGuard: {
|
||||
type: 'object',
|
||||
label: 'Omni Transport Guard',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Delivery-boundary enforcement: hard limits plus mandatory ' +
|
||||
'guard policies applied when the final delivery set still ' +
|
||||
'exceeds limits. Cannot be disabled.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxUploadFileBytes: {
|
||||
type: 'number',
|
||||
label: 'Max Upload File Bytes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 1073741824,
|
||||
description:
|
||||
'Per-file byte ceiling for omni media uploads. Defaults ' +
|
||||
'to 1 GiB, the DashScope temporary-upload per-file cap ' +
|
||||
'(values above it are a startup configuration error). ' +
|
||||
'Media still above the limit after guard policies fail ' +
|
||||
'closed with an explanatory error.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 1073741824,
|
||||
default: 1073741824,
|
||||
},
|
||||
},
|
||||
maxEstimatedTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Estimated Tokens',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 0,
|
||||
description:
|
||||
'Estimated-token ceiling for a single omni media input, ' +
|
||||
'checked at the delivery boundary using the versioned ' +
|
||||
'raw-resource estimator. 0 disables the token guard — the ' +
|
||||
'estimation formula is pending confirmation with the ' +
|
||||
'model provider; set a positive threshold to enforce ' +
|
||||
'fail-closed rejection.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
maxDurationSeconds: {
|
||||
type: 'number',
|
||||
label: 'Max Duration Seconds',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 0,
|
||||
description:
|
||||
'Duration ceiling in seconds for a single omni media ' +
|
||||
'input, checked at the delivery boundary. 0 disables it. ' +
|
||||
'Byte and token limits cannot express a provider duration ' +
|
||||
'cap: a long film downscaled under the byte ceiling still ' +
|
||||
'gets rejected by the API, after paying for the transcode. ' +
|
||||
'Set this and the guard omits it honestly instead.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
type: 'object',
|
||||
label: 'Omni Transport Guard Policies',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, Record<string, unknown> | null>,
|
||||
description:
|
||||
'Guard policies keyed by policy id, run only when the ' +
|
||||
'final delivery set exceeds transport limits. Merged with ' +
|
||||
'system defaults by id. The merged set must cover image, ' +
|
||||
'video, and audio and must not be empty; every policy ' +
|
||||
'output must use source: omit.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
policyTools: {
|
||||
type: 'object',
|
||||
label: 'Omni Policy Tools',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, Record<string, unknown> | null>,
|
||||
description:
|
||||
'Per-tool overrides keyed by policy tool name: settings ' +
|
||||
'(default arguments), runtime (timeoutMs), ' +
|
||||
'and modelAccess (enabled, defaultArguments, lockedArguments, ' +
|
||||
'parameterSchema, output).',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
delivery: {
|
||||
type: 'object',
|
||||
label: 'Omni Delivery',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Model-delivery settings for omni media.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
upload: {
|
||||
type: 'object',
|
||||
label: 'Omni Delivery Upload',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Upload-channel delivery settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
label: 'Upload Base URL',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: '',
|
||||
description:
|
||||
'DashScope-compatible endpoint used only for temporary ' +
|
||||
'media uploads. Set together with apiKeyEnv and model to ' +
|
||||
'separate uploads from inference.',
|
||||
showInDialog: false,
|
||||
},
|
||||
apiKeyEnv: {
|
||||
type: 'string',
|
||||
label: 'Upload API Key Environment Variable',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: '',
|
||||
description:
|
||||
'Name of the environment variable containing the ' +
|
||||
'DashScope upload API key. The key itself is not stored ' +
|
||||
'in settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
label: 'Upload Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: '',
|
||||
description:
|
||||
'DashScope model identifier sent to the temporary-upload ' +
|
||||
'policy endpoint.',
|
||||
showInDialog: false,
|
||||
},
|
||||
urlTtlHours: {
|
||||
type: 'number',
|
||||
label: 'Upload URL TTL (hours)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 47,
|
||||
description:
|
||||
'Validity horizon for cached oss:// upload URLs. ' +
|
||||
'DashScope temporary uploads live 48h; the default keeps ' +
|
||||
'a 1h margin. 0 disables the upload cache (every ' +
|
||||
'delivery re-uploads).',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 47,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ingestion: {
|
||||
type: 'object',
|
||||
label: 'Omni Ingestion',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Media input ingestion settings for omni delivery.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
localization: {
|
||||
type: 'object',
|
||||
label: 'Omni Ingestion Localization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Remote-media localization settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
url: {
|
||||
type: 'object',
|
||||
label: 'Omni URL Localization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'URL media download settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxFileBytes: {
|
||||
type: 'number',
|
||||
label: 'Max Download File Bytes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 0,
|
||||
description:
|
||||
'Byte ceiling for downloading URL media inputs. 0 or ' +
|
||||
'unset follows ' +
|
||||
'omni.processing.transportGuard.maxUploadFileBytes ' +
|
||||
'(downloading more than the upload channel can ' +
|
||||
'deliver is pointless).',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
type: 'object',
|
||||
label: 'Omni Storage',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Managed storage settings under .qwen/omni/.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
retentionDays: {
|
||||
type: 'integer',
|
||||
label: 'Object Retention (days)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 14,
|
||||
description:
|
||||
'Days an object in .qwen/omni/objects/ that no memory ' +
|
||||
'record references survives before garbage collection may ' +
|
||||
'remove it. Referenced objects are never removed. Must be ' +
|
||||
'at least 1; non-positive values fall back to the default.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'integer', minimum: 1, default: 14 },
|
||||
},
|
||||
maxTotalBytes: {
|
||||
type: 'integer',
|
||||
label: 'Object Store Max Bytes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 21474836480,
|
||||
description:
|
||||
'Soft byte budget for .qwen/omni/objects/. Over budget, ' +
|
||||
'garbage collection removes the oldest unreferenced ' +
|
||||
'objects regardless of age; if only referenced objects ' +
|
||||
'remain it warns and suspends new policy derivations ' +
|
||||
'instead of deleting them. Defaults to 20 GiB.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 21474836480,
|
||||
},
|
||||
},
|
||||
quarantine: {
|
||||
type: 'object',
|
||||
label: 'Omni Quarantine',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Retention for failed policy invocations moved to ' +
|
||||
'.qwen/omni/quarantine/ for diagnosis. Quarantined content ' +
|
||||
'is never recalled into recognition or delivery.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
retentionDays: {
|
||||
type: 'number',
|
||||
label: 'Quarantine Retention (days)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 7,
|
||||
description:
|
||||
'Days a quarantined invocation directory is kept before ' +
|
||||
'startup recovery removes it. Must be at least 1; ' +
|
||||
'non-positive values fall back to the default.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: { type: 'number', minimum: 1, default: 7 },
|
||||
},
|
||||
maxBytes: {
|
||||
type: 'number',
|
||||
label: 'Quarantine Max Bytes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 5368709120,
|
||||
description:
|
||||
'Total byte budget for the quarantine directory. Startup ' +
|
||||
'recovery removes oldest entries first until within ' +
|
||||
'budget. Defaults to 5 GiB. Must be at least 1; ' +
|
||||
'non-positive values fall back to the default.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
default: 5368709120,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
label: 'Omni Media Memory',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Persistent multimodal media memory (collection of recognized ' +
|
||||
'files and policy execution results, plus cross-session recall). ' +
|
||||
'Invalid values abort startup.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
collection: {
|
||||
type: 'object',
|
||||
label: 'Omni Memory Collection',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Collection-side budgets.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxInlineTextBytes: {
|
||||
type: 'number',
|
||||
label: 'Max Inline Text Bytes',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 65536,
|
||||
description:
|
||||
'Upper bound for inline text persisted on a memory entry ' +
|
||||
'(transcripts, OCR). Longer text is truncated on the ' +
|
||||
'entry; the stored artifact keeps the full content.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 65536,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recall: {
|
||||
type: 'object',
|
||||
label: 'Omni Memory Recall',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Recall-side exposure and budgets.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
label: 'Recall Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 'active',
|
||||
description:
|
||||
'Mutually exclusive recall exposure: "active" registers ' +
|
||||
'the recall tool for the model; "sideQuery" runs a ' +
|
||||
'passive selector before the main request instead.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'string',
|
||||
enum: ['active', 'sideQuery'],
|
||||
default: 'active',
|
||||
},
|
||||
},
|
||||
maxEntries: {
|
||||
type: 'number',
|
||||
label: 'Max Recall Entries',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 12,
|
||||
description: 'Maximum entries one recall may return.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 12,
|
||||
},
|
||||
},
|
||||
maxTextChars: {
|
||||
type: 'number',
|
||||
label: 'Max Recall Text Chars',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 24000,
|
||||
description:
|
||||
'Total character budget across all text in one recall ' +
|
||||
'result.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 24000,
|
||||
},
|
||||
},
|
||||
kinds: {
|
||||
type: 'array',
|
||||
label: 'Recall Entry Kinds',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: [
|
||||
'metadata',
|
||||
'derived_media',
|
||||
'policy_result',
|
||||
'execution',
|
||||
] as string[],
|
||||
description:
|
||||
'Entry kinds recall may surface. Replaces wholesale when ' +
|
||||
'set (never element-merged).',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
jsonSchemaOverride: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'metadata',
|
||||
'derived_media',
|
||||
'policy_result',
|
||||
'execution',
|
||||
],
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
includeHistoricalVersions: {
|
||||
type: 'boolean',
|
||||
label: 'Include Historical Versions',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether recall may surface entries for non-current file ' +
|
||||
'versions by default.',
|
||||
showInDialog: false,
|
||||
},
|
||||
active: {
|
||||
type: 'object',
|
||||
label: 'Active Recall',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Budgets for the active recall tool.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxFilesPerCall: {
|
||||
type: 'number',
|
||||
label: 'Max Files Per Call',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 8,
|
||||
description:
|
||||
'Maximum distinct files one recall tool call may ' +
|
||||
'query.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sideQuery: {
|
||||
type: 'object',
|
||||
label: 'Side-Query Recall',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Budgets for the passive side-query selector.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
model: {
|
||||
type: 'string',
|
||||
label: 'Selector Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Model for the passive selector; unset uses the ' +
|
||||
'side-query default (the configured fast model, ' +
|
||||
'falling back to the session model).',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: ['string', 'null'],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'number',
|
||||
label: 'Selector Timeout (ms)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30000,
|
||||
description:
|
||||
'Side-query timeout; on timeout the turn proceeds ' +
|
||||
'with an empty recall.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 30000,
|
||||
},
|
||||
},
|
||||
maxCandidateEntries: {
|
||||
type: 'number',
|
||||
label: 'Max Candidate Entries',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 100,
|
||||
description:
|
||||
'Maximum candidate entries shown to the selector.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
maxSelectedEntries: {
|
||||
type: 'number',
|
||||
label: 'Max Selected Entries',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 12,
|
||||
description:
|
||||
'Maximum entries the selector may pick. Must not ' +
|
||||
'exceed recall.maxEntries.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 12,
|
||||
},
|
||||
},
|
||||
maxAttempts: {
|
||||
type: 'number',
|
||||
label: 'Max Selector Attempts',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 1,
|
||||
description:
|
||||
'Attempts allowed for the selector call itself ' +
|
||||
'(unparseable or schema-invalid output is retried up ' +
|
||||
'to this many times). A selection that parses but ' +
|
||||
'names entries outside the manifest is refused ' +
|
||||
'without a retry; the turn then proceeds with an ' +
|
||||
'empty recall.',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
|
||||
@@ -189,6 +189,27 @@ describe('localizeToolDisplayName', () => {
|
||||
expect(localizeToolDisplayName('MysteryTool')).toBe('MysteryTool');
|
||||
});
|
||||
|
||||
// Both translating locales, not just zh: `t()` has no cross-locale
|
||||
// fallback, so a tool added to zh.js alone renders its raw English badge
|
||||
// in a zh-TW session, next to fully-translated siblings.
|
||||
it.each(['zh', 'zh-TW'] as const)(
|
||||
'has a %s translation for every core tool display name',
|
||||
async (locale) => {
|
||||
const { setLanguageAsync, localizeToolDisplayName } = await import(
|
||||
'./index.js'
|
||||
);
|
||||
const { ToolDisplayNames } = await import('@qwen-code/qwen-code-core');
|
||||
await setLanguageAsync(locale);
|
||||
|
||||
const KEEP_ENGLISH = new Set(['Agent', 'Grep', 'Glob']);
|
||||
const untranslated = Object.values(ToolDisplayNames).filter(
|
||||
(name) =>
|
||||
!KEEP_ENGLISH.has(name) && localizeToolDisplayName(name) === name,
|
||||
);
|
||||
expect(untranslated).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('has a zh translation for every core tool display name', async () => {
|
||||
const { setLanguageAsync, localizeToolDisplayName } = await import(
|
||||
'./index.js'
|
||||
|
||||
@@ -230,6 +230,24 @@ export default {
|
||||
'toolDisplayName.Workflow': 'toolDisplayName.Workflow',
|
||||
'toolDisplayName.ReadMcpResource': 'toolDisplayName.ReadMcpResource',
|
||||
'toolDisplayName.ImageGen': 'toolDisplayName.ImageGen',
|
||||
'toolDisplayName.DownsampleImage': 'toolDisplayName.DownsampleImage',
|
||||
'toolDisplayName.DownscaleVideo': 'toolDisplayName.DownscaleVideo',
|
||||
'toolDisplayName.DownsampleAudio': 'toolDisplayName.DownsampleAudio',
|
||||
'toolDisplayName.ExtractKeyframes': 'toolDisplayName.ExtractKeyframes',
|
||||
'toolDisplayName.ExtractAudio': 'toolDisplayName.ExtractAudio',
|
||||
'toolDisplayName.ClipVideo': 'toolDisplayName.ClipVideo',
|
||||
'toolDisplayName.ClipImage': 'toolDisplayName.ClipImage',
|
||||
'toolDisplayName.ClipAudio': 'toolDisplayName.ClipAudio',
|
||||
'toolDisplayName.CaptionImage': 'toolDisplayName.CaptionImage',
|
||||
'toolDisplayName.CaptionAudio': 'toolDisplayName.CaptionAudio',
|
||||
'toolDisplayName.OcrImage': 'toolDisplayName.OcrImage',
|
||||
'toolDisplayName.UnderstandVideoSegments':
|
||||
'toolDisplayName.UnderstandVideoSegments',
|
||||
'toolDisplayName.ConvertImage': 'toolDisplayName.ConvertImage',
|
||||
'toolDisplayName.TranscribeAudio': 'toolDisplayName.TranscribeAudio',
|
||||
'toolDisplayName.RecallMediaMemory': 'toolDisplayName.RecallMediaMemory',
|
||||
'[fixed-only: runs via media policies, not the model]':
|
||||
'[fixed-only: runs via media policies, not the model]',
|
||||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
|
||||
@@ -221,6 +221,23 @@ export default {
|
||||
'toolDisplayName.Workflow': '工作流程',
|
||||
'toolDisplayName.ReadMcpResource': '讀取 MCP 資源',
|
||||
'toolDisplayName.ImageGen': '圖像生成',
|
||||
'toolDisplayName.DownsampleImage': '降採樣圖像',
|
||||
'toolDisplayName.DownscaleVideo': '降採樣影片',
|
||||
'toolDisplayName.DownsampleAudio': '降採樣音訊',
|
||||
'toolDisplayName.ExtractKeyframes': '擷取關鍵影格',
|
||||
'toolDisplayName.ExtractAudio': '擷取音軌',
|
||||
'toolDisplayName.ClipVideo': '剪輯影片',
|
||||
'toolDisplayName.ClipImage': '裁剪圖像',
|
||||
'toolDisplayName.ClipAudio': '剪輯音訊',
|
||||
'toolDisplayName.CaptionImage': '描述圖像',
|
||||
'toolDisplayName.CaptionAudio': '描述音訊',
|
||||
'toolDisplayName.OcrImage': '識別圖像文字',
|
||||
'toolDisplayName.UnderstandVideoSegments': '分段理解影片',
|
||||
'toolDisplayName.ConvertImage': '轉換圖像',
|
||||
'toolDisplayName.TranscribeAudio': '轉寫音訊',
|
||||
'toolDisplayName.RecallMediaMemory': '召回媒體記憶',
|
||||
'[fixed-only: runs via media policies, not the model]':
|
||||
'[僅固定策略:由媒體策略調用,不開放給模型]',
|
||||
|
||||
'↑ to manage attachments': '↑ 管理附件',
|
||||
'← → select, Delete to remove, ↓ to exit': '← → 選擇,Delete 刪除,↓ 退出',
|
||||
|
||||
@@ -222,6 +222,23 @@ export default {
|
||||
'toolDisplayName.Workflow': '工作流',
|
||||
'toolDisplayName.ReadMcpResource': '读取 MCP 资源',
|
||||
'toolDisplayName.ImageGen': '图像生成',
|
||||
'toolDisplayName.DownsampleImage': '降采样图像',
|
||||
'toolDisplayName.DownscaleVideo': '降采样视频',
|
||||
'toolDisplayName.DownsampleAudio': '降采样音频',
|
||||
'toolDisplayName.ExtractKeyframes': '提取关键帧',
|
||||
'toolDisplayName.ExtractAudio': '提取音轨',
|
||||
'toolDisplayName.ClipVideo': '剪辑视频',
|
||||
'toolDisplayName.ClipImage': '裁剪图像',
|
||||
'toolDisplayName.ClipAudio': '剪辑音频',
|
||||
'toolDisplayName.CaptionImage': '描述图像',
|
||||
'toolDisplayName.CaptionAudio': '描述音频',
|
||||
'toolDisplayName.OcrImage': '识别图像文字',
|
||||
'toolDisplayName.UnderstandVideoSegments': '分段理解视频',
|
||||
'toolDisplayName.ConvertImage': '转换图像',
|
||||
'toolDisplayName.TranscribeAudio': '转写音频',
|
||||
'toolDisplayName.RecallMediaMemory': '召回媒体记忆',
|
||||
'[fixed-only: runs via media policies, not the model]':
|
||||
'[仅固定策略:由媒体策略调用,不开放给模型]',
|
||||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
|
||||
@@ -299,6 +299,62 @@ describe('collectContextData (contextCommand)', () => {
|
||||
expect(data.builtinTools[0].name).toBe('web_fetch');
|
||||
});
|
||||
|
||||
it('excludes fixed-only media-policy tools from the per-tool breakdown (D6)', async () => {
|
||||
// A media-policy tool without modelAccess.enabled is stripped from
|
||||
// getFunctionDeclarations() (zero prompt tokens), so listing it in the
|
||||
// breakdown would make the per-tool sum exceed allToolsTokens. One with
|
||||
// modelAccess.enabled IS declared to the model and must stay listed.
|
||||
const descriptor = {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [],
|
||||
};
|
||||
const hiddenPolicyTool = {
|
||||
name: 'omni_downsample_image',
|
||||
schema: { name: 'omni_downsample_image', description: 'policy schema' },
|
||||
mediaPolicyDescriptor: descriptor,
|
||||
};
|
||||
const exposedPolicyTool = {
|
||||
name: 'omni_probe_media',
|
||||
schema: { name: 'omni_probe_media', description: 'probe schema' },
|
||||
mediaPolicyDescriptor: descriptor,
|
||||
};
|
||||
const config = {
|
||||
...mockConfig,
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
contextWindowSize: 32_000,
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllTools: vi
|
||||
.fn()
|
||||
.mockReturnValue([hiddenPolicyTool, exposedPolicyTool]),
|
||||
getFunctionDeclarations: vi
|
||||
.fn()
|
||||
.mockReturnValue([exposedPolicyTool.schema]),
|
||||
isDeferredAndHidden: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getOmniPolicyToolsSettings: vi.fn().mockReturnValue({
|
||||
omni_probe_media: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
getVisibleTools: vi.fn().mockReturnValue(new Set()),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||
getAutoCompactThreshold: vi.fn(),
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const data = await collectContextData(config, true);
|
||||
|
||||
expect(data.builtinTools).toHaveLength(1);
|
||||
expect(data.builtinTools[0].name).toBe('omni_probe_media');
|
||||
});
|
||||
|
||||
it('lists the auto-memory section as a separate memory entry (#7651)', async () => {
|
||||
// The managed auto-memory section is no longer part of getUserMemory(); its
|
||||
// tokens are surfaced via getAutoMemoryPrompt(). Exercise the non-empty
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ToolNames,
|
||||
buildSkillLlmContent,
|
||||
computeThresholds,
|
||||
isMediaPolicyToolHiddenFromModel,
|
||||
estimateContextTextTokens,
|
||||
formatContextFileDisplayPath,
|
||||
type CompactionThresholds,
|
||||
@@ -142,6 +143,13 @@ export async function collectContextData(
|
||||
if (toolRegistry?.isDeferredAndHidden(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
// Same alignment rule for omni media-policy tools: fixed-only tools
|
||||
// (declared descriptor, modelAccess not enabled) are stripped from
|
||||
// getFunctionDeclarations() and cost the model zero prompt tokens, so
|
||||
// listing them here would make the breakdown sum exceed allToolsTokens.
|
||||
if (isMediaPolicyToolHiddenFromModel(config, tool)) {
|
||||
continue;
|
||||
}
|
||||
const toolJsonStr = JSON.stringify(tool.schema);
|
||||
const tokens = estimateContextTextTokens(toolJsonStr);
|
||||
if (tool instanceof DiscoveredMCPTool) {
|
||||
|
||||
@@ -144,4 +144,62 @@ describe('toolsCommand', () => {
|
||||
);
|
||||
expect(message.tools[1].description).toBe('Edits code files.');
|
||||
});
|
||||
|
||||
it('flags hidden media-policy tools as fixedOnly, but not model-enabled ones', async () => {
|
||||
const mediaTools = [
|
||||
{
|
||||
name: 'omni_downsample_image',
|
||||
displayName: 'DownsampleImage',
|
||||
description: 'Downsamples an image.',
|
||||
schema: {},
|
||||
// Media-policy tool with no modelAccess entry → hidden from the
|
||||
// model's declarations → must surface as fixed-only in /tools.
|
||||
mediaPolicyDescriptor: {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'omni_probe_media',
|
||||
displayName: 'ProbeMedia',
|
||||
description: 'Probes media metadata.',
|
||||
schema: {},
|
||||
// Same descriptor, but modelAccess.enabled below re-exposes it to
|
||||
// the model, so it must NOT carry the fixed-only marker.
|
||||
mediaPolicyDescriptor: {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
...mockTools,
|
||||
] as Tool[];
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getToolRegistry: () => ({
|
||||
getAllTools: () => mediaTools,
|
||||
isDeferredAndHidden: () => false,
|
||||
}),
|
||||
getOmniPolicyToolsSettings: () => ({
|
||||
omni_probe_media: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
|
||||
expect(message.tools).toHaveLength(4);
|
||||
expect(message.tools[0]).toMatchObject({
|
||||
name: 'omni_downsample_image',
|
||||
fixedOnly: true,
|
||||
});
|
||||
expect(message.tools[1].fixedOnly).toBeUndefined();
|
||||
expect(message.tools[2].fixedOnly).toBeUndefined();
|
||||
expect(message.tools[3].fixedOnly).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
||||
import { isMediaPolicyToolHiddenFromModel } from '@qwen-code/qwen-code-core/omni/policy/model-access.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const toolsCommand: SlashCommand = {
|
||||
@@ -39,6 +40,7 @@ export const toolsCommand: SlashCommand = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const config = context.services.config!;
|
||||
|
||||
const tools = toolRegistry.getAllTools();
|
||||
// Filter out MCP tools by checking for the absence of a serverName property
|
||||
@@ -50,6 +52,13 @@ export const toolsCommand: SlashCommand = {
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
// Omni media-policy tools without modelAccess.enabled are stripped
|
||||
// from the model's declarations but stay listed here for the human;
|
||||
// the flag renders a "fixed-only" marker so the discrepancy between
|
||||
// /tools and what the model can call is visible, not confusing.
|
||||
...(isMediaPolicyToolHiddenFromModel(config, tool)
|
||||
? { fixedOnly: true }
|
||||
: {}),
|
||||
// Surface the deferred/eager split so a `tools.eager` allowlist is
|
||||
// visible rather than silently reshaping the model's toolset — the
|
||||
// user-facing half of #10075.
|
||||
|
||||
@@ -55,4 +55,28 @@ describe('<ToolsList />', () => {
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('marks fixed-only media-policy tools and leaves others unmarked', () => {
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'omni_downsample_image',
|
||||
displayName: 'DownsampleImage',
|
||||
fixedOnly: true,
|
||||
},
|
||||
{ name: 'read_file', displayName: 'ReadFile' },
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<ToolsList tools={tools} showDescriptions={false} contentWidth={80} />,
|
||||
);
|
||||
const frame = lastFrame() ?? '';
|
||||
// The marker must be attached to the fixed-only tool's line only.
|
||||
const downsampleLine = frame
|
||||
.split('\n')
|
||||
.find((line) => line.includes('DownsampleImage'));
|
||||
expect(downsampleLine).toContain('[fixed-only');
|
||||
const readFileLine = frame
|
||||
.split('\n')
|
||||
.find((line) => line.includes('ReadFile'));
|
||||
expect(readFileLine).not.toContain('fixed-only');
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,12 @@ export const ToolsList: React.FC<ToolsListProps> = ({
|
||||
<Text bold color={theme.text.accent}>
|
||||
{tool.displayName}
|
||||
{showDescriptions ? ` (${tool.name})` : ''}
|
||||
{tool.fixedOnly && (
|
||||
<Text bold={false} color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('[fixed-only: runs via media policies, not the model]')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.deferred ? (
|
||||
<Text bold={false} color={theme.text.secondary}>
|
||||
{' '}
|
||||
|
||||
@@ -32,6 +32,48 @@ describe('extractAtPathCommands', () => {
|
||||
expect(extractAtPathCommands('@foo')).toEqual(['foo']);
|
||||
expect(extractAtPathCommands('@foo @bar')).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
// The punctuation terminators exist to stop a filesystem path at a sentence
|
||||
// boundary, but `?`/`&`/`,`/`;` are structural in a URL. A presigned
|
||||
// OSS/S3 link carries its signature in the query string, so truncating at
|
||||
// `?` produced a request that failed with HTTP 403 — and the truncation was
|
||||
// invisible in the error, which named only the host.
|
||||
it('keeps the whole query string of a URL ref', () => {
|
||||
const presigned =
|
||||
'https://bucket.oss-cn-wulanchabu.aliyuncs.com/dir/movie.mkv' +
|
||||
'?x-oss-credential=LTAI%2F20260805%2Fcn-wulanchabu%2Foss%2Faliyun_v4_request' +
|
||||
'&x-oss-date=20260805T064817Z&x-oss-expires=32400' +
|
||||
'&x-oss-signature-version=OSS4-HMAC-SHA256&x-oss-signature=f1454dcb';
|
||||
expect(extractAtPathCommands(`@${presigned} 视频里有什么`)).toEqual([
|
||||
presigned,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let URL punctuation leak into filesystem path parsing', () => {
|
||||
// The same characters must still terminate a plain path.
|
||||
expect(extractAtPathCommands('Look at @src/a.ts, then @src/b.ts;')).toEqual(
|
||||
['src/a.ts', 'src/b.ts'],
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing sentence punctuation from a URL ref', () => {
|
||||
const url = 'https://example.com/clip.mp4';
|
||||
// ASCII and CJK terminators, and the query-string case where `?` is
|
||||
// structural but the final `。` is prose.
|
||||
expect(extractAtPathCommands(`@${url}. Explain it`)).toEqual([url]);
|
||||
expect(extractAtPathCommands(`@${url}。分析一下`)).toEqual([url]);
|
||||
expect(extractAtPathCommands(`@${url}?v=2, 这是什么`)).toEqual([
|
||||
`${url}?v=2`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps whitespace as the delimiter between a URL ref and the prompt', () => {
|
||||
const url = 'https://example.com/a.mp4?token=x&sig=y';
|
||||
expect(extractAtPathCommands(`@${url} @src/notes.md`)).toEqual([
|
||||
url,
|
||||
'src/notes.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAtCommand', () => {
|
||||
@@ -2119,4 +2161,399 @@ describe('handleAtCommand', () => {
|
||||
expect(JSON.stringify(result.processedQuery)).toContain('COLON BODY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL media references (@https://…)', () => {
|
||||
// The omni graph is imported dynamically inside the URL branch, so a
|
||||
// module-registry mock is what intercepts it. Every export the branch
|
||||
// touches is stubbed; the pipeline is exercised end to end from
|
||||
// handleAtCommand down to the fileData part.
|
||||
const omniMocks = vi.hoisted(() => ({
|
||||
isOmniDeliveryActive: vi.fn(),
|
||||
parseHttpUrlRef: vi.fn(),
|
||||
downloadMediaUrl: vi.fn(),
|
||||
processMediaForOmniDelivery: vi.fn(),
|
||||
effectiveMaxDownloadFileBytes: vi.fn(),
|
||||
sniffFileModality: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core/omni', () => ({
|
||||
...omniMocks,
|
||||
// Deterministic stand-ins for the shared formatters: the tests assert
|
||||
// the WIRING (which formatter, which arguments, part ordering), while
|
||||
// the formatters' own wording is covered by their core unit tests.
|
||||
formatOmissionText: (name: string, reason: string) =>
|
||||
`[omission ${name}: ${reason}]`,
|
||||
formatDisclosureText: (name: string, disclosure: string) =>
|
||||
`[disclosure ${name}: ${disclosure}]`,
|
||||
formatResourceHandleText: (name: string, resourceId: string) =>
|
||||
`[handle ${name}: ${resourceId}]`,
|
||||
// Deterministic stand-in for the shared multi-output materializer:
|
||||
// one marker part per extra. The real builder's output shape is
|
||||
// covered by its core unit tests; these tests pin the wiring (that
|
||||
// the funnel calls it and splices its parts after the primary slot).
|
||||
buildAdditionalMediaParts: (name: string, extras?: unknown[]) =>
|
||||
(extras ?? []).map((_, i) => ({ text: `[extra ${name} ${i}]` })),
|
||||
// Same wiring-only stand-in for the shared transcript materializer.
|
||||
buildTranscriptParts: (name: string, transcripts?: unknown[]) =>
|
||||
(transcripts ?? []).map((_, i) => ({
|
||||
text: `[transcript ${name} ${i}]`,
|
||||
})),
|
||||
OmniObjectStore: class {
|
||||
getOmniRootDir() {
|
||||
return path.join(os.tmpdir(), 'omni-at-test');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function omniConfig(
|
||||
enabled: boolean,
|
||||
modalities: Record<string, boolean> = {
|
||||
image: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
},
|
||||
): Config {
|
||||
return {
|
||||
...mockConfig,
|
||||
isOmniEnabled: () => enabled,
|
||||
getContentGeneratorConfig: () => ({ modalities }),
|
||||
storage: { getQwenDir: () => path.join(os.tmpdir(), 'omni-at-test') },
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
omniMocks.isOmniDeliveryActive.mockReturnValue(true);
|
||||
omniMocks.parseHttpUrlRef.mockImplementation((url: string) => ({ url }));
|
||||
omniMocks.effectiveMaxDownloadFileBytes.mockReturnValue(1024 * 1024);
|
||||
omniMocks.sniffFileModality.mockResolvedValue('video');
|
||||
const partPath = path.join(testRootDir, 'downloaded.mp4');
|
||||
await createTestFile(partPath, 'fake media bytes');
|
||||
omniMocks.downloadMediaUrl.mockResolvedValue({ partPath });
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
fileUri: 'oss://bucket/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
recognized: { modality: 'video', sizeBytes: 2 * 1024 * 1024 },
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes a media URL into a fileData part when omni is active', async () => {
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 700,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(omniMocks.downloadMediaUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'https://example.com/clip.mp4' }),
|
||||
);
|
||||
// Guard/error messages inside the pipeline must name the URL's file,
|
||||
// not the opaque staging path the download landed under.
|
||||
expect(omniMocks.processMediaForOmniDelivery).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ displayName: 'clip.mp4' }),
|
||||
);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
expect(parts).toContainEqual({
|
||||
fileData: {
|
||||
fileUri: 'oss://bucket/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
displayName: 'clip.mp4',
|
||||
},
|
||||
});
|
||||
// The URL stays verbatim in the prompt text so the model can correlate
|
||||
// it with the delivered part.
|
||||
expect(parts[0]!['text']).toContain('https://example.com/clip.mp4');
|
||||
expect(result.toolDisplays![0]).toMatchObject({
|
||||
name: 'Fetch Media URL',
|
||||
status: ToolCallStatus.Success,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to text (no download) when omni is disabled', async () => {
|
||||
const query = 'summarize @https://example.com/clip.mp4 please';
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: omniConfig(false),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 701,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Legacy behavior: the URL is left as text, nothing is fetched.
|
||||
expect(omniMocks.downloadMediaUrl).not.toHaveBeenCalled();
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
});
|
||||
|
||||
it('does not hit the no-content early return for a URL-only prompt', async () => {
|
||||
// urlMediaRefs must count as content; otherwise the pipeline runs and
|
||||
// its parts/cards are discarded by the early return.
|
||||
const result = await handleAtCommand({
|
||||
query: '@https://example.com/clip.mp4',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 702,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
expect(parts.some((p) => 'fileData' in p)).toBe(true);
|
||||
expect(mockOnDebugMessage).not.toHaveBeenCalledWith(
|
||||
'No valid file paths found in @ commands to read.',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips store/upload when the sniffed modality is not in the model modalities', async () => {
|
||||
// Mirrors the local-file path's per-modality gate: media the active
|
||||
// model cannot consume must not be uploaded only to be replaced by an
|
||||
// unsupported-modality placeholder in the converter.
|
||||
omniMocks.sniffFileModality.mockResolvedValue('image');
|
||||
const result = await handleAtCommand({
|
||||
query: 'look @https://example.com/pic.jpg please',
|
||||
config: omniConfig(true, { image: false, audio: true, video: true }),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 703,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(omniMocks.processMediaForOmniDelivery).not.toHaveBeenCalled();
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
expect(parts.some((p) => 'fileData' in p)).toBe(false);
|
||||
expect(result.toolDisplays![0]).toMatchObject({
|
||||
name: 'Fetch Media URL',
|
||||
status: ToolCallStatus.Error,
|
||||
});
|
||||
expect(
|
||||
(result.toolDisplays![0] as { resultDisplay: string }).resultDisplay,
|
||||
).toContain('does not accept image input');
|
||||
});
|
||||
|
||||
it('isolates a failing URL: error card shown, remaining URLs still delivered', async () => {
|
||||
omniMocks.downloadMediaUrl
|
||||
.mockRejectedValueOnce(new Error('URL host did not resolve'))
|
||||
.mockResolvedValueOnce({
|
||||
partPath: path.join(testRootDir, 'downloaded.mp4'),
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'compare @https://bad.example/a.mp4 @https://good.example/b.mp4',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 704,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(result.toolDisplays).toHaveLength(2);
|
||||
expect(result.toolDisplays![0]).toMatchObject({
|
||||
status: ToolCallStatus.Error,
|
||||
});
|
||||
expect(
|
||||
(result.toolDisplays![0] as { resultDisplay: string }).resultDisplay,
|
||||
).toContain('bad.example');
|
||||
expect(result.toolDisplays![1]).toMatchObject({
|
||||
status: ToolCallStatus.Success,
|
||||
});
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
expect(parts.filter((p) => 'fileData' in p)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces the media with an omission notice when the transport guard withholds it', async () => {
|
||||
// Policy design §10.2: an omission is a successful delivery whose
|
||||
// content IS the notice — no fileData part, no error card.
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
omission: { reason: 'video exceeds the 500MB transport limit' },
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 706,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
expect(parts).toContainEqual({
|
||||
text: '[omission clip.mp4: video exceeds the 500MB transport limit]',
|
||||
});
|
||||
expect(parts.some((p) => 'fileData' in p)).toBe(false);
|
||||
expect(result.toolDisplays![0]).toMatchObject({
|
||||
name: 'Fetch Media URL',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: 'Media omitted by the omni transport guard: clip.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('leads the part group with the session resource handle (M §5.2)', async () => {
|
||||
// A delivery mints a registry binding and writes memory records for
|
||||
// the file. If the funnel never tells the model the handle, active
|
||||
// recall rejects it as never-issued and the passive selector finds no
|
||||
// handles to consult — the session can never recall media it just
|
||||
// paid an upload to collect.
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
fileUri: 'oss://bucket/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
recognized: { modality: 'video', sizeBytes: 2 * 1024 * 1024 },
|
||||
resourceId: 'media-3-9f2a',
|
||||
disclosure: '原 1080p → 480p',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 731,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
const handleIdx = parts.findIndex(
|
||||
(p) => p['text'] === '[handle clip.mp4: media-3-9f2a]',
|
||||
);
|
||||
expect(handleIdx).toBeGreaterThan(-1);
|
||||
// Ahead of the disclosure, so the disclosure keeps its D8 adjacency
|
||||
// to the media part it speaks for.
|
||||
const disclosureIdx = parts.findIndex(
|
||||
(p) => p['text'] === '[disclosure clip.mp4: 原 1080p → 480p]',
|
||||
);
|
||||
expect(disclosureIdx).toBe(handleIdx + 1);
|
||||
expect(parts[disclosureIdx + 1]).toMatchObject({
|
||||
fileData: { fileUri: 'oss://bucket/clip.mp4' },
|
||||
});
|
||||
});
|
||||
|
||||
it('still discloses the handle when the guard withholds the media', async () => {
|
||||
// The withheld case is where recall matters MOST: the model cannot
|
||||
// see the media, so a handle it can hand to omni_recall_media_memory
|
||||
// (or a policy tool) is its only remaining route to the content.
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
recognized: { modality: 'video', sizeBytes: 900 * 1024 * 1024 },
|
||||
omission: { reason: 'video exceeds the transport limit' },
|
||||
resourceId: 'media-4-c1d0',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 732,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
const handleIdx = parts.findIndex(
|
||||
(p) => p['text'] === '[handle clip.mp4: media-4-c1d0]',
|
||||
);
|
||||
expect(handleIdx).toBeGreaterThan(-1);
|
||||
expect(parts[handleIdx + 1]).toEqual({
|
||||
text: '[omission clip.mp4: video exceeds the transport limit]',
|
||||
});
|
||||
});
|
||||
|
||||
it('places the degradation disclosure text immediately before the fileData part (D8)', async () => {
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
fileUri: 'oss://bucket/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
recognized: { modality: 'video', sizeBytes: 2 * 1024 * 1024 },
|
||||
degraded: true,
|
||||
disclosure: '原 1080p → 480p,细节受损',
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 707,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
const disclosureIdx = parts.findIndex(
|
||||
(p) => p['text'] === '[disclosure clip.mp4: 原 1080p → 480p,细节受损]',
|
||||
);
|
||||
const fileDataIdx = parts.findIndex((p) => 'fileData' in p);
|
||||
expect(disclosureIdx).toBeGreaterThan(-1);
|
||||
expect(fileDataIdx).toBe(disclosureIdx + 1);
|
||||
expect(
|
||||
(result.toolDisplays![0] as { resultDisplay: string }).resultDisplay,
|
||||
).toContain('(degraded by media policy)');
|
||||
});
|
||||
|
||||
it('splices additionalMedia parts after the primary fileData part (multi-output policies)', async () => {
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
fileUri: 'oss://bucket/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
recognized: { modality: 'video', sizeBytes: 2 * 1024 * 1024 },
|
||||
additionalMedia: [
|
||||
{ fileUri: 'oss://bucket/frame2', mimeType: 'image/jpeg' },
|
||||
{ fileUri: 'oss://bucket/frame3', mimeType: 'image/jpeg' },
|
||||
],
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 708,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
const fileDataIdx = parts.findIndex((p) => 'fileData' in p);
|
||||
expect(fileDataIdx).toBeGreaterThan(-1);
|
||||
// The materialized extras follow the primary media part directly.
|
||||
expect(parts[fileDataIdx + 1]).toEqual({ text: '[extra clip.mp4 0]' });
|
||||
expect(parts[fileDataIdx + 2]).toEqual({ text: '[extra clip.mp4 1]' });
|
||||
});
|
||||
|
||||
it('splices additionalMedia parts after the omission notice when the primary is withheld', async () => {
|
||||
omniMocks.processMediaForOmniDelivery.mockResolvedValue({
|
||||
omission: { reason: 'video exceeds the transport limit' },
|
||||
additionalMedia: [
|
||||
{ fileUri: 'oss://bucket/frame2', mimeType: 'image/jpeg' },
|
||||
],
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4 please',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 709,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
const parts = result.processedQuery as Array<Record<string, unknown>>;
|
||||
const omissionIdx = parts.findIndex(
|
||||
(p) =>
|
||||
p['text'] ===
|
||||
'[omission clip.mp4: video exceeds the transport limit]',
|
||||
);
|
||||
expect(omissionIdx).toBeGreaterThan(-1);
|
||||
expect(parts[omissionIdx + 1]).toEqual({ text: '[extra clip.mp4 0]' });
|
||||
});
|
||||
|
||||
it('ends the turn quietly (shouldProceed=false) on a user abort mid-download', async () => {
|
||||
omniMocks.downloadMediaUrl.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
throw new Error('aborted');
|
||||
});
|
||||
const result = await handleAtCommand({
|
||||
query: 'summarize @https://example.com/clip.mp4',
|
||||
config: omniConfig(true),
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 705,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(false);
|
||||
expect(result.processedQuery).toBeNull();
|
||||
expect(omniMocks.processMediaForOmniDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,14 +121,31 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
}
|
||||
|
||||
// Parse @path
|
||||
//
|
||||
// A URL ref (`@https://…`) is delimited differently from a filesystem
|
||||
// path. The punctuation terminators below exist to stop a path at a
|
||||
// sentence boundary ("see file.txt, then…"), but `?`, `&`, `,` and `;`
|
||||
// are structural in a URL — a presigned OSS/S3 link carries its signature
|
||||
// in the query string, so terminating at `?` would silently drop it and
|
||||
// the request would fail with HTTP 403.
|
||||
//
|
||||
// Instead a URL runs to the first character RFC 3986 does not permit
|
||||
// unencoded. That covers whitespace and also CJK prose written with no
|
||||
// space ("@https://host/a.mp4。分析一下"), which a whitespace-only rule
|
||||
// would swallow into the URL.
|
||||
const isUrlRef = /^https?:\/\//i.test(query.slice(atIndex + 1));
|
||||
// unreserved / gen-delims / sub-delims / pct-encoding, per RFC 3986.
|
||||
const NON_URL_CHAR = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]/;
|
||||
let pathEndIndex = atIndex + 1;
|
||||
let inEscape = false;
|
||||
while (pathEndIndex < query.length) {
|
||||
const char = query[pathEndIndex];
|
||||
const char = query[pathEndIndex]!;
|
||||
if (inEscape) {
|
||||
inEscape = false;
|
||||
} else if (char === '\\') {
|
||||
inEscape = true;
|
||||
} else if (isUrlRef) {
|
||||
if (NON_URL_CHAR.test(char)) break;
|
||||
} else if (/[,\s;!?()[\]{}]/.test(char)) {
|
||||
// Path ends at first whitespace or punctuation not escaped
|
||||
break;
|
||||
@@ -143,7 +160,18 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
}
|
||||
pathEndIndex++;
|
||||
}
|
||||
const rawAtPath = query.substring(atIndex, pathEndIndex);
|
||||
let rawAtPath = query.substring(atIndex, pathEndIndex);
|
||||
if (isUrlRef) {
|
||||
// `.`/`,`/`;`/`!`/`?` are legal URL characters but are usually prose
|
||||
// when they land at the very end ("@https://host/a.mp4. Explain it").
|
||||
// Trimmed after the scan rather than treated as terminators, so they
|
||||
// can never cut a URL short mid-query. Brackets and parens are left
|
||||
// alone: they appear balanced inside real URLs, and a wrapped URL still
|
||||
// yields a named error rather than a silently mangled request.
|
||||
const trimmed = rawAtPath.replace(/[.,;:!?]+$/, '');
|
||||
pathEndIndex = atIndex + trimmed.length;
|
||||
rawAtPath = trimmed;
|
||||
}
|
||||
// unescapePath expects the @ symbol to be present, and will handle it.
|
||||
const atPath = unescapePath(rawAtPath);
|
||||
parts.push({ type: 'atPath', content: atPath });
|
||||
@@ -280,6 +308,19 @@ export async function resolveAtCommandQuery({
|
||||
ref: { id?: string; title?: string };
|
||||
}> = [];
|
||||
|
||||
// URL media references (`@https://…`) collected during the loop and
|
||||
// localized (downloaded → recognized → promoted into the omni object
|
||||
// store) after it. Only active when omni delivery is on; otherwise URL
|
||||
// tokens keep today's fall-through behavior (left as text).
|
||||
const urlMediaRefs: Array<{ originalAtPath: string; url: string }> = [];
|
||||
/** `@`-referenced media whose bytes are gone but whose memory survives:
|
||||
* re-anchored to a session handle so recall stays reachable. */
|
||||
const rememberedMediaRefs: Array<{
|
||||
originalAtPath: string;
|
||||
pathName: string;
|
||||
annotation: string;
|
||||
}> = [];
|
||||
|
||||
for (const atPathPart of atPathCommandParts) {
|
||||
const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
|
||||
|
||||
@@ -292,6 +333,23 @@ export async function resolveAtCommandQuery({
|
||||
|
||||
const pathName = originalAtPath.substring(1);
|
||||
|
||||
// URL media reference (`@https://…`): detected BEFORE every other
|
||||
// parser — a URL can't be an extension/session/MCP ref, and without
|
||||
// this branch it would fall through to filesystem resolution where the
|
||||
// ENOENT skip silently drops it. Only intercepted when omni delivery
|
||||
// is active; otherwise preserve the legacy text fall-through.
|
||||
if (/^https?:\/\//i.test(pathName) && config.isOmniEnabled?.()) {
|
||||
const omni = await import('@qwen-code/qwen-code-core/omni');
|
||||
if (omni.isOmniDeliveryActive(config) && omni.parseHttpUrlRef(pathName)) {
|
||||
if (!urlMediaRefs.some((r) => r.url === pathName)) {
|
||||
urlMediaRefs.push({ originalAtPath, url: pathName });
|
||||
}
|
||||
// Keep the URL verbatim in the text sent to the model.
|
||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extension reference (`@ext:<name>`): detected BEFORE MCP/filesystem
|
||||
// resolution. Only matches when the path starts with `ext:` and the name
|
||||
// corresponds to an active extension.
|
||||
@@ -470,9 +528,43 @@ export async function resolveAtCommandQuery({
|
||||
}
|
||||
}
|
||||
if (!resolvedSuccessfully && sawNotFound) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
// The bytes are gone, but media memory may still hold everything
|
||||
// that was ever derived from them (transcripts, keyframes, the
|
||||
// processing history). A handle is normally minted only at delivery,
|
||||
// which needs the bytes — so that knowledge used to be unreachable
|
||||
// for good. The user's own `@`-reference is the same authorization a
|
||||
// delivery carries, so re-anchor from the recorded identity and hand
|
||||
// the model a handle it can recall with (design M §9.2.1).
|
||||
let reanchored = false;
|
||||
if (config.isOmniEnabled?.()) {
|
||||
const { reanchorRememberedMedia } = await import(
|
||||
'@qwen-code/qwen-code-core/omni'
|
||||
);
|
||||
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
||||
const anchor = await reanchorRememberedMedia(
|
||||
config,
|
||||
path.resolve(dir, pathName),
|
||||
);
|
||||
if (!anchor) continue;
|
||||
rememberedMediaRefs.push({
|
||||
originalAtPath,
|
||||
pathName,
|
||||
annotation: anchor.annotation,
|
||||
});
|
||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
||||
onDebugMessage(
|
||||
`Path ${pathName} is gone but remembered; re-anchored as ` +
|
||||
`${anchor.resourceId} (bytes unavailable).`,
|
||||
);
|
||||
reanchored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!reanchored) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!resolvedSuccessfully && deferredIgnoreReason) {
|
||||
ignoredByReason[deferredIgnoreReason].push(pathName);
|
||||
@@ -546,6 +638,214 @@ export async function resolveAtCommandQuery({
|
||||
),
|
||||
);
|
||||
|
||||
// URL media localization: download → recognize → promote into the omni
|
||||
// object store → upload → fileData part. Sequential (media downloads can
|
||||
// be large; parallel GiB transfers would contend), failure-isolated per
|
||||
// URL (an error card is shown and the turn continues — mirroring the MCP
|
||||
// resource block's Promise.allSettled semantics).
|
||||
const urlMediaParts: Part[] = [];
|
||||
const urlMediaDisplays: IndividualToolCallDisplay[] = [];
|
||||
const urlMediaLabels: string[] = [];
|
||||
if (urlMediaRefs.length > 0) {
|
||||
const core = await import('@qwen-code/qwen-code-core/omni');
|
||||
for (let i = 0; i < urlMediaRefs.length; i++) {
|
||||
const ref = urlMediaRefs[i];
|
||||
const callId = `client-url-media-${userMessageTimestamp}-${i}`;
|
||||
const hostLabel = (() => {
|
||||
try {
|
||||
return new URL(ref.url).hostname;
|
||||
} catch {
|
||||
return 'invalid-url';
|
||||
}
|
||||
})();
|
||||
let tempPartPath: string | undefined;
|
||||
try {
|
||||
const store = new core.OmniObjectStore(config.storage.getQwenDir());
|
||||
const downloadsDir = path.join(store.getOmniRootDir(), 'downloads');
|
||||
const downloaded = await core.downloadMediaUrl({
|
||||
url: ref.url,
|
||||
downloadsDir,
|
||||
maxBytes: core.effectiveMaxDownloadFileBytes(config),
|
||||
signal,
|
||||
});
|
||||
tempPartPath = downloaded.partPath;
|
||||
// Full local-file pipeline on the downloaded bytes: sniff/probe/
|
||||
// hash again (the design requires re-recognition from the local
|
||||
// file — never trust transfer-time observations), guard, store,
|
||||
// upload. Displays and error messages need a stable name; use the
|
||||
// URL basename.
|
||||
const urlBase = path.basename(new URL(ref.url).pathname) || hostLabel;
|
||||
// Per-modality gate, mirroring the local-file path in fileUtils:
|
||||
// media the active model cannot consume must not be stored and
|
||||
// uploaded — the fileData part would only be replaced by an
|
||||
// unsupported-modality placeholder in the converter, after paying
|
||||
// for the upload. (The download itself is unavoidable: modality
|
||||
// is only knowable from the bytes.) A null sniff falls through to
|
||||
// the pipeline, whose recognition failure names the problem.
|
||||
const modalities =
|
||||
config.getContentGeneratorConfig?.()?.modalities ?? {};
|
||||
const sniffedModality = await core.sniffFileModality(
|
||||
downloaded.partPath,
|
||||
);
|
||||
if (sniffedModality && !modalities[sniffedModality]) {
|
||||
urlMediaDisplays.push({
|
||||
callId,
|
||||
name: 'Fetch Media URL',
|
||||
description: `Download ${ref.url}`,
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: `Skipped ${urlBase}: the active model does not accept ${sniffedModality} input.`,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const delivery = await core.processMediaForOmniDelivery(
|
||||
downloaded.partPath,
|
||||
config,
|
||||
// displayName: guard/error messages must name the URL's file, not
|
||||
// the opaque staging path the download landed under. sourceUrl:
|
||||
// the staging path is deleted in the finally below, so memory
|
||||
// must anchor this media's identity to the object store and
|
||||
// record the URL as its source — a handle bound to the staging
|
||||
// path would resolve to ENOENT for the rest of the session.
|
||||
{ signal, displayName: urlBase, sourceUrl: ref.url },
|
||||
);
|
||||
// §6.2/D8 ordering contract documented on buildTranscriptParts.
|
||||
const transcriptParts = core.buildTranscriptParts(
|
||||
urlBase,
|
||||
delivery.transcripts,
|
||||
);
|
||||
// Additional media Parts (multi-output fixed policies): follow the
|
||||
// primary media slot in every branch below.
|
||||
const additionalParts = core.buildAdditionalMediaParts(
|
||||
urlBase,
|
||||
delivery.additionalMedia,
|
||||
);
|
||||
// Session resource handle (memory design M §5.2): leads the part
|
||||
// group in every branch below, exactly as readMediaViaOmniDelivery
|
||||
// does it. Without this, URL-delivered media accrues memory records
|
||||
// and an issued registry binding that the model is never told about
|
||||
// — active recall rejects the unknown handle and the passive
|
||||
// selector finds no handles to consult, so the session can never
|
||||
// recall what it just spent an upload collecting. Placed FIRST so
|
||||
// the disclosure keeps its D8 adjacency to the media part.
|
||||
const handleParts = delivery.resourceId
|
||||
? [
|
||||
{
|
||||
text: core.formatResourceHandleText(
|
||||
urlBase,
|
||||
delivery.resourceId,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (delivery.omission) {
|
||||
// Explicit omission (policy design §10.2): the media is withheld
|
||||
// and the omission notice text stands in its place — mirroring
|
||||
// readMediaViaOmniDelivery. Not an error: the fetch succeeded;
|
||||
// the transport guard's verdict is the content.
|
||||
urlMediaParts.push(...handleParts);
|
||||
urlMediaParts.push({
|
||||
text: core.formatOmissionText(urlBase, delivery.omission.reason),
|
||||
});
|
||||
urlMediaParts.push(...additionalParts);
|
||||
urlMediaParts.push(...transcriptParts);
|
||||
urlMediaLabels.push(ref.url);
|
||||
urlMediaDisplays.push({
|
||||
callId,
|
||||
name: 'Fetch Media URL',
|
||||
description: `Downloaded ${ref.url}`,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: `Media omitted by the omni transport guard: ${urlBase}`,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!delivery.fileUri && transcriptParts.length > 0) {
|
||||
// Pure-transcript delivery (§6.2): the policies replaced the
|
||||
// media with text-only deliverables — no media Part is emitted
|
||||
// for the primary (additional deliverables, if any, still are).
|
||||
// The primary disclosure (chained prior lossy steps, decision
|
||||
// D8) still renders: the transcript was derived through them.
|
||||
urlMediaParts.push(...handleParts);
|
||||
if (delivery.disclosure) {
|
||||
urlMediaParts.push({
|
||||
text: core.formatDisclosureText(urlBase, delivery.disclosure),
|
||||
});
|
||||
}
|
||||
urlMediaParts.push(...additionalParts);
|
||||
urlMediaParts.push(...transcriptParts);
|
||||
urlMediaLabels.push(ref.url);
|
||||
urlMediaDisplays.push({
|
||||
callId,
|
||||
name: 'Fetch Media URL',
|
||||
description: `Downloaded ${ref.url}`,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: `Localized ${urlBase} and delivered as transcript (omni policy).`,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
urlMediaParts.push(...handleParts);
|
||||
// Disclosure IMMEDIATELY before its media part (decision D8):
|
||||
// provider converters that relocate media move the pair together.
|
||||
if (delivery.disclosure) {
|
||||
urlMediaParts.push({
|
||||
text: core.formatDisclosureText(urlBase, delivery.disclosure),
|
||||
});
|
||||
}
|
||||
urlMediaParts.push({
|
||||
fileData: {
|
||||
fileUri: delivery.fileUri,
|
||||
mimeType: delivery.mimeType,
|
||||
displayName: urlBase,
|
||||
},
|
||||
});
|
||||
urlMediaParts.push(...additionalParts);
|
||||
urlMediaParts.push(...transcriptParts);
|
||||
urlMediaLabels.push(ref.url);
|
||||
urlMediaDisplays.push({
|
||||
callId,
|
||||
name: 'Fetch Media URL',
|
||||
description: `Downloaded ${ref.url}`,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: `Localized ${urlBase} (${delivery.recognized.modality}, ${(delivery.recognized.sizeBytes / 1024 / 1024).toFixed(1)}MB) and delivered via omni upload${delivery.degraded ? ' (degraded by media policy)' : ''}.`,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
// User cancelled mid-download: end the turn quietly instead of
|
||||
// throwing — resolveAtCommandQuery has no throw contract, and a
|
||||
// rejection here would surface as an unhandled-rejection banner
|
||||
// (cancelOngoingRequest already handles the UI reset).
|
||||
if (tempPartPath) {
|
||||
await fs.rm(tempPartPath, { force: true }).catch(() => {});
|
||||
}
|
||||
return {
|
||||
processedQuery: null,
|
||||
shouldProceed: false,
|
||||
toolDisplays: [...urlMediaDisplays],
|
||||
filesRead: urlMediaLabels,
|
||||
// No chat-recording entry for a user-cancelled resolution.
|
||||
};
|
||||
}
|
||||
const reason = getErrorMessage(error);
|
||||
onDebugMessage(`Failed to localize media URL ${ref.url}: ${reason}`);
|
||||
urlMediaDisplays.push({
|
||||
callId,
|
||||
name: 'Fetch Media URL',
|
||||
description: `Download ${ref.url}`,
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: `Failed to fetch media from ${hostLabel}: ${reason}`,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (tempPartPath) {
|
||||
await fs.rm(tempPartPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resourceParts: Part[] = [];
|
||||
const resourceDisplays: IndividualToolCallDisplay[] = [];
|
||||
const resourceLabels: string[] = [];
|
||||
@@ -606,7 +906,11 @@ export async function resolveAtCommandQuery({
|
||||
mcpResourceRefs.length === 0 &&
|
||||
mcpServerMentions.length === 0 &&
|
||||
extensionMentions.length === 0 &&
|
||||
sessionMentions.length === 0
|
||||
sessionMentions.length === 0 &&
|
||||
// URL media refs count as content too: without this, a URL-only prompt
|
||||
// would run the whole download→upload pipeline and then discard the
|
||||
// delivered parts (and their display cards) via this early return.
|
||||
urlMediaRefs.length === 0
|
||||
) {
|
||||
onDebugMessage('No valid file paths found in @ commands to read.');
|
||||
if (initialQueryText === '@' && query.trim() === '@') {
|
||||
@@ -951,6 +1255,7 @@ export async function resolveAtCommandQuery({
|
||||
...scopedMentionLabels,
|
||||
...contentLabelsForDisplay,
|
||||
...resourceLabels,
|
||||
...urlMediaLabels,
|
||||
];
|
||||
return {
|
||||
processedQuery: null,
|
||||
@@ -958,6 +1263,7 @@ export async function resolveAtCommandQuery({
|
||||
toolDisplays: [
|
||||
...scopedMentionDisplays,
|
||||
...resourceDisplays,
|
||||
...urlMediaDisplays,
|
||||
errorToolCallDisplay,
|
||||
],
|
||||
filesRead: labelsOnError,
|
||||
@@ -975,16 +1281,30 @@ export async function resolveAtCommandQuery({
|
||||
// its content block via the "--- Content from ... ---" delimiter labels (and
|
||||
// the verbatim `@server:uri` / `@path` left in the prompt text), not by
|
||||
// positional alignment, so grouping is safe.
|
||||
// Re-anchored media: a handle annotation plus an explicit note that the
|
||||
// bytes are gone, so the model recalls instead of trying to read.
|
||||
const rememberedMediaParts: Part[] = rememberedMediaRefs.flatMap((ref) => [
|
||||
{ text: ref.annotation },
|
||||
{
|
||||
text:
|
||||
`【媒体缺失】${ref.pathName}:文件已不在磁盘上,无法投递画面/音频。` +
|
||||
`其处理记忆仍可用——用上面的句柄调用 omni_recall_media_memory 取回。`,
|
||||
},
|
||||
]);
|
||||
const processedQueryParts: PartListUnion = [
|
||||
{ text: initialQueryText },
|
||||
...scopedMentionParts,
|
||||
...fileParts,
|
||||
...resourceParts,
|
||||
...urlMediaParts,
|
||||
...rememberedMediaParts,
|
||||
];
|
||||
const allLabels = [
|
||||
...scopedMentionLabels,
|
||||
...contentLabelsForDisplay,
|
||||
...resourceLabels,
|
||||
...urlMediaLabels,
|
||||
...rememberedMediaRefs.map((ref) => `${ref.pathName} (记忆)`),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -994,6 +1314,7 @@ export async function resolveAtCommandQuery({
|
||||
...scopedMentionDisplays,
|
||||
...fileDisplays,
|
||||
...resourceDisplays,
|
||||
...urlMediaDisplays,
|
||||
],
|
||||
filesRead: allLabels,
|
||||
recording: {
|
||||
|
||||
@@ -13138,6 +13138,7 @@ describe('useLlmStream', () => {
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
executionOrigin: { kind: 'client' },
|
||||
}),
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
@@ -16513,6 +16514,39 @@ describe('useLlmStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps media preparation cancellable before the model stream starts', async () => {
|
||||
let preparationSignal: AbortSignal | undefined;
|
||||
handleAtCommandSpy.mockImplementation(({ signal }) => {
|
||||
preparationSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
resolve({
|
||||
processedQuery: null,
|
||||
shouldProceed: false,
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
const { result } = renderTestHook();
|
||||
let submission: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
submission = result.current.submitQuery('@slow-video.mp4 inspect');
|
||||
});
|
||||
await waitFor(() => expect(preparationSignal).toBeDefined());
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
result.current.cancelOngoingRequest();
|
||||
await submission;
|
||||
});
|
||||
expect(preparationSignal?.aborted).toBe(true);
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
|
||||
const rawQuery = '@include file.txt Summarize this.';
|
||||
const processedQueryParts = [
|
||||
|
||||
@@ -1654,6 +1654,11 @@ export const useLlmStream = (
|
||||
args: toolArgs,
|
||||
isClientInitiated: true,
|
||||
prompt_id,
|
||||
// Client-direct provenance (omni policy design): slash
|
||||
// commands scheduling a tool are the in-process `client`
|
||||
// channel — subject to the same media-policy modelAccess
|
||||
// gate as model calls, never to fixed-policy semantics.
|
||||
executionOrigin: { kind: 'client' },
|
||||
};
|
||||
registerToolBatch(toolCallRequest);
|
||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
@@ -3301,11 +3306,24 @@ export const useLlmStream = (
|
||||
if (isAtCommand(message)) {
|
||||
const timeout = new AbortController();
|
||||
const atCommandSignal = AbortSignal.any([signal, timeout.signal]);
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeout.abort(
|
||||
new Error(MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE),
|
||||
);
|
||||
}, MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS);
|
||||
// URL media refs are exempt from the fixed mid-turn budget: the
|
||||
// omni URL path downloads and uploads media end-to-end inside
|
||||
// resolveAtCommandQuery under its own watchdogs (30s header, 60s
|
||||
// idle), so a 10s cap would structurally kill every mid-turn
|
||||
// @https reference — and inside the resolver the timeout abort is
|
||||
// indistinguishable from a user cancel, so the message would be
|
||||
// dropped quietly rather than failing with a named error.
|
||||
// Filesystem resolution keeps the cap unchanged.
|
||||
const hasUrlMediaRef =
|
||||
/@https?:\/\//i.test(message) &&
|
||||
(config.isOmniEnabled?.() ?? false);
|
||||
const timeoutId = hasUrlMediaRef
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timeout.abort(
|
||||
new Error(MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE),
|
||||
);
|
||||
}, MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS);
|
||||
try {
|
||||
const atCommandResult = await resolveWithAbort(
|
||||
atCommandSignal,
|
||||
@@ -3760,6 +3778,15 @@ export const useLlmStream = (
|
||||
|
||||
const releaseSubmissionActivity =
|
||||
retainSubmissionActivity(submissionGeneration);
|
||||
if (
|
||||
submitType === SendMessageType.UserQuery &&
|
||||
!allowConcurrentBtwDuringResponse &&
|
||||
!isDetachedToolContinuation
|
||||
) {
|
||||
// Media preparation can upload before the model stream starts.
|
||||
// Submission activity owns clearing this state on every exit path.
|
||||
setIsResponding(true);
|
||||
}
|
||||
const submission = promptIdContext.run(prompt_id, async () => {
|
||||
let queuedGoal = metadata?.goal;
|
||||
let preparedQuery: {
|
||||
|
||||
@@ -398,6 +398,9 @@ export interface ToolDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
/** Omni media-policy tool that only runs via fixed policies: it is hidden
|
||||
* from the model's declarations, so /tools annotates it for the human. */
|
||||
fixedOnly?: boolean;
|
||||
/**
|
||||
* Registered, but its schema is not in the eager model request — the tool
|
||||
* is reached on demand via `tool_search`. Set for `shouldDefer` tools and
|
||||
|
||||
@@ -67,6 +67,38 @@ describe('parseInputForHighlighting', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Must stay in sync with parseAllAtCommands: the parser consumes the whole
|
||||
// presigned URL (query string included), so the input box has to paint the
|
||||
// same span — a half-highlighted URL reads as "the rest will be dropped".
|
||||
it('should highlight a URL ref through its full query string', () => {
|
||||
const url =
|
||||
'https://bucket.oss-cn-wulanchabu.aliyuncs.com/dir/%E8%BE%93%E5%87%BA.mp4' +
|
||||
'?x-oss-credential=LTAI%2F20260805%2Foss&x-oss-signature=f1454dcb';
|
||||
const text = `@${url} 这个视频是谁做的`;
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: `@${url}`, type: 'file' },
|
||||
{ text: ' 这个视频是谁做的', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not paint trailing sentence punctuation of a URL ref', () => {
|
||||
const text = '@https://example.com/a.mp4. Explain it';
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: '@https://example.com/a.mp4', type: 'file' },
|
||||
{ text: '. Explain it', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should match the URL scheme case-insensitively, like the parser', () => {
|
||||
// parseAllAtCommands matches /^https?:\/\//i — an uppercase scheme must
|
||||
// not fall back to the narrower filesystem charset and half-paint.
|
||||
const url = 'HTTPS://example.com/a.mp4?sig=abc%2Fdef';
|
||||
expect(parseInputForHighlighting(`@${url} check`, 0)).toEqual([
|
||||
{ text: `@${url}`, type: 'file' },
|
||||
{ text: ' check', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a command in the middle when preceded by whitespace', () => {
|
||||
const text = 'I need /help with this';
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
@@ -174,6 +206,17 @@ describe('parseInputForHighlighting', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should paint a URL ref with a backslash-escaped span in full', () => {
|
||||
// The parser's escape branch runs for URL refs too, so the highlighter
|
||||
// must not split the token at the backslash.
|
||||
const text = 'play @https://host/my\\ video.mp4 now';
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'play ', type: 'default' },
|
||||
{ text: '@https://host/my\\ video.mp4', type: 'file' },
|
||||
{ text: ' now', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should only highlight valid slash commands when command metadata is provided', () => {
|
||||
const text = '/help please /review this /clear and /missing plus /usr/bin';
|
||||
expect(parseInputForHighlighting(text, 0, slashCommands)).toEqual([
|
||||
|
||||
@@ -13,8 +13,20 @@ export type HighlightToken = {
|
||||
type: 'default' | 'command' | 'file';
|
||||
};
|
||||
|
||||
// The @-ref alternatives must stay in sync with parseAllAtCommands in
|
||||
// hooks/atCommandProcessor.ts, or the input box paints a different span than
|
||||
// the parser will actually consume. A URL ref (`@https://…`) runs through the
|
||||
// full RFC 3986 charset — `%`, `?`, `&`, `=` are structural in a presigned
|
||||
// URL — while a filesystem ref keeps the narrower path charset. The `\\ `
|
||||
// alternative and the `\` in the class mirror the parser's backslash-escape
|
||||
// branch, which runs for URL refs too — an escaped span must stay painted
|
||||
// rather than split the token.
|
||||
const HIGHLIGHT_REGEX =
|
||||
/(^\/[a-zA-Z][a-zA-Z0-9:_-]*)|((?<=\s)\/[a-zA-Z][a-zA-Z0-9:_-]*)|(@(?:\\ |[a-zA-Z0-9_.:/-])+)/g;
|
||||
/(^\/[a-zA-Z][a-zA-Z0-9:_-]*)|((?<=\s)\/[a-zA-Z][a-zA-Z0-9:_-]*)|(@[hH][tT][tT][pP][sS]?:\/\/(?:\\ |[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%\\])+)|(@(?:\\ |[a-zA-Z0-9_.:/-])+)/g;
|
||||
|
||||
// Mirrors the parser's trailing-punctuation trim: '.'/','/';'/':'/'!'/'?' at
|
||||
// the very end of a URL are prose, not part of the ref.
|
||||
const TRAILING_URL_PUNCTUATION = /[.,;:!?]+$/;
|
||||
|
||||
export function parseInputForHighlighting(
|
||||
text: string,
|
||||
@@ -38,9 +50,20 @@ export function parseInputForHighlighting(
|
||||
|
||||
HIGHLIGHT_REGEX.lastIndex = 0;
|
||||
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
||||
const [fullMatch] = match;
|
||||
let [fullMatch] = match;
|
||||
const matchIndex = match.index;
|
||||
|
||||
if (match[3] !== undefined) {
|
||||
// URL ref: trailing sentence punctuation is prose (parser trims it the
|
||||
// same way), so paint it default rather than file-colored.
|
||||
const trimmed = fullMatch.replace(TRAILING_URL_PUNCTUATION, '');
|
||||
if (trimmed.length > 1) {
|
||||
fullMatch = trimmed;
|
||||
// Rewind so the trimmed tail is re-scanned as default text.
|
||||
HIGHLIGHT_REGEX.lastIndex = matchIndex + trimmed.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the text before the match as a default token
|
||||
if (matchIndex > lastIndex) {
|
||||
tokens.push({
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"@qwen-code/qwen-code-core/transcriptRecords": [
|
||||
"../core/src/utils/transcript-records.ts"
|
||||
],
|
||||
"@qwen-code/qwen-code-core/omni": ["../core/src/omni/index.ts"],
|
||||
"@qwen-code/qwen-code-core/omniPolicyCollection": [
|
||||
"../core/src/omni/policy/model-call-collection.ts"
|
||||
],
|
||||
"@qwen-code/qwen-code-core/goalWire": ["../core/src/goals/goal-wire.ts"],
|
||||
"@qwen-code/qwen-code-core/noFollowOpen": [
|
||||
"../core/src/utils/no-follow-open.ts"
|
||||
|
||||
@@ -19,6 +19,10 @@ const toAliases = (map: Record<string, string>) =>
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@qwen-code\/qwen-code-core\/omni$/,
|
||||
replacement: path.resolve(__dirname, '../core/src/omni/index.ts'),
|
||||
},
|
||||
// Named core subpaths. None of these targets can be derived from the
|
||||
// specifier (noFollowOpen lives at utils/no-follow-open.ts), so they
|
||||
// have to be matched ahead of the wildcard below. A new named subpath
|
||||
@@ -26,6 +30,10 @@ export default defineConfig({
|
||||
// the wildcard, or the wildcard will claim it and point at a file
|
||||
// that does not exist.
|
||||
...toAliases({
|
||||
'@qwen-code/qwen-code-core/omniPolicyCollection': path.resolve(
|
||||
__dirname,
|
||||
'../core/src/omni/policy/model-call-collection.ts',
|
||||
),
|
||||
'@qwen-code/qwen-code-core/noFollowOpen': path.resolve(
|
||||
__dirname,
|
||||
'../core/src/utils/no-follow-open.ts',
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./omni": {
|
||||
"types": "./dist/src/omni/index.d.ts",
|
||||
"import": "./dist/src/omni/index.js"
|
||||
},
|
||||
"./omniPolicyCollection": {
|
||||
"types": "./dist/src/omni/policy/model-call-collection.d.ts",
|
||||
"import": "./dist/src/omni/policy/model-call-collection.js"
|
||||
},
|
||||
"./subagentRuntime": {
|
||||
"types": "./dist/src/subagent-runtime.d.ts",
|
||||
"import": "./dist/src/subagent-runtime.js"
|
||||
@@ -175,6 +183,7 @@
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yauzl": "^2.9.1",
|
||||
"msw": "^2.3.4",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Part } from '@google/genai';
|
||||
import { ApprovalMode } from '../config/approval-mode.js';
|
||||
import { CoreToolScheduler } from '../core/coreToolScheduler.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
import { ExecTool } from '../tools/exec.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { Kind, type MediaPolicyToolDescriptor } from '../tools/tools.js';
|
||||
|
||||
class PolicyTool extends MockTool {
|
||||
override get mediaPolicyDescriptor(): MediaPolicyToolDescriptor {
|
||||
return {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [{ kind: 'media', required: true }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setup(omniEnabled = false) {
|
||||
const config = makeFakeConfig({
|
||||
codeModeOnly: true,
|
||||
omniEnabled,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
targetDir: '/tmp',
|
||||
cwd: '/tmp',
|
||||
});
|
||||
const registry = new ToolRegistry(config);
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
|
||||
const completed = vi.fn();
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config,
|
||||
onAllToolCallsComplete: async (calls) => completed(calls),
|
||||
onToolCallsUpdate: vi.fn(),
|
||||
getPreferredEditor: () => undefined,
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
return { config, registry, completed, scheduler };
|
||||
}
|
||||
|
||||
describe('Omni integration with CodeModeOnly', () => {
|
||||
it.each([true, false])(
|
||||
'admits fixed_policy only for a media tool (descriptor=%s)',
|
||||
async (policy) => {
|
||||
const { registry, completed, scheduler } = setup();
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
llmContent: 'processed',
|
||||
returnDisplay: 'processed',
|
||||
});
|
||||
registry.registerTool(
|
||||
new (policy ? PolicyTool : MockTool)({
|
||||
name: 'omni_downsample_image',
|
||||
kind: Kind.Read,
|
||||
execute,
|
||||
}),
|
||||
);
|
||||
await scheduler.schedule(
|
||||
{
|
||||
callId: 'fixed-policy',
|
||||
name: 'omni_downsample_image',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'policy-prompt',
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'guard',
|
||||
stage: 'transport_guard',
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
|
||||
const call = completed.mock.calls[0]?.[0][0];
|
||||
expect(call?.status).toBe(policy ? 'success' : 'error');
|
||||
expect(execute).toHaveBeenCalledTimes(policy ? 1 : 0);
|
||||
if (!policy) expect(call.response.executionStatus).toBe('not_started');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'retains uploaded media and annotations even after a later script error (%s)',
|
||||
async (scriptFails) => {
|
||||
const { config, registry, completed, scheduler } = setup(true);
|
||||
registry.registerTool(new ExecTool(config));
|
||||
const parts: Part[] = [
|
||||
{ text: '【媒体资源】sample.png:media-1-ab' },
|
||||
{ text: '【媒体处理说明】sample.png:2048px → 768px' },
|
||||
{ fileData: { mimeType: 'image/png', fileUri: 'oss://sample' } },
|
||||
{ text: 'derived transcript' },
|
||||
];
|
||||
registry.registerTool(
|
||||
new MockTool({
|
||||
name: 'read_media',
|
||||
kind: Kind.Read,
|
||||
execute: async () => ({
|
||||
llmContent: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'read_media',
|
||||
id: 'exec-media:code:1',
|
||||
response: { output: 'read sample.png' },
|
||||
parts,
|
||||
},
|
||||
},
|
||||
],
|
||||
returnDisplay: 'read sample.png',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await scheduler.schedule(
|
||||
{
|
||||
callId: 'exec-media',
|
||||
name: 'exec',
|
||||
args: {
|
||||
source: `const result = await tools.read_media({}); result.content?.forEach(image); ${scriptFails ? "throw new Error('later failure');" : ''}`,
|
||||
},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'media-prompt',
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
|
||||
const call = completed.mock.calls[0]?.[0][0];
|
||||
expect(call?.status).toBe('success');
|
||||
const response = call.response.responseParts[0].functionResponse;
|
||||
expect(response.parts).toEqual(parts);
|
||||
expect(response.response.output).toContain('read sample.png');
|
||||
if (scriptFails)
|
||||
expect(response.response.output).toContain('later failure');
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
@@ -1388,6 +1388,82 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('omni quarantine budget getters', () => {
|
||||
it('passes through positive settings', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniQuarantineRetentionDays: 3,
|
||||
omniQuarantineMaxBytes: 1024,
|
||||
});
|
||||
expect(config.getOmniQuarantineRetentionDays()).toBe(3);
|
||||
expect(config.getOmniQuarantineMaxBytes()).toBe(1024);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unset', undefined],
|
||||
['zero', 0],
|
||||
['negative', -1],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
])(
|
||||
'falls back to defaults on a %s setting (a bad value must not expire the whole quarantine)',
|
||||
(_label, bad) => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniQuarantineRetentionDays: bad,
|
||||
omniQuarantineMaxBytes: bad,
|
||||
});
|
||||
expect(config.getOmniQuarantineRetentionDays()).toBe(7);
|
||||
expect(config.getOmniQuarantineMaxBytes()).toBe(5 * 1024 * 1024 * 1024);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('omni storage GC getters (settings → sweep knobs)', () => {
|
||||
it('passes through valid settings', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniStorageRetentionDays: 3,
|
||||
omniStorageMaxTotalBytes: 1024,
|
||||
});
|
||||
expect(config.getOmniStorageRetentionDays()).toBe(3);
|
||||
expect(config.getOmniStorageMaxTotalBytes()).toBe(1024);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unset', undefined],
|
||||
['zero', 0],
|
||||
['negative', -1],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
// Sub-day retention would gut the multi-process grace window the
|
||||
// GC's safety argument leans on — the schema promises minimum 1.
|
||||
['sub-day', 0.5],
|
||||
])('retentionDays falls back to 14 on a %s setting', (_label, bad) => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniStorageRetentionDays: bad,
|
||||
});
|
||||
expect(config.getOmniStorageRetentionDays()).toBe(14);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unset', undefined],
|
||||
['zero', 0],
|
||||
['negative', -1],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
])('maxTotalBytes falls back to 20 GiB on a %s setting', (_label, bad) => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniStorageMaxTotalBytes: bad,
|
||||
});
|
||||
expect(config.getOmniStorageMaxTotalBytes()).toBe(
|
||||
20 * 1024 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory file count compatibility', () => {
|
||||
it('keeps the legacy parameter and accessors until a future major release', () => {
|
||||
const config = new Config({ ...baseParams, geminiMdFileCount: 2 });
|
||||
@@ -12209,6 +12285,61 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(wasShellToolRegistered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('omni media-memory recall exposure (D10)', () => {
|
||||
/** Register a fresh omni-enabled registry and report what it holds.
|
||||
* `createToolRegistry` (not `initialize`) is the unit under test: it
|
||||
* is where the mode decision happens, and it runs before the omni
|
||||
* normalization block in startup. */
|
||||
async function registeredToolNames(
|
||||
omniMemory?: Record<string, unknown>,
|
||||
): Promise<string[]> {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
omniEnabled: true,
|
||||
...(omniMemory !== undefined ? { omniMemory } : {}),
|
||||
});
|
||||
await config.createToolRegistry(undefined, { skipDiscovery: true });
|
||||
const registerFactoryMock = (
|
||||
(await vi.importMock('../tools/tool-registry')) as {
|
||||
ToolRegistry: { prototype: { registerFactory: Mock } };
|
||||
}
|
||||
).ToolRegistry.prototype.registerFactory;
|
||||
return (registerFactoryMock as Mock).mock.calls.map(
|
||||
(call) => call[0] as string,
|
||||
);
|
||||
}
|
||||
|
||||
it('exposes the recall tool in active mode', async () => {
|
||||
const names = await registeredToolNames({ recall: { mode: 'active' } });
|
||||
expect(names).toContain(ToolNames.OMNI_RECALL_MEDIA_MEMORY);
|
||||
});
|
||||
|
||||
it('withholds the recall tool in sideQuery mode', async () => {
|
||||
// The two recall surfaces are mutually exclusive: in sideQuery mode
|
||||
// the harness injects recall itself before every request. Leaving
|
||||
// the tool registered as well would let the model spend a tool call
|
||||
// re-fetching memory it was already handed — and the registration
|
||||
// is decided once, here, so nothing downstream can take it back.
|
||||
const names = await registeredToolNames({
|
||||
recall: { mode: 'sideQuery' },
|
||||
});
|
||||
expect(names).not.toContain(ToolNames.OMNI_RECALL_MEDIA_MEMORY);
|
||||
// The rest of the omni toolset still registered — proof the tool is
|
||||
// missing because of the mode, not because omni was off.
|
||||
expect(names).toContain(ToolNames.OMNI_DOWNSAMPLE_IMAGE);
|
||||
});
|
||||
|
||||
it('aborts startup on an invalid omni.memory setting', async () => {
|
||||
// A rejected `omni.memory` must never degrade to defaults: the
|
||||
// default is `active`, so a typo in the mode would silently hand the
|
||||
// model a recall tool in a session the user configured for passive
|
||||
// injection — the exact silent fallback the normalizer forbids.
|
||||
await expect(
|
||||
registeredToolNames({ recall: { mode: 'passive' } }),
|
||||
).rejects.toThrow(/omni\.memory\.recall\.mode/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTruncateToolOutputThreshold', () => {
|
||||
|
||||
@@ -41,6 +41,13 @@ import {
|
||||
selectVisionBridgeModel,
|
||||
} from '../services/visionBridge/vision-bridge-service.js';
|
||||
import type { AnyToolInvocation } from '../tools/tools.js';
|
||||
import type {
|
||||
NormalizedOmniProcessingConfig,
|
||||
OmniPolicyToolsSettings,
|
||||
} from '../omni/policy/types.js';
|
||||
import type { OmniUploadConfig } from '../omni/upload-config.js';
|
||||
import type { NormalizedOmniMemoryConfig } from '../services/media-memory/config.js';
|
||||
import { MediaResourceRegistry } from '../services/media-memory/registry.js';
|
||||
import type { ArenaManager } from '../agents/arena/ArenaManager.js';
|
||||
import { ArenaAgentClient } from '../agents/arena/ArenaAgentClient.js';
|
||||
import type { TeamManager } from '../agents/team/TeamManager.js';
|
||||
@@ -1197,6 +1204,47 @@ export interface ConfigParameters {
|
||||
artifactPublisher?: 'local' | 'host' | 'oss';
|
||||
artifactHost?: ArtifactHostConfig;
|
||||
artifactOss?: ArtifactOssConfig;
|
||||
/** Omni multimodal experiment: enable the upload-based media delivery
|
||||
* pipeline (omni-experiment branch). */
|
||||
omniEnabled?: boolean;
|
||||
/** Per-file byte ceiling for omni media uploads (default 1 GiB). */
|
||||
omniMaxUploadFileBytes?: number;
|
||||
/** Estimated-token ceiling for omni media (0/unset = guard disabled). */
|
||||
omniMaxEstimatedTokens?: number;
|
||||
omniMaxDurationSeconds?: number;
|
||||
/** Byte ceiling for omni URL downloads (unset = follow upload cap). */
|
||||
omniUrlDownloadMaxFileBytes?: number;
|
||||
/** Upload URL TTL in hours (0 disables the cache; default 47). */
|
||||
omniUploadUrlTtlHours?: number;
|
||||
/** Dedicated DashScope upload endpoint, independent of inference. */
|
||||
omniUploadBaseUrl?: string;
|
||||
/** Environment variable containing the dedicated upload API key. */
|
||||
omniUploadApiKeyEnv?: string;
|
||||
/** Model sent to the DashScope upload-policy endpoint. */
|
||||
omniUploadModel?: string;
|
||||
/** Raw `omni.processing.policyTools` map (per-tool settings/runtime/
|
||||
* modelAccess). Normalized lazily by the omni policy modules. */
|
||||
omniPolicyTools?: OmniPolicyToolsSettings;
|
||||
/** Raw `omni.processing.fixedPolicies` map (id → policy | null
|
||||
* tombstone). Normalized at startup against system defaults. */
|
||||
omniFixedPolicies?: Record<string, unknown>;
|
||||
/** Raw `omni.processing.transportGuard.policies` map. */
|
||||
omniTransportGuardPolicies?: Record<string, unknown>;
|
||||
/** Raw `omni.processing.limits` per-root derivation budgets. */
|
||||
omniProcessingLimits?: Record<string, unknown>;
|
||||
/** `omni.storage.quarantine.retentionDays` (default 7). */
|
||||
omniQuarantineRetentionDays?: number;
|
||||
/** `omni.storage.quarantine.maxBytes` (default 5 GiB). */
|
||||
omniQuarantineMaxBytes?: number;
|
||||
/** `omni.storage.retentionDays` — days an unreferenced object survives
|
||||
* before GC may sweep it (default 14). */
|
||||
omniStorageRetentionDays?: number;
|
||||
/** `omni.storage.maxTotalBytes` — soft byte budget for the object
|
||||
* store (default 20 GiB). */
|
||||
omniStorageMaxTotalBytes?: number;
|
||||
/** Raw `omni.memory` settings (collection/recall). Normalized at
|
||||
* startup; invalid configuration aborts startup. */
|
||||
omniMemory?: Record<string, unknown>;
|
||||
/** Image generation model selected through `/model --image`. */
|
||||
imageModel?: string;
|
||||
/**
|
||||
@@ -2828,6 +2876,35 @@ export class Config {
|
||||
private readonly artifactPublisher: 'local' | 'host' | 'oss' = 'local';
|
||||
private readonly artifactHost?: ArtifactHostConfig;
|
||||
private readonly artifactOss?: ArtifactOssConfig;
|
||||
private readonly omniEnabled: boolean = false;
|
||||
private readonly omniMaxUploadFileBytes?: number;
|
||||
private readonly omniMaxEstimatedTokens?: number;
|
||||
private readonly omniMaxDurationSeconds?: number;
|
||||
private readonly omniUrlDownloadMaxFileBytes?: number;
|
||||
private readonly omniUploadUrlTtlHours?: number;
|
||||
private readonly omniUploadBaseUrl?: string;
|
||||
private readonly omniUploadApiKeyEnv?: string;
|
||||
private readonly omniUploadModel?: string;
|
||||
private omniUploadConfig?: OmniUploadConfig;
|
||||
private readonly omniPolicyTools?: OmniPolicyToolsSettings;
|
||||
private readonly omniFixedPolicies?: Record<string, unknown>;
|
||||
private readonly omniTransportGuardPolicies?: Record<string, unknown>;
|
||||
private readonly omniProcessingLimits?: Record<string, unknown>;
|
||||
private readonly omniQuarantineRetentionDays?: number;
|
||||
private readonly omniStorageRetentionDays?: number;
|
||||
private readonly omniStorageMaxTotalBytes?: number;
|
||||
private readonly omniQuarantineMaxBytes?: number;
|
||||
private readonly omniMemory?: Record<string, unknown>;
|
||||
/** Normalized `omni.processing` view; set once during initialize()
|
||||
* (after the tool registry exists) when omni is enabled. */
|
||||
private omniProcessingConfig?: NormalizedOmniProcessingConfig;
|
||||
/** Normalized `omni.memory` view; set once during initialize() when
|
||||
* omni is enabled. */
|
||||
private omniMemoryConfig?: NormalizedOmniMemoryConfig;
|
||||
/** Session-lifetime binder between persistent media-memory identities
|
||||
* and the opaque resource handles the model sees (M §5.2); created
|
||||
* lazily on first use, never persisted. */
|
||||
private omniMediaResourceRegistry?: MediaResourceRegistry;
|
||||
private workflowsEnabled: boolean | undefined;
|
||||
private readonly sessionWorkflowEnabled: boolean;
|
||||
private sessionWorkflowEnabledProvider?: () => boolean;
|
||||
@@ -3200,6 +3277,24 @@ export class Config {
|
||||
this.artifactPublisher = params.artifactPublisher ?? 'local';
|
||||
this.artifactHost = params.artifactHost;
|
||||
this.artifactOss = params.artifactOss;
|
||||
this.omniEnabled = params.omniEnabled ?? false;
|
||||
this.omniMaxUploadFileBytes = params.omniMaxUploadFileBytes;
|
||||
this.omniMaxEstimatedTokens = params.omniMaxEstimatedTokens;
|
||||
this.omniMaxDurationSeconds = params.omniMaxDurationSeconds;
|
||||
this.omniUrlDownloadMaxFileBytes = params.omniUrlDownloadMaxFileBytes;
|
||||
this.omniUploadUrlTtlHours = params.omniUploadUrlTtlHours;
|
||||
this.omniUploadBaseUrl = params.omniUploadBaseUrl;
|
||||
this.omniUploadApiKeyEnv = params.omniUploadApiKeyEnv;
|
||||
this.omniUploadModel = params.omniUploadModel;
|
||||
this.omniPolicyTools = params.omniPolicyTools;
|
||||
this.omniFixedPolicies = params.omniFixedPolicies;
|
||||
this.omniTransportGuardPolicies = params.omniTransportGuardPolicies;
|
||||
this.omniProcessingLimits = params.omniProcessingLimits;
|
||||
this.omniQuarantineRetentionDays = params.omniQuarantineRetentionDays;
|
||||
this.omniQuarantineMaxBytes = params.omniQuarantineMaxBytes;
|
||||
this.omniStorageRetentionDays = params.omniStorageRetentionDays;
|
||||
this.omniStorageMaxTotalBytes = params.omniStorageMaxTotalBytes;
|
||||
this.omniMemory = params.omniMemory;
|
||||
this.workflowsEnabled = params.workflowsEnabled;
|
||||
this.sessionWorkflowEnabled = params.sessionWorkflowEnabled ?? false;
|
||||
this.modelProposedGoals = params.modelProposedGoals ?? 'alwaysAsk';
|
||||
@@ -3572,6 +3667,7 @@ export class Config {
|
||||
this.getFileService();
|
||||
await this.llmClient.initialize();
|
||||
await this.toolRegistry.warmAll({ strict: true });
|
||||
await this.initializeOmniPolicies();
|
||||
logStartSession(this, new StartSessionEvent(this));
|
||||
this.provisionalWorkspaceActivated = true;
|
||||
})();
|
||||
@@ -3579,6 +3675,37 @@ export class Config {
|
||||
return activation;
|
||||
}
|
||||
|
||||
private async initializeOmniPolicies(): Promise<void> {
|
||||
// Normalize the omni fixed-policy configuration now that the tool
|
||||
// registry can resolve policy-tool references. A violation throws
|
||||
// OmniPolicyConfigError and aborts startup — a mis-configured
|
||||
// transport guard must never degrade into sending over-limit media.
|
||||
if (this.isOmniEnabled()) {
|
||||
const { normalizeOmniProcessingConfig } = await import(
|
||||
'../omni/policy/config.js'
|
||||
);
|
||||
this.omniProcessingConfig = normalizeOmniProcessingConfig(
|
||||
{
|
||||
fixedPolicies: this.omniFixedPolicies,
|
||||
transportGuardPolicies: this.omniTransportGuardPolicies,
|
||||
limits: this.omniProcessingLimits,
|
||||
policyTools: this.omniPolicyTools,
|
||||
maxUploadFileBytes: this.omniMaxUploadFileBytes,
|
||||
maxEstimatedTokens: this.omniMaxEstimatedTokens,
|
||||
urlTtlHours: this.omniUploadUrlTtlHours,
|
||||
},
|
||||
this.toolRegistry,
|
||||
);
|
||||
|
||||
// Same stance for `omni.memory`: normalized once (idempotent —
|
||||
// createToolRegistry already ran it to gate the recall tool's
|
||||
// registration on `recall.mode`); an invalid memory configuration
|
||||
// (bad budgets, unknown recall mode) throws OmniMemoryConfigError
|
||||
// and aborts startup.
|
||||
await this.ensureOmniMemoryConfig();
|
||||
}
|
||||
}
|
||||
|
||||
isProvisionalWorkspace(): boolean {
|
||||
return this.provisionalWorkspace;
|
||||
}
|
||||
@@ -3639,6 +3766,25 @@ export class Config {
|
||||
this.debugLogger.info('Config initialization started');
|
||||
await this.proxyDispatcherReady;
|
||||
options?.signal?.throwIfAborted();
|
||||
|
||||
// Omni multimodal support declares ffmpeg/ffprobe as hard runtime
|
||||
// prerequisites: fail fast at startup with an actionable message
|
||||
// instead of erroring midway through the first video interaction.
|
||||
if (this.isOmniEnabled()) {
|
||||
const { normalizeDedicatedOmniUploadConfig } = await import(
|
||||
'../omni/upload-config.js'
|
||||
);
|
||||
this.omniUploadConfig = normalizeDedicatedOmniUploadConfig({
|
||||
baseUrl: this.omniUploadBaseUrl,
|
||||
apiKeyEnv: this.omniUploadApiKeyEnv,
|
||||
model: this.omniUploadModel,
|
||||
});
|
||||
const { assertOmniRuntimeDependencies } = await import(
|
||||
'../omni/ffmpeg.js'
|
||||
);
|
||||
await assertOmniRuntimeDependencies();
|
||||
}
|
||||
|
||||
if (options?.skipFileCheckpointing === true) {
|
||||
this.fileCheckpointingEnabled = false;
|
||||
this.fileHistoryService = undefined;
|
||||
@@ -4163,6 +4309,11 @@ export class Config {
|
||||
recordStartupEvent('config_initialize_tool_warmup_end');
|
||||
}
|
||||
|
||||
if (!this.provisionalWorkspace) {
|
||||
await this.initializeOmniPolicies();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
// Fire-and-forget MCP discovery. Each server's tools land in the
|
||||
// registry as it becomes ready; the cli's AppContainer debounces
|
||||
// `setTools()` (~16ms / one frame) so the model sees the new tools
|
||||
@@ -8728,6 +8879,122 @@ export class Config {
|
||||
return this.artifactOss;
|
||||
}
|
||||
|
||||
isOmniEnabled(): boolean {
|
||||
// Bare mode means the minimal toolset and no experimental pipelines:
|
||||
// gating here (the single choke point) keeps every omni surface off —
|
||||
// tool registration, content normalization, the ffmpeg runtime
|
||||
// assertion, and the delivery gate — and deliberately wins over the
|
||||
// env-var opt-in below.
|
||||
if (this.bareMode) return false;
|
||||
// Omni is experimental and opt-in: enabled via settings or env var.
|
||||
if (process.env['QWEN_CODE_ENABLE_OMNI'] === '1') return true;
|
||||
return this.omniEnabled;
|
||||
}
|
||||
|
||||
getOmniMaxUploadFileBytes(): number | undefined {
|
||||
return this.omniMaxUploadFileBytes;
|
||||
}
|
||||
|
||||
getOmniMaxEstimatedTokens(): number | undefined {
|
||||
return this.omniMaxEstimatedTokens;
|
||||
}
|
||||
|
||||
getOmniMaxDurationSeconds(): number | undefined {
|
||||
return this.omniMaxDurationSeconds;
|
||||
}
|
||||
|
||||
getOmniUrlDownloadMaxFileBytes(): number | undefined {
|
||||
return this.omniUrlDownloadMaxFileBytes;
|
||||
}
|
||||
|
||||
getOmniUploadUrlTtlHours(): number | undefined {
|
||||
return this.omniUploadUrlTtlHours;
|
||||
}
|
||||
|
||||
getOmniUploadConfig(): OmniUploadConfig | undefined {
|
||||
return this.omniUploadConfig;
|
||||
}
|
||||
|
||||
getOmniPolicyToolsSettings(): OmniPolicyToolsSettings | undefined {
|
||||
return this.omniPolicyTools;
|
||||
}
|
||||
|
||||
async loadOmniMediaReader(): Promise<typeof import('../omni/index.js')> {
|
||||
return import('../omni/index.js');
|
||||
}
|
||||
|
||||
/** Normalized `omni.processing` view. Undefined until tool warmup
|
||||
* completes (or when omni is disabled). */
|
||||
getOmniProcessingConfig(): NormalizedOmniProcessingConfig | undefined {
|
||||
return this.omniProcessingConfig;
|
||||
}
|
||||
|
||||
/** Normalized `omni.memory` view. Undefined until initialize()
|
||||
* completes (or when omni is disabled). */
|
||||
getOmniMemoryConfig(): NormalizedOmniMemoryConfig | undefined {
|
||||
return this.omniMemoryConfig;
|
||||
}
|
||||
|
||||
/** Normalize `omni.memory` on first use (idempotent). Called from
|
||||
* createToolRegistry — which runs BEFORE initialize()'s omni
|
||||
* normalization block and needs `recall.mode` to gate the recall
|
||||
* tool's registration (D10) — and again from initialize() as a
|
||||
* no-op backstop. Invalid settings throw OmniMemoryConfigError:
|
||||
* startup-fatal on both paths. */
|
||||
private async ensureOmniMemoryConfig(): Promise<NormalizedOmniMemoryConfig> {
|
||||
if (!this.omniMemoryConfig) {
|
||||
const { normalizeOmniMemoryConfig } = await import(
|
||||
'../services/media-memory/config.js'
|
||||
);
|
||||
this.omniMemoryConfig = normalizeOmniMemoryConfig(this.omniMemory);
|
||||
}
|
||||
return this.omniMemoryConfig;
|
||||
}
|
||||
|
||||
/** Session registry binding persistent media-memory identities to the
|
||||
* opaque `resourceId` handles the model references (M §5.2). One
|
||||
* instance per session; a handle is only meaningful in the session
|
||||
* that minted it. */
|
||||
getOmniMediaResourceRegistry(): MediaResourceRegistry {
|
||||
this.omniMediaResourceRegistry ??= new MediaResourceRegistry();
|
||||
return this.omniMediaResourceRegistry;
|
||||
}
|
||||
|
||||
getOmniQuarantineRetentionDays(): number {
|
||||
// A zero/negative/NaN setting would make the recovery sweep treat the
|
||||
// whole quarantine as expired (or break its cutoff comparisons) —
|
||||
// fall back to the default instead of propagating nonsense.
|
||||
const days = this.omniQuarantineRetentionDays;
|
||||
return typeof days === 'number' && Number.isFinite(days) && days > 0
|
||||
? days
|
||||
: 7;
|
||||
}
|
||||
|
||||
getOmniQuarantineMaxBytes(): number {
|
||||
const bytes = this.omniQuarantineMaxBytes;
|
||||
return typeof bytes === 'number' && Number.isFinite(bytes) && bytes > 0
|
||||
? bytes
|
||||
: 5 * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
getOmniStorageRetentionDays(): number {
|
||||
// Zero/negative/NaN/sub-day values would gut the retention grace the
|
||||
// GC's multi-process argument leans on — fall back to the default
|
||||
// instead (the schema promises `minimum: 1`; enforce it here too,
|
||||
// since settings loading never validates jsonSchemaOverride).
|
||||
const days = this.omniStorageRetentionDays;
|
||||
return typeof days === 'number' && Number.isFinite(days) && days >= 1
|
||||
? days
|
||||
: 14;
|
||||
}
|
||||
|
||||
getOmniStorageMaxTotalBytes(): number {
|
||||
const bytes = this.omniStorageMaxTotalBytes;
|
||||
return typeof bytes === 'number' && Number.isFinite(bytes) && bytes > 0
|
||||
? bytes
|
||||
: 20 * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
resolveImageGenerationModel(
|
||||
setting: string | undefined,
|
||||
): ImageGenerationConfig | undefined {
|
||||
@@ -10960,6 +11227,136 @@ export class Config {
|
||||
await this.registerWorkflowTool(registry);
|
||||
}
|
||||
|
||||
// Omni media-policy tools: always registered when omni is enabled (the
|
||||
// fixed-policy orchestrator must be able to find them), but hidden from
|
||||
// every model-facing surface unless
|
||||
// `omni.processing.policyTools.<name>.modelAccess.enabled` opens them up
|
||||
// (see omni/policy/model-access.ts).
|
||||
if (this.isOmniEnabled()) {
|
||||
// Table-driven: each entry pairs the registered name with a lazy
|
||||
// import-and-construct factory (the module loads on first use).
|
||||
const omniPolicyToolFactories: Array<[ToolName, ToolFactory]> = [
|
||||
[
|
||||
ToolNames.OMNI_DOWNSAMPLE_IMAGE,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/downsample-image.js')
|
||||
).OmniDownsampleImageTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_DOWNSCALE_VIDEO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/downscale-video.js')
|
||||
).OmniDownscaleVideoTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_DOWNSAMPLE_AUDIO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/downsample-audio.js')
|
||||
).OmniDownsampleAudioTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_EXTRACT_KEYFRAMES,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/extract-keyframes.js')
|
||||
).OmniExtractKeyframesTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_EXTRACT_AUDIO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/extract-audio.js')
|
||||
).OmniExtractAudioTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CLIP_VIDEO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/clip-video.js')
|
||||
).OmniClipVideoTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CONVERT_IMAGE,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/convert-image.js')
|
||||
).OmniConvertImageTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_TRANSCRIBE_AUDIO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/transcribe-audio.js')
|
||||
).OmniTranscribeAudioTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CLIP_IMAGE,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/clip-image.js')
|
||||
).OmniClipImageTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CLIP_AUDIO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/clip-audio.js')
|
||||
).OmniClipAudioTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CAPTION_IMAGE,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/caption-image.js')
|
||||
).OmniCaptionImageTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CAPTION_AUDIO,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/caption-audio.js')
|
||||
).OmniCaptionAudioTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_OCR_IMAGE,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/ocr-image.js')
|
||||
).OmniOcrImageTool(this),
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_UNDERSTAND_VIDEO_SEGMENTS,
|
||||
async () =>
|
||||
new (
|
||||
await import('../omni/policy/tools/understand-video-segments.js')
|
||||
).OmniUnderstandVideoSegmentsTool(this),
|
||||
],
|
||||
];
|
||||
for (const [name, factory] of omniPolicyToolFactories) {
|
||||
await registerLazy(name, factory);
|
||||
}
|
||||
|
||||
// Active-mode memory recall (M §9, D10 mutual exclusion): the
|
||||
// normalized `omni.memory` config decides at REGISTRATION time
|
||||
// whether the tool exists at all — in sideQuery mode the passive
|
||||
// selector runs instead and this tool must never be exposed.
|
||||
// Normalized on demand because createToolRegistry runs before
|
||||
// initialize()'s omni normalization block; invalid settings are
|
||||
// startup-fatal on this path too.
|
||||
const memoryConfig = await this.ensureOmniMemoryConfig();
|
||||
if (memoryConfig.recall.mode === 'active') {
|
||||
await registerLazy(ToolNames.OMNI_RECALL_MEDIA_MEMORY, async () => {
|
||||
const { OmniRecallMediaMemoryTool } = await import(
|
||||
'../omni/recall-media-memory-tool.js'
|
||||
);
|
||||
return new OmniRecallMediaMemoryTool(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register monitor tool
|
||||
await registerLazy(ToolNames.MONITOR, async () => {
|
||||
const { MonitorTool } = await import('../tools/monitor.js');
|
||||
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
} from './turn.js';
|
||||
import { LoopType } from '../telemetry/types.js';
|
||||
import { logMemoryRecallDelivery } from '../telemetry/index.js';
|
||||
import { formatOmniMemorySideQueryReminder } from '../omni/memory-side-query.js';
|
||||
import type { MediaMemoryRecallResult } from '../services/media-memory/index.js';
|
||||
|
||||
type MockSessionStartProfiler = {
|
||||
time: Mock;
|
||||
@@ -426,6 +428,15 @@ vi.mock(
|
||||
);
|
||||
import { microcompactHistory } from '../services/microcompaction/microcompact.js';
|
||||
|
||||
// Only the selector itself is stubbed — the reminder the client injects is
|
||||
// formatted by the real omni module, so the assertions below match the
|
||||
// exact block a production passive recall would put on the wire.
|
||||
const runOmniMemorySideQueryMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../omni/memory-side-query.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../omni/memory-side-query.js')>()),
|
||||
runOmniMemorySideQuery: runOmniMemorySideQueryMock,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Array.fromAsync ponyfill, which will be available in es 2024.
|
||||
*
|
||||
@@ -2286,6 +2297,115 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('omni passive media-memory recall injection', () => {
|
||||
/** Minimal materialized recall — the client neither builds nor reads
|
||||
* this payload, it only has to get the formatted block onto the wire. */
|
||||
const recallResult = {
|
||||
status: 'hit',
|
||||
files: [{ resourceId: 'media-1-abcdef01', mediaType: 'image' }],
|
||||
entries: [{ entryId: 'e-1', kind: 'derived_media' }],
|
||||
gaps: [],
|
||||
} as unknown as MediaMemoryRecallResult;
|
||||
|
||||
function enableOmni(): void {
|
||||
(
|
||||
mockConfig as unknown as { isOmniEnabled: () => boolean }
|
||||
).isOmniEnabled = () => true;
|
||||
}
|
||||
|
||||
async function runUserQuery(): Promise<unknown[]> {
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: LlmEventType.Content, value: 'response' };
|
||||
})(),
|
||||
);
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'what changed in this clip?' }],
|
||||
new AbortController().signal,
|
||||
'prompt-omni-recall',
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// drain
|
||||
}
|
||||
return mockTurnRunFn.mock.lastCall?.[1] as unknown[];
|
||||
}
|
||||
|
||||
it('prepends the recalled block ahead of the user parts of the outgoing request', async () => {
|
||||
// This is the ONLY place the sideQuery surface reaches a model
|
||||
// request. The whole point of passive recall is that it lands
|
||||
// BEFORE the main request is sent (M §9.3) — retrofitting it after
|
||||
// the systemReminders spread, or dropping the call, leaves a
|
||||
// pipeline that still selects and materializes entries and then
|
||||
// throws them away, with no other symptom than the model behaving
|
||||
// as if memory were empty.
|
||||
enableOmni();
|
||||
runOmniMemorySideQueryMock.mockResolvedValue({
|
||||
result: recallResult,
|
||||
resourceIds: ['media-1-abcdef01'],
|
||||
});
|
||||
|
||||
const request = await runUserQuery();
|
||||
|
||||
const reminder = formatOmniMemorySideQueryReminder(recallResult);
|
||||
expect(request).toContain(reminder);
|
||||
// The user's own text has been flattened to a bare string by this
|
||||
// point; the reminder block must sit ahead of it.
|
||||
const userPartIndex = request.indexOf('what changed in this clip?');
|
||||
expect(userPartIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(request.indexOf(reminder)).toBeLessThan(userPartIndex);
|
||||
|
||||
// The selector must see the request as it stands BEFORE injection —
|
||||
// that text (and the media handles inside it) is the only thing
|
||||
// scoping passive recall to what this request actually references.
|
||||
const [params] = runOmniMemorySideQueryMock.mock.calls[0] as [
|
||||
{ requestParts: unknown[]; promptId?: string },
|
||||
];
|
||||
expect(params.promptId).toBe('prompt-omni-recall');
|
||||
expect(params.requestParts).toContain('what changed in this clip?');
|
||||
expect(params.requestParts).not.toContain(reminder);
|
||||
});
|
||||
|
||||
it('injects nothing when the selector declines', async () => {
|
||||
// A null outcome is the normal path in active mode (where the recall
|
||||
// TOOL is registered instead — see memory-side-query.test.ts for the
|
||||
// D10 gate), and also covers a request with no handles, a selector
|
||||
// timeout, and every other degraded case. None of them may put an
|
||||
// empty 【媒体记忆】 shell in front of the user's question.
|
||||
enableOmni();
|
||||
runOmniMemorySideQueryMock.mockResolvedValue(null);
|
||||
|
||||
const request = await runUserQuery();
|
||||
|
||||
expect(runOmniMemorySideQueryMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
request.filter(
|
||||
(part) =>
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
'text' in part &&
|
||||
(part as { text?: string }).text?.includes('【媒体记忆】'),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('never consults the selector when omni is off', async () => {
|
||||
// Omni is opt-in and experimental: a session without it must pay no
|
||||
// recall latency and touch no memory store.
|
||||
runOmniMemorySideQueryMock.mockResolvedValue({
|
||||
result: recallResult,
|
||||
resourceIds: [],
|
||||
});
|
||||
|
||||
const request = await runUserQuery();
|
||||
|
||||
expect(runOmniMemorySideQueryMock).not.toHaveBeenCalled();
|
||||
expect(request).not.toContain(
|
||||
formatOmniMemorySideQueryReminder(recallResult),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn a workflow's `+500k` budget measures against starts here, in the
|
||||
// one place every front end's turns go through.
|
||||
describe('turn token budget', () => {
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
resolveInteractionMode,
|
||||
resolveMainSessionOutputStyle,
|
||||
} from './prompts.js';
|
||||
import { buildOmniMediaGuidanceSection } from '../omni/media-guidance.js';
|
||||
import { getOutputStyleTurnReminder } from './output-styles.js';
|
||||
import {
|
||||
CompressionStatus,
|
||||
@@ -1630,6 +1631,11 @@ export class LlmClient {
|
||||
const base = getMainSessionBaseSystemPrompt(this.config);
|
||||
const stableLayers = {
|
||||
base,
|
||||
// Progressive media understanding contract: WHY deliveries carry
|
||||
// 【媒体降质】/【媒体省略】/【媒体转写】 markers and how to fetch
|
||||
// fuller evidence. Stable — omni config/provider don't change
|
||||
// in-session — so it belongs inside the cached static prefix.
|
||||
mediaGuidance: buildOmniMediaGuidanceSection(this.config),
|
||||
contextFiles: this.config.getUserMemory(),
|
||||
appendPrompt: this.config.getAppendSystemPrompt(),
|
||||
};
|
||||
@@ -4028,6 +4034,49 @@ export class LlmClient {
|
||||
systemReminders.unshift(userQueryMemory.prompt);
|
||||
}
|
||||
|
||||
// Omni passive media-memory recall (memory design M §9.3, D10
|
||||
// sideQuery mode): a bounded selector reads what memory knows
|
||||
// about the media handles THIS request carries and the chosen
|
||||
// entries are injected here — strictly before the main request
|
||||
// is sent (never retrofitted into a later turn). Latency is
|
||||
// bounded by sideQuery.timeoutMs; the no-op cases (mode active,
|
||||
// memory off, no handles in the request) return null without
|
||||
// model traffic, and every failure degrades to no injection.
|
||||
// Optional call: stub configs in tests may omit the method.
|
||||
if (this.config.isOmniEnabled?.()) {
|
||||
const { runOmniMemorySideQuery, formatOmniMemorySideQueryReminder } =
|
||||
await import('../omni/memory-side-query.js');
|
||||
const omniRecall = await runOmniMemorySideQuery({
|
||||
config: this.config,
|
||||
requestParts: requestToSend,
|
||||
promptId: prompt_id,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
if (omniRecall?.result) {
|
||||
systemReminders.push(
|
||||
formatOmniMemorySideQueryReminder(omniRecall.result),
|
||||
);
|
||||
// The user record was persisted before this reminder existed —
|
||||
// record the payload so the transcript (and the trajectory
|
||||
// exporter) shows what memory the model was actually given.
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordOmniRecallReminder(omniRecall.result);
|
||||
} else if (omniRecall?.reason) {
|
||||
// A degraded passive recall is invisible by construction: the
|
||||
// turn proceeds normally, just without the memory it was
|
||||
// supposed to carry. With a pinned-but-unavailable selector
|
||||
// model that is a permanent outage of the feature with nothing
|
||||
// to see, so the reason is recorded (memory design M §9.3
|
||||
// obliges recording it) rather than dropped on the floor.
|
||||
debugLogger.debug(
|
||||
`omni passive media-memory recall degraded ` +
|
||||
`(${omniRecall.reason}) for ` +
|
||||
`${omniRecall.resourceIds.length} resource(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
requestToSend = [...systemReminders, ...requestToSend];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Scheduler wiring tests for the omni media-policy protocol:
|
||||
* modelAccess gate at schedule time, fixed-policy permission bypass,
|
||||
* and raw policy-artifact capture on the success response.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { executeToolCall } from './nonInteractiveToolExecutor.js';
|
||||
import type {
|
||||
Config,
|
||||
MediaPolicyToolDescriptor,
|
||||
OmniPolicyToolsSettings,
|
||||
ToolCallRequestInfo,
|
||||
ToolRegistry,
|
||||
ToolResult,
|
||||
} from '../index.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
CoreToolScheduler,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
ToolErrorType,
|
||||
} from '../index.js';
|
||||
import type { ToolCall } from './coreToolScheduler.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
|
||||
const DESCRIPTOR: MediaPolicyToolDescriptor = {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [{ kind: 'media', required: true }],
|
||||
};
|
||||
|
||||
/** MockTool that reports itself as a media-policy tool (code-registration
|
||||
* fact — the descriptor getter, never configuration). */
|
||||
class MockMediaPolicyTool extends MockTool {
|
||||
override get mediaPolicyDescriptor(): MediaPolicyToolDescriptor {
|
||||
return DESCRIPTOR;
|
||||
}
|
||||
}
|
||||
|
||||
const FIXED_ORIGIN = {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'image-compress-v1',
|
||||
stage: 'preprocessing',
|
||||
} as const;
|
||||
|
||||
function makeConfig(options: {
|
||||
tool: MockTool;
|
||||
omniPolicyTools?: OmniPolicyToolsSettings;
|
||||
approvalMode?: ApprovalMode;
|
||||
interactive?: boolean;
|
||||
isToolEnabled?: (name: string) => Promise<boolean>;
|
||||
}): Config {
|
||||
const mockToolRegistry = {
|
||||
getTool: (name: string) =>
|
||||
name === options.tool.name ? options.tool : undefined,
|
||||
ensureTool: async (name: string) =>
|
||||
name === options.tool.name ? options.tool : undefined,
|
||||
getToolByName: (name: string) =>
|
||||
name === options.tool.name ? options.tool : undefined,
|
||||
getAllToolNames: () => [options.tool.name],
|
||||
getFunctionDeclarations: () => [],
|
||||
getAllTools: () => [options.tool],
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
return {
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getApprovalMode: () => options.approvalMode ?? ApprovalMode.DEFAULT,
|
||||
getAllowedTools: () => [],
|
||||
getPermissionsAllow: () => [],
|
||||
getPermissionsDeny: () => undefined,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getEffectiveInputModalities: () => ({ image: true }),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
},
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
getChatRecordingService: () => undefined,
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(options.interactive ?? false),
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getAutoModeDenialState: () => ({
|
||||
consecutiveBlock: 0,
|
||||
consecutiveUnavailable: 0,
|
||||
totalBlock: 0,
|
||||
totalUnavailable: 0,
|
||||
}),
|
||||
setAutoModeDenialState: vi.fn(),
|
||||
getAutoModeSettings: () => ({}),
|
||||
getOmniPolicyToolsSettings: () => options.omniPolicyTools,
|
||||
...(options.isToolEnabled
|
||||
? {
|
||||
getPermissionManager: () => ({
|
||||
isToolEnabled: options.isToolEnabled,
|
||||
findMatchingDenyRule: () => undefined,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
const request = (
|
||||
overrides: Partial<ToolCallRequestInfo> & { name: string },
|
||||
): ToolCallRequestInfo => ({
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler media-policy modelAccess gate', () => {
|
||||
it('rejects a call with a missing executionOrigin (fails closed as model) when modelAccess is absent', async () => {
|
||||
const executeFn = vi.fn();
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
});
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.errorType).toBe(ToolErrorType.EXECUTION_DENIED);
|
||||
expect(response.error?.message).toContain(
|
||||
'"omni.processing.policyTools.omni_compress_image.modelAccess.enabled": true',
|
||||
);
|
||||
expect(executeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an enabled tool with defaults + model args + lockedArguments merged', async () => {
|
||||
const executeFn: Mock = vi.fn().mockResolvedValue({
|
||||
llmContent: 'ok',
|
||||
returnDisplay: 'ok',
|
||||
} satisfies ToolResult);
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
});
|
||||
const config = makeConfig({
|
||||
tool,
|
||||
omniPolicyTools: {
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
defaultArguments: { quality: 80, format: 'jpeg' },
|
||||
lockedArguments: { output_dir: '/objects' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name, args: { quality: 55, source: 'a.png' } }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(executeFn).toHaveBeenCalledWith({
|
||||
quality: 55,
|
||||
format: 'jpeg',
|
||||
source: 'a.png',
|
||||
output_dir: '/objects',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects explicit lockedArguments keys as INVALID_TOOL_PARAMS', async () => {
|
||||
const executeFn = vi.fn();
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
});
|
||||
const config = makeConfig({
|
||||
tool,
|
||||
omniPolicyTools: {
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { output_dir: '/objects' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name, args: { output_dir: '/evil' } }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.errorType).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
|
||||
expect(response.error?.message).toContain('"output_dir"');
|
||||
expect(executeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a forged fixed_policy origin on a non-media-policy tool', async () => {
|
||||
const executeFn = vi.fn();
|
||||
const tool = new MockTool({
|
||||
name: 'run_shell_command',
|
||||
execute: executeFn,
|
||||
});
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({
|
||||
name: tool.name,
|
||||
args: { command: 'rm -rf /' },
|
||||
executionOrigin: FIXED_ORIGIN,
|
||||
}),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.errorType).toBe(ToolErrorType.EXECUTION_DENIED);
|
||||
expect(response.error?.message).toContain('not a media policy tool');
|
||||
expect(executeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler fixed_policy execution', () => {
|
||||
it('executes a fixed_policy call without confirmation even when modelAccess is disabled', async () => {
|
||||
const executeFn: Mock = vi.fn().mockResolvedValue({
|
||||
llmContent: 'compressed',
|
||||
returnDisplay: 'compressed',
|
||||
} satisfies ToolResult);
|
||||
const getDefaultPermission = vi.fn(async () => 'ask' as const);
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
getDefaultPermission,
|
||||
});
|
||||
// No omniPolicyTools at all: modelAccess disabled by default, but the
|
||||
// fixed-policy orchestrator path must still work.
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({
|
||||
name: tool.name,
|
||||
args: { source: 'a.png' },
|
||||
executionOrigin: FIXED_ORIGIN,
|
||||
}),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(executeFn).toHaveBeenCalledWith({ source: 'a.png' });
|
||||
// The interactive permission flow is skipped entirely.
|
||||
expect(getDefaultPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps confirmation for model-origin calls of the same enabled tool (bypass is origin-keyed)', async () => {
|
||||
const executeFn = vi.fn();
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
getDefaultPermission: async () => 'ask' as const,
|
||||
getConfirmationDetails: async () => ({
|
||||
type: 'info' as const,
|
||||
title: 'Confirm compression',
|
||||
prompt: 'Compress?',
|
||||
onConfirm: async () => {},
|
||||
}),
|
||||
});
|
||||
const config = makeConfig({
|
||||
tool,
|
||||
interactive: true,
|
||||
omniPolicyTools: {
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config,
|
||||
onAllToolCallsComplete: vi.fn(),
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
await scheduler.schedule(
|
||||
[request({ name: tool.name, args: { source: 'a.png' } })],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const statuses = onToolCallsUpdate.mock.calls
|
||||
.flatMap((call) => call[0] as ToolCall[])
|
||||
.map((toolCall) => toolCall.status);
|
||||
expect(statuses).toContain('awaiting_approval');
|
||||
});
|
||||
expect(executeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still enforces PermissionManager.isToolEnabled for fixed_policy calls', async () => {
|
||||
const executeFn = vi.fn();
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: executeFn,
|
||||
});
|
||||
const config = makeConfig({
|
||||
tool,
|
||||
isToolEnabled: async () => false,
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({
|
||||
name: tool.name,
|
||||
executionOrigin: FIXED_ORIGIN,
|
||||
}),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeDefined();
|
||||
expect(executeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler policyArtifacts capture', () => {
|
||||
const ARTIFACTS = [
|
||||
{
|
||||
title: 'compressed.webp',
|
||||
workspacePath: 'objects/compressed.webp',
|
||||
mimeType: 'image/webp',
|
||||
},
|
||||
];
|
||||
|
||||
it('captures raw artifacts of a successful media-policy call into policyArtifacts', async () => {
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'ok',
|
||||
returnDisplay: 'ok',
|
||||
artifacts: ARTIFACTS,
|
||||
} satisfies ToolResult),
|
||||
});
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({
|
||||
name: tool.name,
|
||||
callId: 'staging-invocation-7',
|
||||
executionOrigin: FIXED_ORIGIN,
|
||||
}),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(response.policyArtifacts).toEqual({
|
||||
toolName: 'omni_compress_image',
|
||||
invocationId: 'staging-invocation-7',
|
||||
executionOrigin: FIXED_ORIGIN,
|
||||
artifacts: ARTIFACTS,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a model origin in policyArtifacts for enabled model-origin calls', async () => {
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'ok',
|
||||
returnDisplay: 'ok',
|
||||
artifacts: ARTIFACTS,
|
||||
} satisfies ToolResult),
|
||||
});
|
||||
const config = makeConfig({
|
||||
tool,
|
||||
omniPolicyTools: {
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.policyArtifacts?.executionOrigin).toEqual({
|
||||
kind: 'model',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit policyArtifacts for ordinary tools with artifacts', async () => {
|
||||
const tool = new MockTool({
|
||||
name: 'ordinary_tool',
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'ok',
|
||||
returnDisplay: 'ok',
|
||||
artifacts: ARTIFACTS,
|
||||
} satisfies ToolResult),
|
||||
});
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(response.policyArtifacts).toBeUndefined();
|
||||
// The regular artifacts channel is unaffected.
|
||||
expect(response.artifacts).toEqual(ARTIFACTS);
|
||||
});
|
||||
|
||||
it('does not emit policyArtifacts when a media-policy call produced no artifacts', async () => {
|
||||
const tool = new MockMediaPolicyTool({
|
||||
name: 'omni_compress_image',
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'nothing to do',
|
||||
returnDisplay: 'nothing to do',
|
||||
} satisfies ToolResult),
|
||||
});
|
||||
const config = makeConfig({ tool });
|
||||
|
||||
const response = await executeToolCall(
|
||||
config,
|
||||
request({ name: tool.name, executionOrigin: FIXED_ORIGIN }),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(response.policyArtifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolCallRequestInfo,
|
||||
ToolConfirmationPayload,
|
||||
ToolExecutionOrigin,
|
||||
ToolInvocation,
|
||||
ToolInvocationGuard,
|
||||
ToolExecutionStatus,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
MOCK_TOOL_GET_DEFAULT_PERMISSION,
|
||||
MOCK_TOOL_GET_CONFIRMATION_DETAILS,
|
||||
} from '../test-utils/mock-tool.js';
|
||||
import type { MediaPolicyToolDescriptor } from '../tools/tools.js';
|
||||
import { LlmChat } from './llm-chat.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import type { HookExecutionResponse } from '../confirmation-bus/types.js';
|
||||
@@ -101,6 +103,19 @@ import {
|
||||
} from '../utils/promptIdContext.js';
|
||||
import type { ToolResultBoundaryObservation } from '../tools/tool-result-boundary-diagnostics.js';
|
||||
|
||||
/** MockTool that self-identifies as an omni media-policy tool, so a
|
||||
* `fixed_policy` execution origin passes the scheduler's origin/descriptor
|
||||
* pairing gate and reaches the code under test. */
|
||||
class MockMediaPolicyTool extends MockTool {
|
||||
override get mediaPolicyDescriptor(): MediaPolicyToolDescriptor {
|
||||
return {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ToolSpanRecord = {
|
||||
name: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
@@ -6032,6 +6047,80 @@ describe('CoreToolScheduler', () => {
|
||||
expect(runSideQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the image funnel entirely for a fixed_policy invocation', async () => {
|
||||
// Same vision-bridge setup that DOES bridge for a model-originated call
|
||||
// (see the test above) — the only difference is the execution origin.
|
||||
// A fixed-policy call's result never feeds the model (the orchestrator
|
||||
// consumes policyArtifacts directly), and running the funnel would
|
||||
// re-enter media processing from inside a policy run.
|
||||
runSideQueryMock.mockResolvedValue({ text: 'Screen says READY' });
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
llmContent: [
|
||||
{ text: 'degraded image written' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'aW1hZ2U=',
|
||||
displayName: 'degraded.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
returnDisplay: 'degraded image written',
|
||||
});
|
||||
const { scheduler, onAllToolCallsComplete } =
|
||||
createSchedulerForLegacyToolTests({
|
||||
toolsByName: new Map([
|
||||
[
|
||||
'omni_downsample_image',
|
||||
new MockMediaPolicyTool({
|
||||
name: 'omni_downsample_image',
|
||||
kind: Kind.Read,
|
||||
execute,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
visionBridge: true,
|
||||
});
|
||||
|
||||
await scheduler.schedule(
|
||||
[
|
||||
{
|
||||
callId: 'call-policy-image',
|
||||
name: 'omni_downsample_image',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-policy-image',
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'img-downsample',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
},
|
||||
],
|
||||
new AbortController().signal,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const [completed] = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[];
|
||||
if (completed.status !== 'success') {
|
||||
throw new Error(`Expected success, received ${completed.status}`);
|
||||
}
|
||||
// No vision bridge side query, no bridged text, no notice/override.
|
||||
expect(runSideQueryMock).not.toHaveBeenCalled();
|
||||
const functionResponse =
|
||||
completed.response.responseParts[0].functionResponse;
|
||||
expect(functionResponse?.response?.['output']).toContain(
|
||||
'degraded image written',
|
||||
);
|
||||
expect(functionResponse?.response?.['output']).not.toContain(
|
||||
'Screen says READY',
|
||||
);
|
||||
expect(completed.response.visionBridgeNotice).toBeUndefined();
|
||||
expect(completed.response.modelOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
it('bridges images returned with a tool error', async () => {
|
||||
runSideQueryMock.mockResolvedValue({ text: 'Dialog says access denied' });
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
@@ -14137,6 +14226,7 @@ describe('CoreToolScheduler telemetry spans', () => {
|
||||
args?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
tools?: AnyDeclarativeTool[];
|
||||
executionOrigin?: ToolExecutionOrigin;
|
||||
}): Promise<{
|
||||
scheduler: CoreToolScheduler;
|
||||
onAllToolCallsComplete: ReturnType<typeof vi.fn>;
|
||||
@@ -14154,6 +14244,9 @@ describe('CoreToolScheduler telemetry spans', () => {
|
||||
args: options.args ?? { input: 'x' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-ask',
|
||||
...(options.executionOrigin
|
||||
? { executionOrigin: options.executionOrigin }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
abortController.signal,
|
||||
@@ -14535,6 +14628,77 @@ describe('CoreToolScheduler telemetry spans', () => {
|
||||
expect(getBlockedSpans()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('denies a PreToolUse ask (no bounce) for a fixed_policy invocation', async () => {
|
||||
// Interactive session where a model-originated call WOULD bounce — the
|
||||
// exclusion must come from the execution origin alone: the orchestrator
|
||||
// awaits the call headlessly behind the scheduler, so an
|
||||
// awaiting_approval entry would sit unanswerable.
|
||||
const execute = vi.fn();
|
||||
const messageBus = askMessageBus();
|
||||
const { onAllToolCallsComplete, onToolCallsUpdate } = await scheduleWithAsk(
|
||||
{
|
||||
messageBus,
|
||||
// Media-policy tool: a fixed_policy origin on a non-policy tool
|
||||
// would be rejected by the origin/descriptor gate before the hook
|
||||
// even fires, which is not the path under test here.
|
||||
tools: [new MockMediaPolicyTool({ name: 'mockTool', execute })],
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'img-downsample',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
const completed = onAllToolCallsComplete.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as ToolCall[];
|
||||
expect(completed[0].status).toBe('error');
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
// Never bounced: no awaiting_approval transition, no blocked span.
|
||||
const statuses = onToolCallsUpdate.mock.calls.flatMap((call) =>
|
||||
(call[0] as ToolCall[]).map((tc) => tc.status),
|
||||
);
|
||||
expect(statuses).not.toContain('awaiting_approval');
|
||||
expect(getBlockedSpans()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still denies a hard PreToolUse deny for a fixed_policy invocation (fail-closed)', async () => {
|
||||
// The fixed_policy exemption is scoped to the ask-bounce ONLY: a hook
|
||||
// that hard-denies must block a policy-originated run exactly like any
|
||||
// other — policies must not become a hook-bypass channel.
|
||||
const execute = vi.fn();
|
||||
const messageBus = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
|
||||
correlationId: 'pre-hook',
|
||||
success: true,
|
||||
output: { decision: 'deny', reason: 'blocked by policy hook' },
|
||||
}),
|
||||
};
|
||||
const { onAllToolCallsComplete } = await scheduleWithAsk({
|
||||
messageBus,
|
||||
tools: [new MockMediaPolicyTool({ name: 'mockTool', execute })],
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'img-downsample',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
const completed = onAllToolCallsComplete.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as ToolCall[];
|
||||
expect(completed[0].status).toBe('error');
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a pending ask (no hang) when the signal aborts', async () => {
|
||||
const execute = vi.fn();
|
||||
const messageBus = askMessageBus();
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
ToolExecutionStatus,
|
||||
PolicyArtifactBatch,
|
||||
ToolExecutionOrigin,
|
||||
} from './turn.js';
|
||||
import type {
|
||||
AutoModeFallbackConfirmation,
|
||||
@@ -24,6 +26,7 @@ import type { Config } from '../config/config.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { evaluateMediaPolicyToolCall } from '../omni/policy/model-access.js';
|
||||
import { sanitizeToolNameForProvider } from '../utils/tool-name-utils.js';
|
||||
import { compactToolResultDisplayForHistory } from '../utils/toolResultDisplayCompaction.js';
|
||||
import {
|
||||
@@ -1780,16 +1783,47 @@ export class CoreToolScheduler {
|
||||
private async processToolResultImages(
|
||||
responseParts: Part[],
|
||||
signal: AbortSignal,
|
||||
executionOrigin?: ToolExecutionOrigin,
|
||||
): Promise<{
|
||||
responseParts: Part[];
|
||||
modelOverride?: string;
|
||||
visionBridgeNotice?: string;
|
||||
}> {
|
||||
// A fixed_policy invocation's result never feeds the model — the
|
||||
// orchestrator consumes `policyArtifacts` directly — so the image
|
||||
// funnel (omni re-delivery + vision bridge) must not run on it: it
|
||||
// would re-enter media processing from inside a policy run, wasting
|
||||
// work and re-acquiring the per-root resource gate this very run may
|
||||
// already hold (self-deadlock at concurrency 1). Applies to every
|
||||
// call site (success, timeout, tool error) via this single check.
|
||||
if (executionOrigin?.kind === 'fixed_policy') {
|
||||
return { responseParts };
|
||||
}
|
||||
let modelOverride: string | undefined;
|
||||
const notices: string[] = [];
|
||||
// Omni second normalization trigger point: convert inline tool-result
|
||||
// media into oss:// fileData BEFORE the vision bridge runs — converted
|
||||
// parts are invisible to isImagePart, so the bridge skips them. Sibling
|
||||
// step by design (§8.2): never mixed into bridge logic. Preserves the
|
||||
// identity-equality contract: unchanged input returns the same array.
|
||||
// Dynamic import keeps the omni module graph out of the ACP/serve
|
||||
// fast-path static bundle closure (mirrors fileUtils' pattern; the
|
||||
// serve bundle-closure CI check forbids iconv-scale chunks on the
|
||||
// acpAgent static path).
|
||||
let omniProcessed = responseParts;
|
||||
if (this.config.isOmniEnabled?.()) {
|
||||
const { processToolResultOmniMedia } = await import(
|
||||
'../omni/tool-result-media.js'
|
||||
);
|
||||
omniProcessed = await processToolResultOmniMedia(
|
||||
responseParts,
|
||||
this.config,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
const processedParts = await bridgeToolResultImages({
|
||||
config: this.config,
|
||||
responseParts,
|
||||
responseParts: omniProcessed,
|
||||
signal,
|
||||
onFullTurnModel: (model) => {
|
||||
if (!this.onToolResultFullTurnModel?.(model)) return false;
|
||||
@@ -2735,6 +2769,7 @@ export class CoreToolScheduler {
|
||||
const canonicalName = canonicalToolName(reqInfo.name);
|
||||
if (
|
||||
this.config.getToolMode?.() === ToolMode.CodeModeOnly &&
|
||||
reqInfo.executionOrigin?.kind !== 'fixed_policy' &&
|
||||
!isCodeModeToolCallAllowed(canonicalName, reqInfo.source ?? 'model')
|
||||
) {
|
||||
newToolCalls.push({
|
||||
@@ -2880,10 +2915,45 @@ export class CoreToolScheduler {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Omni media-policy protocol gate (before buildInvocation, so the
|
||||
// merged arguments still go through the tool's native schema and
|
||||
// business validation):
|
||||
// - model/client-origin calls of a media-policy tool require
|
||||
// modelAccess.enabled, are rejected when they explicitly name a
|
||||
// lockedArguments key, and get defaultArguments/lockedArguments
|
||||
// merged in;
|
||||
// - a fixed_policy origin on a NON-media-policy tool is rejected
|
||||
// (defense in depth: a forged origin must not become a
|
||||
// permission bypass for Shell/Edit/MCP tools);
|
||||
// - a missing origin fails closed as model.
|
||||
const policyGate = evaluateMediaPolicyToolCall({
|
||||
config: this.config,
|
||||
tool: toolInstance,
|
||||
args: reqInfo.args,
|
||||
executionOrigin: reqInfo.executionOrigin,
|
||||
});
|
||||
if (policyGate.outcome === 'reject') {
|
||||
newToolCalls.push({
|
||||
status: 'error',
|
||||
request: reqInfo,
|
||||
tool: toolInstance,
|
||||
response: createErrorResponse(
|
||||
reqInfo,
|
||||
new Error(policyGate.message),
|
||||
policyGate.reason === 'invalid_params'
|
||||
? ToolErrorType.INVALID_TOOL_PARAMS
|
||||
: ToolErrorType.EXECUTION_DENIED,
|
||||
'not_started',
|
||||
),
|
||||
durationMs: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const invocationOrError = runInRequestGoalContext(reqInfo, () =>
|
||||
this.buildInvocation(
|
||||
toolInstance,
|
||||
reqInfo.args,
|
||||
policyGate.args,
|
||||
reqInfo.callId,
|
||||
reqInfo.prompt_id,
|
||||
),
|
||||
@@ -3055,6 +3125,22 @@ export class CoreToolScheduler {
|
||||
// L3→L4→L5 Permission Flow
|
||||
// =================================================================
|
||||
|
||||
// Fixed-policy orchestrator calls skip the interactive permission
|
||||
// flow entirely: no PermissionManager ask/deny evaluation, no
|
||||
// confirmation dialog, no plan/auto classification. The remaining
|
||||
// guards still hold — PM tool-enablement ran at schedule time,
|
||||
// origin/descriptor pairing was enforced before buildInvocation,
|
||||
// and PreToolUse hooks fire (a hook deny fails the call closed)
|
||||
// at execution time.
|
||||
if (reqInfo.executionOrigin?.kind === 'fixed_policy') {
|
||||
this.setToolCallOutcome(
|
||||
reqInfo.callId,
|
||||
ToolConfirmationOutcome.ProceedAlways,
|
||||
);
|
||||
this.setStatusInternal(reqInfo.callId, 'scheduled');
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- L3→L4: Shared permission flow ----
|
||||
let toolParams = invocation.params as Record<string, unknown>;
|
||||
const flowResult = await runInRequestGoalContext(reqInfo, () =>
|
||||
@@ -5036,10 +5122,15 @@ export class CoreToolScheduler {
|
||||
// prompt, bounce the tool into the existing awaiting_approval flow
|
||||
// instead of denying it. 'denied'/'stop' (and 'ask' in a
|
||||
// non-interactive/background context where we cannot prompt) keep
|
||||
// the original deny-as-error behavior.
|
||||
// the original deny-as-error behavior. A fixed_policy invocation
|
||||
// never bounces: the orchestrator awaits it headlessly behind the
|
||||
// scheduler, so an awaiting_approval entry would sit unanswerable
|
||||
// (the confirmation UI belongs to the outer tool call) — an 'ask'
|
||||
// on a fixed-policy call fails closed as a deny instead.
|
||||
if (
|
||||
preHookResult.blockType === 'ask' &&
|
||||
!signal.aborted &&
|
||||
scheduledCall.request.executionOrigin?.kind !== 'fixed_policy' &&
|
||||
this.canPromptForAskBounce()
|
||||
) {
|
||||
// Mirror the confirmation-phase abort re-check: never open a
|
||||
@@ -5954,6 +6045,7 @@ export class CoreToolScheduler {
|
||||
const processedImages = await this.processToolResultImages(
|
||||
convertedResponse,
|
||||
signal,
|
||||
scheduledCall.request.executionOrigin,
|
||||
);
|
||||
const response = processedImages.responseParts;
|
||||
if (response !== convertedResponse) {
|
||||
@@ -5966,6 +6058,53 @@ export class CoreToolScheduler {
|
||||
...(toolResult.artifacts ?? []),
|
||||
...(postToolUseArtifacts ?? []),
|
||||
];
|
||||
// Raw media-policy artifacts, captured from the tool's OWN result —
|
||||
// deliberately excluding the PostToolUse hook artifacts merged into
|
||||
// `artifacts` above, which must never impersonate policy outputs.
|
||||
const policyArtifacts: PolicyArtifactBatch | undefined =
|
||||
scheduledCall.tool.mediaPolicyDescriptor &&
|
||||
toolResult.artifacts &&
|
||||
toolResult.artifacts.length > 0
|
||||
? {
|
||||
toolName: canonicalName,
|
||||
invocationId: callId,
|
||||
executionOrigin: scheduledCall.request.executionOrigin ?? {
|
||||
kind: 'model',
|
||||
},
|
||||
artifacts: toolResult.artifacts,
|
||||
}
|
||||
: undefined;
|
||||
// Model/client-origin successes enter the SAME OmniPolicySucceeded
|
||||
// boundary the fixed-policy orchestrator uses (memory design M
|
||||
// §7.1/§17): without this, an evidence-gathering call the advisor
|
||||
// suggested succeeds but is never recorded, so the next recall
|
||||
// reports the identical gap and the work is re-paid every session.
|
||||
// Awaited (the commit must not race the next turn's recall) and
|
||||
// internally never-throwing — collection failure cannot affect the
|
||||
// tool result (D12).
|
||||
if (
|
||||
policyArtifacts &&
|
||||
policyArtifacts.executionOrigin.kind !== 'fixed_policy' &&
|
||||
scheduledCall.tool.mediaPolicyDescriptor
|
||||
) {
|
||||
const { collectModelPolicyCall } = await import(
|
||||
'../omni/policy/model-call-collection.js'
|
||||
);
|
||||
await collectModelPolicyCall({
|
||||
config: this.config,
|
||||
batch: policyArtifacts,
|
||||
descriptor: scheduledCall.tool.mediaPolicyDescriptor,
|
||||
args: scheduledCall.invocation.params as Record<string, unknown>,
|
||||
// The REAL execution window (approval wait excluded). Without
|
||||
// it the record would hold the collection window instead, and
|
||||
// a 98-minute audio extraction reads as 0.6s.
|
||||
...('executionStartTime' in scheduledCall &&
|
||||
typeof scheduledCall.executionStartTime === 'number'
|
||||
? { startedAt: scheduledCall.executionStartTime }
|
||||
: {}),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
const successResponse: CoreToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
@@ -5991,6 +6130,7 @@ export class CoreToolScheduler {
|
||||
? { visionBridgeNotice: processedImages.visionBridgeNotice }
|
||||
: {}),
|
||||
...(artifacts.length > 0 ? { artifacts } : {}),
|
||||
...(policyArtifacts ? { policyArtifacts } : {}),
|
||||
};
|
||||
// After an APPROVED exit_plan_mode, swap the large `plan` argument
|
||||
// still sitting in the model turn's functionCall for a pointer to the
|
||||
@@ -6187,6 +6327,7 @@ export class CoreToolScheduler {
|
||||
const processedImages = await this.processToolResultImages(
|
||||
responseParts,
|
||||
signal,
|
||||
scheduledCall.request.executionOrigin,
|
||||
);
|
||||
responseParts = processedImages.responseParts;
|
||||
|
||||
@@ -6321,6 +6462,7 @@ export class CoreToolScheduler {
|
||||
const processedImages = await this.processToolResultImages(
|
||||
imageErrorParts,
|
||||
signal,
|
||||
scheduledCall.request.executionOrigin,
|
||||
);
|
||||
const bridgedErrorParts = processedImages.responseParts;
|
||||
if (
|
||||
|
||||
@@ -59,6 +59,11 @@ import {
|
||||
} from './tool-call-preparation.js';
|
||||
import { ApprovalMode } from '../config/approval-mode.js';
|
||||
|
||||
const degradeOmniMediaMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../omni/reactive-degrade.js', () => ({
|
||||
degradeOmniMediaAfterServerReject: degradeOmniMediaMock,
|
||||
}));
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
|
||||
@@ -6209,6 +6214,163 @@ describe('LlmChat', async () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe('Omni overflow recovery on LlmChat', () => {
|
||||
beforeEach(() => {
|
||||
degradeOmniMediaMock.mockReset();
|
||||
Object.assign(mockConfig, {
|
||||
getOmniProcessingConfig: () => ({
|
||||
limits: { maxTransportPasses: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rebuilds the request from degraded media before compressing history', async () => {
|
||||
const compress = vi
|
||||
.spyOn(ChatCompressionService.prototype, 'compress')
|
||||
.mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
degradeOmniMediaMock.mockImplementation(
|
||||
async (_config, history: Content[]) => {
|
||||
history.at(-1)!.parts = [
|
||||
{
|
||||
fileData: { mimeType: 'image/png', fileUri: 'oss://degraded' },
|
||||
},
|
||||
];
|
||||
return { replacedParts: 1, degradedResources: 1 };
|
||||
},
|
||||
);
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockRejectedValueOnce(new Error('context_length_exceeded'))
|
||||
.mockResolvedValueOnce(makeStreamResponse('recovered'));
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{
|
||||
message: [
|
||||
{
|
||||
fileData: { mimeType: 'image/png', fileUri: 'oss://original' },
|
||||
},
|
||||
],
|
||||
},
|
||||
'omni-recovery',
|
||||
);
|
||||
const events: StreamEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
expect(degradeOmniMediaMock).toHaveBeenCalledOnce();
|
||||
expect(compress).toHaveBeenCalledTimes(1);
|
||||
const retry = JSON.stringify(
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mock
|
||||
.calls[1][0],
|
||||
);
|
||||
expect(retry).toContain('oss://degraded');
|
||||
expect(retry).not.toContain('oss://original');
|
||||
expect(
|
||||
events.filter((event) => event.type === StreamEventType.RETRY),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds degradation and then follows the existing compression failure path', async () => {
|
||||
vi.spyOn(
|
||||
ChatCompressionService.prototype,
|
||||
'compress',
|
||||
).mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
degradeOmniMediaMock.mockResolvedValue({
|
||||
replacedParts: 1,
|
||||
degradedResources: 1,
|
||||
});
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
new Error('context_length_exceeded'),
|
||||
);
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'latest' },
|
||||
'omni-bound',
|
||||
);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow('context_length_exceeded');
|
||||
expect(degradeOmniMediaMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not degrade media on a byte-only HTTP 413', async () => {
|
||||
vi.spyOn(
|
||||
ChatCompressionService.prototype,
|
||||
'compress',
|
||||
).mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
Object.assign(new Error('Request Entity Too Large'), { status: 413 }),
|
||||
);
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'latest' },
|
||||
'omni-413',
|
||||
);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow();
|
||||
expect(degradeOmniMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates cancellation during degradation without another model request', async () => {
|
||||
const controller = new AbortController();
|
||||
degradeOmniMediaMock.mockImplementation(async () => {
|
||||
controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
});
|
||||
vi.mocked(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).mockRejectedValueOnce(new Error('context_length_exceeded'));
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{
|
||||
message: 'latest',
|
||||
config: { abortSignal: controller.signal },
|
||||
},
|
||||
'omni-cancel',
|
||||
);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
})(),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attempt reactive compression more than once per send', async () => {
|
||||
const compressedHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'summary' }] },
|
||||
@@ -14618,6 +14780,63 @@ describe('LlmChat', async () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a pending continuation when Omni media degradation takes over', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
Object.assign(mockConfig, {
|
||||
getOmniProcessingConfig: () => ({
|
||||
limits: { maxTransportPasses: 1 },
|
||||
}),
|
||||
});
|
||||
degradeOmniMediaMock.mockResolvedValue({
|
||||
replacedParts: 1,
|
||||
degradedResources: 1,
|
||||
});
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockResolvedValueOnce(cutAfter([textChunk('discarded half ')]))
|
||||
.mockRejectedValueOnce(
|
||||
new Error('prompt is too long: 135000 tokens > 128000 maximum'),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
(async function* () {
|
||||
yield textChunk('a clean answer', 'STOP');
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-transport-continuation-replaced-by-omni',
|
||||
);
|
||||
await collectStreamWithFakeTimers(stream, 10_000);
|
||||
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenCalledTimes(3);
|
||||
const thirdRequest = requestContentsOfCall(2);
|
||||
expect(
|
||||
thirdRequest.some((entry) =>
|
||||
entry.parts?.some((part) =>
|
||||
part.text?.includes('discarded half'),
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
thirdRequest.some((entry) =>
|
||||
entry.parts?.some((part) =>
|
||||
part.text?.includes('The connection dropped mid-response'),
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(chat.getHistory().at(-1)).toEqual({
|
||||
role: 'model',
|
||||
parts: [{ text: 'a clean answer' }],
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back after yielding only tool preparation metadata', async () => {
|
||||
|
||||
@@ -3481,6 +3481,7 @@ export class LlmChat {
|
||||
// model's answer starting mid-sentence.
|
||||
let transportContinuationPrefix: Part[] = [];
|
||||
let reactiveCompressionAttempted = false;
|
||||
let omniMediaDegradeAttempts = 0;
|
||||
let suppressNextRetryEvent = false;
|
||||
let streamYieldedAnyChunk = false;
|
||||
|
||||
@@ -4056,6 +4057,72 @@ export class LlmChat {
|
||||
contextOverflow.isExceeded ||
|
||||
requestPayloadOverflow.isTooLarge
|
||||
) {
|
||||
// Server-limit fallback for omni media (server-feedback-driven
|
||||
// transport guard): a request carrying oss:// media that the
|
||||
// server rejected as over its input limit is retried with the
|
||||
// media degraded one guard-ladder rung further. Runs BEFORE
|
||||
// reactive compression — history compression cannot shrink
|
||||
// media tokens, which dominate these rejections. Bounded by
|
||||
// the guard's maxTransportPasses and only armed when a
|
||||
// normalized omni processing config exists (omni sessions).
|
||||
const omniDegradeMaxAttempts =
|
||||
self.config.getOmniProcessingConfig?.()?.limits
|
||||
.maxTransportPasses ?? 0;
|
||||
if (
|
||||
contextOverflow.isExceeded &&
|
||||
!exactRoute &&
|
||||
omniMediaDegradeAttempts < omniDegradeMaxAttempts
|
||||
) {
|
||||
const degradeAttempt = omniMediaDegradeAttempts++;
|
||||
let degradeOutcome:
|
||||
| { replacedParts: number; degradedResources: number }
|
||||
| undefined;
|
||||
try {
|
||||
// Dynamic import keeps the omni pipeline out of the send
|
||||
// path for non-omni sessions (mirrors fileUtils).
|
||||
const { degradeOmniMediaAfterServerReject } = await import(
|
||||
'../omni/reactive-degrade.js'
|
||||
);
|
||||
degradeOutcome = await degradeOmniMediaAfterServerReject(
|
||||
self.config,
|
||||
self.history,
|
||||
degradeAttempt,
|
||||
{
|
||||
signal: params.config?.abortSignal,
|
||||
observedLimitTokens: contextOverflow.limitTokens,
|
||||
},
|
||||
);
|
||||
} catch (degradeError) {
|
||||
if (
|
||||
params.config?.abortSignal?.aborted ||
|
||||
isAbortError(degradeError)
|
||||
) {
|
||||
throw degradeError;
|
||||
}
|
||||
debugLogger.warn(
|
||||
'Omni media degradation fallback failed.',
|
||||
degradeError,
|
||||
);
|
||||
}
|
||||
if (degradeOutcome && degradeOutcome.replacedParts > 0) {
|
||||
self.popPendingPartialAssistantTurn();
|
||||
requestContents = self.getRequestHistoryForRoute(
|
||||
currentUserContent,
|
||||
requestModalities,
|
||||
);
|
||||
debugLogger.warn(
|
||||
`Server input limit exceeded; degraded ` +
|
||||
`${degradeOutcome.degradedResources} omni media ` +
|
||||
`resource(s) in place (attempt ${degradeAttempt + 1}/` +
|
||||
`${omniDegradeMaxAttempts}); retrying.`,
|
||||
);
|
||||
resetTransportContinuation();
|
||||
yield { type: StreamEventType.RETRY };
|
||||
suppressNextRetryEvent = true;
|
||||
rearmQuietAcceptanceIfBudgetSpent();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Whether this pass (or a previous one) spent the one-shot
|
||||
// payload-overflow recovery; when it did and the error is
|
||||
// still a payload overflow, the wrap below turns it into an
|
||||
|
||||
@@ -39,6 +39,7 @@ const hash = getProjectHash(projectDir);
|
||||
const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home');
|
||||
|
||||
let originalHome: string | undefined;
|
||||
let originalRuntimeDir: string | undefined;
|
||||
let testGeminiDir: string;
|
||||
let testLogFilePath: string;
|
||||
let testCheckpointFilePath: string;
|
||||
@@ -121,6 +122,11 @@ describe('Logger', () => {
|
||||
vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
|
||||
originalHome = process.env['HOME'];
|
||||
process.env['HOME'] = TEST_HOME_DIR;
|
||||
// Self-hosted CI runners export QWEN_RUNTIME_DIR for their own qwen
|
||||
// tooling; it outranks HOME-derived paths in Storage, so these
|
||||
// path-expectation tests must run with it cleared.
|
||||
originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
setTestPaths();
|
||||
// Clean up before the test
|
||||
await cleanupLogAndCheckpointFiles();
|
||||
@@ -143,6 +149,11 @@ describe('Logger', () => {
|
||||
} else {
|
||||
process.env['HOME'] = originalHome;
|
||||
}
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -147,6 +147,21 @@ describe('defaultModalities', () => {
|
||||
expect(m.audio).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns full multimodal for qwen omni models', () => {
|
||||
for (const model of [
|
||||
'qwen3.5-omni-plus',
|
||||
'qwen3-omni-flash',
|
||||
'qwen-omni-turbo',
|
||||
]) {
|
||||
expect(defaultModalities(model)).toEqual({
|
||||
image: true,
|
||||
pdf: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns image + video for qwen3.7-plus', () => {
|
||||
const m = defaultModalities('qwen3.7-plus');
|
||||
expect(m.image).toBe(true);
|
||||
|
||||
@@ -41,6 +41,11 @@ const MODALITY_PATTERNS: Array<[RegExp, InputModalities]> = [
|
||||
// -------------------
|
||||
// Alibaba / Qwen
|
||||
// -------------------
|
||||
// Qwen Omni models: full multimodal (image + audio + video) — the omni
|
||||
// harness targets these. Must precede the qwen3.x-plus/qwen fallbacks:
|
||||
// "qwen3.5-omni-plus" would otherwise match /^qwen/ and be text-only.
|
||||
[/^qwen\d*\.?\d*-omni/, FULL_MULTIMODAL],
|
||||
[/^qwen-omni/, FULL_MULTIMODAL],
|
||||
// Qwen Plus models: image + video support
|
||||
[/^qwen3\.5-plus/, { image: true, video: true }],
|
||||
[/^qwen3\.6-plus/, { image: true, video: true }],
|
||||
|
||||
@@ -1625,6 +1625,180 @@ describe('OpenAIContentConverter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('moves an omni degradation disclosure together with its media part when splitting tool media', () => {
|
||||
// The omni pipeline emits a disclosure text Part IMMEDIATELY before
|
||||
// each lossy derivative's media Part. When splitToolMedia relocates
|
||||
// the media into the follow-up user message, the disclosure must move
|
||||
// WITH it — stranded in the text-only tool message, the model could
|
||||
// not attribute it to the media. Ordinary text parts stay behind.
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { id: 'call_1', name: 'Read', args: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'Image content' },
|
||||
parts: [
|
||||
{ text: 'ordinary tool text' },
|
||||
{ text: '【媒体降质】photo.png:downsampled to 1568px' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'base64encodedimagedata',
|
||||
},
|
||||
},
|
||||
] as unknown as Part[],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertLlmRequestToOpenAI(request, {
|
||||
...requestContext,
|
||||
splitToolMedia: true,
|
||||
});
|
||||
|
||||
const toolMessage = messages.find((m) => m.role === 'tool');
|
||||
expect(toolMessage?.content).toBe('Image content\nordinary tool text');
|
||||
|
||||
const userMessage = messages.find((m) => m.role === 'user');
|
||||
const userContent = userMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}>;
|
||||
expect(userContent.map((p) => p.type)).toEqual([
|
||||
'text',
|
||||
'text',
|
||||
'image_url',
|
||||
]);
|
||||
expect(userContent[0].text).toBe(
|
||||
'(attached media from previous tool call)',
|
||||
);
|
||||
// Disclosure sits immediately before its media part.
|
||||
expect(userContent[1].text).toBe(
|
||||
'【媒体降质】photo.png:downsampled to 1568px',
|
||||
);
|
||||
expect(userContent[2].image_url?.url).toBe(
|
||||
'data:image/png;base64,base64encodedimagedata',
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a bare keyframe timestamp marker together with its frame', () => {
|
||||
// Per-frame markers (`<MM:SS>`) carry no 【媒体降质】 prefix — the
|
||||
// degradation notice rode once on the first frame's header — but they
|
||||
// must still migrate WITH their image so each frame keeps its label.
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { id: 'call_1', name: 'Read', args: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'frames' },
|
||||
parts: [
|
||||
{ text: '<00:34>' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'framebytes',
|
||||
},
|
||||
},
|
||||
] as unknown as Part[],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertLlmRequestToOpenAI(request, {
|
||||
...requestContext,
|
||||
splitToolMedia: true,
|
||||
});
|
||||
|
||||
// The marker did NOT stay stranded in the tool message text.
|
||||
const toolMessage = messages.find((m) => m.role === 'tool');
|
||||
expect(toolMessage?.content).toBe('frames');
|
||||
|
||||
const userContent = messages.find((m) => m.role === 'user')
|
||||
?.content as Array<{ type: string; text?: string }>;
|
||||
expect(userContent.map((p) => p.type)).toEqual([
|
||||
'text',
|
||||
'text',
|
||||
'image_url',
|
||||
]);
|
||||
expect(userContent[1].text).toBe('<00:34>');
|
||||
});
|
||||
|
||||
it('gives a disclosure only to the media part directly following it', () => {
|
||||
// Two media parts after one disclosure: only the adjacent one owns
|
||||
// it — the second media part must not pull the disclosure past the
|
||||
// first (prev-tracking, not "last disclosure seen").
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { id: 'call_1', name: 'Read', args: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'two images' },
|
||||
parts: [
|
||||
{ text: '【媒体降质】a.png:lossy' },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'first' } },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'second' } },
|
||||
] as unknown as Part[],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertLlmRequestToOpenAI(request, {
|
||||
...requestContext,
|
||||
splitToolMedia: true,
|
||||
});
|
||||
const userMessage = messages.find((m) => m.role === 'user');
|
||||
const userContent = userMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}>;
|
||||
expect(userContent.map((p) => p.type)).toEqual([
|
||||
'text',
|
||||
'text',
|
||||
'image_url',
|
||||
'image_url',
|
||||
]);
|
||||
expect(userContent[1].text).toBe('【媒体降质】a.png:lossy');
|
||||
expect(userContent[2].image_url?.url).toBe('data:image/png;base64,first');
|
||||
});
|
||||
|
||||
it('should keep all tool messages contiguous and merge split media into a single follow-up user message for parallel tool calls (issue #3616)', () => {
|
||||
// Two assistant tool calls in parallel. Both responses come back in the
|
||||
// same `user` content as separate functionResponse parts. The first
|
||||
@@ -2470,6 +2644,176 @@ describe('OpenAIContentConverter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass oss:// video fileData in a user message through to video_url unchanged (omni upload delivery)', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'Describe this video' },
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'video/mp4',
|
||||
fileUri:
|
||||
'oss://dashscope-instant/uploads/model/abc/12345678-video.mp4',
|
||||
displayName: 'video.mp4',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertLlmRequestToOpenAI(
|
||||
request,
|
||||
requestContext,
|
||||
);
|
||||
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeDefined();
|
||||
const contentArray = userMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
video_url?: { url: string };
|
||||
}>;
|
||||
const videoPart = contentArray.find((p) => p.type === 'video_url');
|
||||
expect(videoPart?.video_url?.url).toBe(
|
||||
'oss://dashscope-instant/uploads/model/abc/12345678-video.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert oss:// audio fileData to input_audio with the bare URL (omni upload delivery)', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'What is this sound?' },
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'audio/mpeg',
|
||||
fileUri:
|
||||
'oss://dashscope-instant/uploads/model/abc/12345678-tone.mp3',
|
||||
displayName: 'tone.mp3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertLlmRequestToOpenAI(
|
||||
request,
|
||||
requestContext,
|
||||
);
|
||||
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
const contentArray = userMessage?.content as Array<{
|
||||
type: string;
|
||||
input_audio?: { data: string; format: string };
|
||||
}>;
|
||||
const audioPart = contentArray.find((p) => p.type === 'input_audio');
|
||||
// Bare oss URL — no data: prefix (unlike the inline branch).
|
||||
expect(audioPart?.input_audio?.data).toBe(
|
||||
'oss://dashscope-instant/uploads/model/abc/12345678-tone.mp3',
|
||||
);
|
||||
expect(audioPart?.input_audio?.format).toBe('mp3');
|
||||
});
|
||||
|
||||
it('should convert flac/ogg/m4a audio fileData instead of textifying', () => {
|
||||
for (const [mime, format] of [
|
||||
['audio/flac', 'flac'],
|
||||
['audio/ogg', 'ogg'],
|
||||
['audio/mp4', 'm4a'],
|
||||
] as const) {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
fileData: {
|
||||
mimeType: mime,
|
||||
fileUri: 'oss://bucket/key',
|
||||
displayName: 'clip',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const messages = converter.convertLlmRequestToOpenAI(
|
||||
request,
|
||||
requestContext,
|
||||
);
|
||||
const userMessage = messages.find((m) => m.role === 'user');
|
||||
const contentArray = userMessage?.content as Array<{
|
||||
type: string;
|
||||
input_audio?: { data: string; format: string };
|
||||
text?: string;
|
||||
}>;
|
||||
const audioPart = contentArray.find((p) => p.type === 'input_audio');
|
||||
expect(audioPart?.input_audio?.format).toBe(format);
|
||||
expect(
|
||||
contentArray.some((p) =>
|
||||
p.text?.includes('Unsupported file media type'),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should convert inline flac/ogg/m4a audio instead of textifying', () => {
|
||||
// Mirrors the fileData case above: the inline branch shares
|
||||
// getAudioFormat, and a regression that re-textifies inline
|
||||
// flac/ogg/m4a must not ship green.
|
||||
for (const [mime, format] of [
|
||||
['audio/flac', 'flac'],
|
||||
['audio/ogg', 'ogg'],
|
||||
['audio/mp4', 'm4a'],
|
||||
] as const) {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: mime,
|
||||
data: 'YXVkaW8=',
|
||||
displayName: 'clip',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const messages = converter.convertLlmRequestToOpenAI(
|
||||
request,
|
||||
requestContext,
|
||||
);
|
||||
const userMessage = messages.find((m) => m.role === 'user');
|
||||
const contentArray = userMessage?.content as Array<{
|
||||
type: string;
|
||||
input_audio?: { data: string; format: string };
|
||||
text?: string;
|
||||
}>;
|
||||
const audioPart = contentArray.find((p) => p.type === 'input_audio');
|
||||
expect(audioPart?.input_audio?.format).toBe(format);
|
||||
expect(audioPart?.input_audio?.data).toBe(
|
||||
`data:${mime};base64,YXVkaW8=`,
|
||||
);
|
||||
expect(
|
||||
contentArray.some((p) =>
|
||||
p.text?.includes('Unsupported inline media type'),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should render unsupported inlineData file types as a text block', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
@@ -2531,7 +2875,7 @@ describe('OpenAIContentConverter', () => {
|
||||
expect(contentArray[1].text).toContain('archive.zip');
|
||||
});
|
||||
|
||||
it('should render unsupported fileData types (including audio) as a text block', () => {
|
||||
it('should render unsupported fileData types as a text block', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
@@ -2558,9 +2902,9 @@ describe('OpenAIContentConverter', () => {
|
||||
parts: [
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'audio/mpeg',
|
||||
fileUri: 'https://example.com/audio.mp3',
|
||||
displayName: 'audio.mp3',
|
||||
mimeType: 'application/zip',
|
||||
fileUri: 'https://example.com/archive.zip',
|
||||
displayName: 'archive.zip',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2588,8 +2932,8 @@ describe('OpenAIContentConverter', () => {
|
||||
expect(contentArray[0].text).toBe('File content');
|
||||
expect(contentArray[1].type).toBe('text');
|
||||
expect(contentArray[1].text).toContain('Unsupported file media type');
|
||||
expect(contentArray[1].text).toContain('audio/mpeg');
|
||||
expect(contentArray[1].text).toContain('audio.mp3');
|
||||
expect(contentArray[1].text).toContain('application/zip');
|
||||
expect(contentArray[1].text).toContain('archive.zip');
|
||||
});
|
||||
|
||||
it('should create tool message with text-only content when no media parts', () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from '../tool-call-preparation.js';
|
||||
import { InvalidStreamError } from '../invalid-stream-error.js';
|
||||
import { normalizeMcpToolName } from '../../utils/tool-name-utils.js';
|
||||
import { isDisclosureText } from '../../omni/disclosure.js';
|
||||
import { evictOldestImagesBeyondCap } from './image-budget.js';
|
||||
import { setGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js';
|
||||
import { SchemaValidator } from '../../utils/schemaValidator.js';
|
||||
|
||||
@@ -489,6 +491,12 @@ export function convertLlmRequestToOpenAI(
|
||||
messages = mergeConsecutiveAssistantMessages(messages);
|
||||
}
|
||||
|
||||
// Bound the number of images in the assembled request below the backing
|
||||
// API's hard per-request image cap (256 on qwen-omni). Omni keyframes
|
||||
// accumulate across turns; without this a long look-closer trajectory
|
||||
// eventually sends >256 image parts and the API rejects the whole request.
|
||||
evictOldestImagesBeyondCap(messages);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -739,6 +747,15 @@ function processContent(
|
||||
) {
|
||||
const mediaParts: OpenAIContentPart[] = [];
|
||||
const textParts: OpenAI.Chat.ChatCompletionContentPartText[] = [];
|
||||
// Track the previous part so an omni media-degradation disclosure
|
||||
// (emitted immediately before its media part) moves WITH the media
|
||||
// into the follow-up user message instead of being stranded in the
|
||||
// text-only tool message, where the model could not attribute it.
|
||||
// The asymmetry with transcript text (§6.2) is deliberate:
|
||||
// transcripts FOLLOW their media part and read fine as plain text
|
||||
// in the tool message — only the disclosure carries the D8
|
||||
// adjacency requirement, so only the preceding disclosure migrates.
|
||||
let prev: OpenAIContentPart | undefined;
|
||||
for (const cp of toolMessage.content as OpenAIContentPart[]) {
|
||||
if (
|
||||
cp &&
|
||||
@@ -747,10 +764,17 @@ function processContent(
|
||||
cp.type === 'video_url' ||
|
||||
cp.type === 'file')
|
||||
) {
|
||||
if (prev?.type === 'text' && isDisclosureText(prev.text)) {
|
||||
textParts.pop();
|
||||
mediaParts.push(prev);
|
||||
}
|
||||
mediaParts.push(cp);
|
||||
} else if (cp && cp.type === 'text') {
|
||||
textParts.push(cp);
|
||||
}
|
||||
// Consecutive media parts after one disclosure must not each
|
||||
// claim it: only the part directly following the text does.
|
||||
prev = cp;
|
||||
}
|
||||
if (mediaParts.length > 0) {
|
||||
const textOnly = textParts.map((p) => p.text).join('\n');
|
||||
@@ -992,7 +1016,9 @@ function createMediaContentPart(
|
||||
type: 'input_audio' as const,
|
||||
input_audio: {
|
||||
data: `data:${mimeType};base64,${part.inlineData.data}`,
|
||||
format,
|
||||
// DashScope accepts flac/ogg/m4a beyond the OpenAI SDK's
|
||||
// wav|mp3 union; the request wire format is identical.
|
||||
format: format as 'wav' | 'mp3',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1069,6 +1095,33 @@ function createMediaContentPart(
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === 'audio') {
|
||||
if (!modalities.audio) {
|
||||
return unsupportedModalityPlaceholder(
|
||||
'audio',
|
||||
filename,
|
||||
requestContext,
|
||||
);
|
||||
}
|
||||
const format = getAudioFormat(mimeType);
|
||||
if (format) {
|
||||
// Unlike the inline branch (data: URI), the upload channel passes
|
||||
// the bare URL — DashScope's input_audio.data accepts either, and
|
||||
// oss:// references are resolved server-side via the
|
||||
// X-DashScope-OssResourceResolve header (verified live 2026-08-03
|
||||
// on qwen3.5-omni-plus).
|
||||
return {
|
||||
type: 'input_audio' as const,
|
||||
input_audio: {
|
||||
data: fileUri,
|
||||
// See inline branch: DashScope accepts a wider format set
|
||||
// than the OpenAI SDK union.
|
||||
format: format as 'wav' | 'mp3',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const displayNameStr = part.fileData.displayName
|
||||
? ` (${part.fileData.displayName})`
|
||||
: '';
|
||||
@@ -1113,9 +1166,26 @@ function getMediaType(mimeType: string): 'image' | 'audio' | 'video' | 'file' {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function getAudioFormat(mimeType: string): 'wav' | 'mp3' | null {
|
||||
/**
|
||||
* Audio formats the DashScope input_audio channel accepts. Kept in
|
||||
* lockstep with the omni recognizer's audio sniff set — an upload the
|
||||
* recognizer accepts must never textify here after paying for transfer.
|
||||
*
|
||||
* flac/ogg/m4a are a DashScope acceptance extension beyond the OpenAI
|
||||
* SDK's wav|mp3 union, applied unconditionally because RequestContext
|
||||
* carries no provider identity to scope on. The trade is deliberate:
|
||||
* a non-DashScope endpoint that rejects the format returns an explicit
|
||||
* 400, whereas textifying would silently drop the audio. Revisit if a
|
||||
* provider flag ever lands on RequestContext.
|
||||
*/
|
||||
function getAudioFormat(
|
||||
mimeType: string,
|
||||
): 'wav' | 'mp3' | 'flac' | 'ogg' | 'm4a' | null {
|
||||
if (mimeType.includes('wav')) return 'wav';
|
||||
if (mimeType.includes('mp3') || mimeType.includes('mpeg')) return 'mp3';
|
||||
if (mimeType.includes('flac')) return 'flac';
|
||||
if (mimeType.includes('ogg')) return 'ogg';
|
||||
if (mimeType === 'audio/mp4' || mimeType.includes('m4a')) return 'm4a';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type OpenAI from 'openai';
|
||||
import {
|
||||
DEFAULT_MAX_REQUEST_IMAGES,
|
||||
evictOldestImagesBeyondCap,
|
||||
} from './image-budget.js';
|
||||
|
||||
type Message = OpenAI.Chat.ChatCompletionMessageParam;
|
||||
|
||||
const img = (url: string) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url },
|
||||
});
|
||||
const txt = (text: string) => ({ type: 'text' as const, text });
|
||||
|
||||
function userWithImages(urls: string[]): Message {
|
||||
return { role: 'user', content: [txt('look'), ...urls.map(img)] } as Message;
|
||||
}
|
||||
|
||||
function countImages(messages: Message[]): number {
|
||||
let n = 0;
|
||||
for (const m of messages) {
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const p of m.content)
|
||||
if ((p as { type?: string }).type === 'image_url') n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
describe('evictOldestImagesBeyondCap', () => {
|
||||
it('leaves a request under the cap untouched', () => {
|
||||
const messages = [userWithImages(['a', 'b', 'c'])];
|
||||
const evicted = evictOldestImagesBeyondCap(messages, 10);
|
||||
expect(evicted).toBe(0);
|
||||
expect(countImages(messages)).toBe(3);
|
||||
});
|
||||
|
||||
it('evicts the oldest images and keeps the newest cap', () => {
|
||||
// Five images across two turns; cap 3 → evict the 2 oldest.
|
||||
const messages = [
|
||||
userWithImages(['a', 'b', 'c']),
|
||||
userWithImages(['d', 'e']),
|
||||
];
|
||||
const evicted = evictOldestImagesBeyondCap(messages, 3);
|
||||
expect(evicted).toBe(2);
|
||||
expect(countImages(messages)).toBe(3);
|
||||
// The two oldest (a, b) became placeholders; c, d, e survive.
|
||||
const urls = messages
|
||||
.flatMap((m) =>
|
||||
Array.isArray(m.content) ? (m.content as unknown[]) : [],
|
||||
)
|
||||
.filter((p) => (p as { type?: string }).type === 'image_url')
|
||||
.map((p) => (p as { image_url: { url: string } }).image_url.url);
|
||||
expect(urls).toEqual(['c', 'd', 'e']);
|
||||
// Evicted slots keep a text part (message stays well-formed).
|
||||
const firstContent = messages[0].content as Array<{ type: string }>;
|
||||
expect(firstContent.filter((p) => p.type === 'text').length).toBe(3);
|
||||
});
|
||||
|
||||
it('does not touch video_url parts', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
img('a'),
|
||||
img('b'),
|
||||
{ type: 'video_url', video_url: { url: 'v' } },
|
||||
],
|
||||
} as Message,
|
||||
];
|
||||
const evicted = evictOldestImagesBeyondCap(messages, 1);
|
||||
expect(evicted).toBe(1);
|
||||
// video_url survives; one image remains.
|
||||
const kinds = (messages[0].content as Array<{ type: string }>).map(
|
||||
(p) => p.type,
|
||||
);
|
||||
expect(kinds.filter((k) => k === 'video_url').length).toBe(1);
|
||||
expect(kinds.filter((k) => k === 'image_url').length).toBe(1);
|
||||
});
|
||||
|
||||
it('tolerates string-content messages', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: 'hi' } as Message,
|
||||
userWithImages(['a', 'b']),
|
||||
];
|
||||
const evicted = evictOldestImagesBeyondCap(messages, 1);
|
||||
expect(evicted).toBe(1);
|
||||
expect(countImages(messages)).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults the cap to the qwen-omni safety margin', () => {
|
||||
expect(DEFAULT_MAX_REQUEST_IMAGES).toBeLessThan(256);
|
||||
const messages = [
|
||||
userWithImages(Array.from({ length: 300 }, (_, i) => `f${i}`)),
|
||||
];
|
||||
evictOldestImagesBeyondCap(messages);
|
||||
expect(countImages(messages)).toBe(DEFAULT_MAX_REQUEST_IMAGES);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
/**
|
||||
* Cap on the number of image parts in a single assembled request.
|
||||
*
|
||||
* Omni keyframes are delivered as `image_url` parts that persist in the
|
||||
* conversation history and are re-sent on every turn. Across a long
|
||||
* look-closer trajectory (many extract-keyframes / clip → read cycles) the
|
||||
* running count climbs until the backing API rejects the WHOLE request —
|
||||
* DashScope's qwen-omni caps at 256 images ("Too many images. The maximum
|
||||
* allowed is 256"), a hard 400 that no retry recovers. We keep a margin below
|
||||
* that limit so a request that also carries a few non-keyframe images (a
|
||||
* user-attached picture, a video's own frames) still lands under the ceiling.
|
||||
*/
|
||||
export const DEFAULT_MAX_REQUEST_IMAGES = 250;
|
||||
|
||||
/** Text left in place of an evicted image, so the message keeps a part and
|
||||
* the model learns why an earlier frame is gone rather than silently losing
|
||||
* it. */
|
||||
const EVICTED_IMAGE_PLACEHOLDER =
|
||||
'[已淘汰较早的关键帧以控制单次请求的图片数量;如需重看该画面请重新抽帧或切片]';
|
||||
|
||||
type Message = OpenAI.Chat.ChatCompletionMessageParam;
|
||||
|
||||
function isImagePart(part: unknown): boolean {
|
||||
return (
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
(part as { type?: unknown }).type === 'image_url'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the newest `cap` image parts across the whole request, replacing
|
||||
* each older one with a short text placeholder in place. History is
|
||||
* chronological (earliest message first), so "oldest" = earliest array
|
||||
* position; the current turn's freshly-delivered frames are always retained.
|
||||
*
|
||||
* Counts `image_url` parts only. `video_url` parts are left untouched: a clip
|
||||
* delivered as native video is the high-value payload the model asked for, and
|
||||
* the observed 256 crashes come from accumulated keyframe images, not videos.
|
||||
*
|
||||
* Mutates `messages` in place and returns the number of images evicted.
|
||||
*/
|
||||
export function evictOldestImagesBeyondCap(
|
||||
messages: Message[],
|
||||
cap: number = DEFAULT_MAX_REQUEST_IMAGES,
|
||||
): number {
|
||||
if (cap < 0) return 0;
|
||||
let total = 0;
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (isImagePart(part)) total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total <= cap) return 0;
|
||||
|
||||
let toEvict = total - cap;
|
||||
let evicted = 0;
|
||||
for (const message of messages) {
|
||||
if (toEvict === 0) break;
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
const content = message.content as unknown as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
for (let i = 0; i < content.length && toEvict > 0; i++) {
|
||||
if (isImagePart(content[i])) {
|
||||
content[i] = { type: 'text', text: EVICTED_IMAGE_PLACEHOLDER };
|
||||
toEvict--;
|
||||
evicted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return evicted;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type OpenAI from 'openai';
|
||||
import type { GenerateContentParameters } from '@google/genai';
|
||||
import { GenerateContentResponse } from '@google/genai';
|
||||
import type { ErrorHandler, PipelineConfig } from './types.js';
|
||||
import { ContentGenerationPipeline } from './pipeline.js';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { AuthType, ContentGeneratorConfig } from '../contentGenerator.js';
|
||||
import type { OpenAICompatibleProvider } from './provider/index.js';
|
||||
|
||||
const mockInvalidateByUrl = vi.hoisted(() => vi.fn());
|
||||
const mockUploadCacheCtor = vi.hoisted(() => vi.fn());
|
||||
const mockObjectStoreCtor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('openai');
|
||||
vi.mock('./converter.js', () => ({
|
||||
OpenAIContentConverter: {
|
||||
convertLlmRequestToOpenAI: vi.fn(),
|
||||
convertOpenAIResponseToGemini: vi.fn(),
|
||||
convertOpenAIChunkToLlm: vi.fn(),
|
||||
convertGeminiToolsToOpenAI: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../telemetry/loggers.js', () => ({
|
||||
logProtocolTagSanitized: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../telemetry/gen-ai-request.js', () => ({
|
||||
reportOpenAiRequest: vi.fn(),
|
||||
reportOpenAiResponse: vi.fn(),
|
||||
reportOpenAiChunk: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../omni/upload-cache.js', () => ({
|
||||
OmniUploadCache: class {
|
||||
constructor(...args: unknown[]) {
|
||||
mockUploadCacheCtor(...args);
|
||||
}
|
||||
|
||||
invalidateByUrl = mockInvalidateByUrl;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../omni/storage.js', () => ({
|
||||
OmniObjectStore: class {
|
||||
constructor(...args: unknown[]) {
|
||||
mockObjectStoreCtor(...args);
|
||||
}
|
||||
|
||||
getOmniRootDir() {
|
||||
return '/tmp/omni-root';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ContentGenerationPipeline omni oss cache invalidation', () => {
|
||||
let pipeline: ContentGenerationPipeline;
|
||||
let mockConfig: PipelineConfig;
|
||||
let mockProvider: OpenAICompatibleProvider;
|
||||
let mockClient: OpenAI;
|
||||
let mockConverter: typeof OpenAIContentConverter;
|
||||
let mockErrorHandler: ErrorHandler;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
let mockCliConfig: Config;
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'test-model',
|
||||
contents: [{ parts: [{ text: 'Describe the media' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'omni-prompt-id';
|
||||
|
||||
/** Messages carrying oss:// media in all three OpenAI part shapes. */
|
||||
const ossMediaMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe the media' },
|
||||
{ type: 'image_url', image_url: { url: 'oss://bucket/image.png' } },
|
||||
{ type: 'video_url', video_url: { url: 'oss://bucket/video.mp4' } },
|
||||
{
|
||||
type: 'input_audio',
|
||||
input_audio: { data: 'oss://bucket/audio.wav', format: 'wav' },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as OpenAI.Chat.ChatCompletionMessageParam[];
|
||||
|
||||
const nonOssMediaMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/image.png' },
|
||||
},
|
||||
{
|
||||
type: 'input_audio',
|
||||
input_audio: { data: 'data:audio/wav;base64,AAAA', format: 'wav' },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as OpenAI.Chat.ChatCompletionMessageParam[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenAI;
|
||||
|
||||
mockConverter = OpenAIContentConverter;
|
||||
|
||||
mockProvider = {
|
||||
buildClient: vi.fn().mockReturnValue(mockClient),
|
||||
buildRequest: vi.fn().mockImplementation((req) => req),
|
||||
buildHeaders: vi.fn().mockReturnValue({}),
|
||||
getDefaultGenerationConfig: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
mockErrorHandler = {
|
||||
handle: vi.fn().mockImplementation((error: unknown) => {
|
||||
throw error;
|
||||
}),
|
||||
shouldSuppressErrorLogging: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ErrorHandler;
|
||||
|
||||
mockCliConfig = {
|
||||
isOmniEnabled: () => true,
|
||||
storage: { getQwenDir: () => '/tmp/qwen' },
|
||||
} as unknown as Config;
|
||||
|
||||
mockContentGeneratorConfig = {
|
||||
model: 'test-model',
|
||||
authType: 'openai' as AuthType,
|
||||
} as ContentGeneratorConfig;
|
||||
|
||||
mockConfig = {
|
||||
cliConfig: mockCliConfig,
|
||||
provider: mockProvider,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
errorHandler: mockErrorHandler,
|
||||
};
|
||||
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
});
|
||||
|
||||
it('invalidates each distinct oss:// URL when a non-streaming request fails with a media download error', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockRejectedValue(
|
||||
new Error('Download the media resource timed out'),
|
||||
);
|
||||
|
||||
await expect(pipeline.execute(request, userPromptId)).rejects.toThrow(
|
||||
'Download the media resource timed out',
|
||||
);
|
||||
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledTimes(3);
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledWith('oss://bucket/image.png');
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledWith('oss://bucket/video.mp4');
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledWith('oss://bucket/audio.wav');
|
||||
// The cache is constructed without a scope: invalidation scans all
|
||||
// entries by URL.
|
||||
expect(mockUploadCacheCtor).toHaveBeenCalledWith('/tmp/omni-root');
|
||||
});
|
||||
|
||||
it('invalidates when the stream fails mid-iteration with an oss-naming error', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
const emptyResponse = new GenerateContentResponse();
|
||||
(mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue(
|
||||
emptyResponse,
|
||||
);
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
id: 'chunk-1',
|
||||
choices: [{ delta: { content: 'partial' }, finish_reason: null }],
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
throw new Error(
|
||||
'Failed to resolve oss://bucket/image.png: resource not found',
|
||||
);
|
||||
},
|
||||
};
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(stream);
|
||||
|
||||
const generator = await pipeline.executeStream(request, userPromptId);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of generator) {
|
||||
// Drain until the mid-stream error surfaces.
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow('Failed to resolve oss://bucket/image.png');
|
||||
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledTimes(3);
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledWith('oss://bucket/image.png');
|
||||
});
|
||||
|
||||
it('invalidates when the stream reports an error_finish chunk naming the oss media', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
id: 'chunk-1',
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: 'Download the media resource timed out',
|
||||
},
|
||||
finish_reason: 'error_finish',
|
||||
},
|
||||
],
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk;
|
||||
},
|
||||
};
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(stream);
|
||||
|
||||
const generator = await pipeline.executeStream(request, userPromptId);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of generator) {
|
||||
// Drain until the error_finish chunk surfaces as an error.
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow('Download the media resource timed out');
|
||||
|
||||
expect(mockInvalidateByUrl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not invalidate on 429/RESOURCE_EXHAUSTED even with oss media present', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Error('RESOURCE_EXHAUSTED: quota exceeded for oss:// media'),
|
||||
{ status: 429 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(pipeline.execute(request, userPromptId)).rejects.toThrow(
|
||||
'RESOURCE_EXHAUSTED',
|
||||
);
|
||||
|
||||
expect(mockInvalidateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invalidate when the error merely contains the letters "oss" without the scheme', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockRejectedValue(
|
||||
new Error('connection loss detected'),
|
||||
);
|
||||
|
||||
await expect(pipeline.execute(request, userPromptId)).rejects.toThrow(
|
||||
'connection loss detected',
|
||||
);
|
||||
|
||||
expect(mockInvalidateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invalidate when the request carries no oss:// URLs', async () => {
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
nonOssMediaMessages,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockRejectedValue(
|
||||
new Error('Failed to resolve oss://bucket/other.png'),
|
||||
);
|
||||
|
||||
await expect(pipeline.execute(request, userPromptId)).rejects.toThrow(
|
||||
'Failed to resolve',
|
||||
);
|
||||
|
||||
expect(mockInvalidateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invalidate when omni is disabled', async () => {
|
||||
mockCliConfig = {
|
||||
isOmniEnabled: () => false,
|
||||
storage: { getQwenDir: () => '/tmp/qwen' },
|
||||
} as unknown as Config;
|
||||
pipeline = new ContentGenerationPipeline({
|
||||
...mockConfig,
|
||||
cliConfig: mockCliConfig,
|
||||
});
|
||||
(mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue(
|
||||
ossMediaMessages,
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockRejectedValue(
|
||||
new Error('Download the media resource timed out'),
|
||||
);
|
||||
|
||||
await expect(pipeline.execute(request, userPromptId)).rejects.toThrow(
|
||||
'Download the media resource timed out',
|
||||
);
|
||||
|
||||
expect(mockInvalidateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -584,6 +584,7 @@ export class ContentGenerationPipeline {
|
||||
guarded,
|
||||
context,
|
||||
request,
|
||||
openaiRequest,
|
||||
userPromptId,
|
||||
telemetryAttempt,
|
||||
);
|
||||
@@ -611,6 +612,7 @@ export class ContentGenerationPipeline {
|
||||
stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>,
|
||||
context: RequestContext,
|
||||
request: PromptCacheSharingParameters,
|
||||
openaiRequest: OpenAI.Chat.ChatCompletionCreateParams,
|
||||
userPromptId: string,
|
||||
telemetryAttempt: GenAiAttemptHandle | undefined,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
@@ -820,6 +822,15 @@ export class ContentGenerationPipeline {
|
||||
yield pendingFinishResponse;
|
||||
}
|
||||
} catch (error) {
|
||||
// Omni delivery-cache hygiene for mid-stream failures: DashScope
|
||||
// reports dead oss:// media after the 200 OK — either as an
|
||||
// error_finish SSE chunk (surfaced as StreamContentError above) or as
|
||||
// a stream error — so the non-streaming catch in
|
||||
// executeWithErrorHandling never sees it. Run the same conservative
|
||||
// invalidation here before any rethrow. Never throws; awaiting keeps
|
||||
// a fast retry from racing the cache file write.
|
||||
await this.invalidateOmniOssCacheOnError(openaiRequest, error);
|
||||
|
||||
if (error instanceof InvalidStreamError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -1604,6 +1615,14 @@ export class ContentGenerationPipeline {
|
||||
try {
|
||||
return await executeAttempt();
|
||||
} catch (error) {
|
||||
// Omni delivery-cache hygiene: when a request carrying oss:// media
|
||||
// fails with a provider-side resolution error, drop the cached URL(s)
|
||||
// so the user's retry re-uploads instead of resending a dead
|
||||
// reference. Deliberately no automatic in-pipeline resend — the next
|
||||
// interaction is the retry (design: omni-s3 D2). Conservative
|
||||
// matching; never throws, so awaiting cannot mask the original error,
|
||||
// and it must complete before a fast retry can race the file write.
|
||||
await this.invalidateOmniOssCacheOnError(openaiRequest, error);
|
||||
const model = context.model.toLowerCase();
|
||||
const wireRequest = openaiRequest as Record<string, unknown> | undefined;
|
||||
const chatTemplateKwargs = wireRequest?.['chat_template_kwargs'] as
|
||||
@@ -1663,6 +1682,68 @@ export class ContentGenerationPipeline {
|
||||
* Shared error handling logic for both executeWithErrorHandling and processStreamWithLogging
|
||||
* This centralizes the common error processing steps to avoid duplication
|
||||
*/
|
||||
/**
|
||||
* Upload-cache invalidation for failed oss deliveries. Never throws
|
||||
* (internal try/catch), so callers can await it without masking the
|
||||
* original error.
|
||||
*/
|
||||
private async invalidateOmniOssCacheOnError(
|
||||
openaiRequest: OpenAI.Chat.ChatCompletionCreateParams | undefined,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.config.cliConfig.isOmniEnabled?.()) return;
|
||||
// Throttling must never churn the cache: a 429 on a request that
|
||||
// happens to carry oss media says nothing about the media's health
|
||||
// (and some providers phrase quota errors as RESOURCE_EXHAUSTED).
|
||||
if (getErrorStatus(error) === 429) return;
|
||||
const message = getErrorMessage(error);
|
||||
// Dead-media provider errors either quote the oss URL — the cached
|
||||
// URLs always carry the scheme — or describe the media download step
|
||||
// (DashScope: "Download the media resource timed out"). Require the
|
||||
// full "oss://" scheme rather than the bare word so messages like
|
||||
// "connection loss" or "across" cannot nuke healthy cache entries.
|
||||
// This trades conservative false negatives: a phrasing like "Failed
|
||||
// to fetch the media resource" (no URL, no "download") is
|
||||
// deliberately missed.
|
||||
if (
|
||||
!/oss:\/\//i.test(message) &&
|
||||
!(/download/i.test(message) && /resource|media/i.test(message))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const urls = new Set<string>();
|
||||
for (const m of openaiRequest?.messages ?? []) {
|
||||
const content = (m as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
const p = part as {
|
||||
image_url?: { url?: string };
|
||||
video_url?: { url?: string };
|
||||
input_audio?: { data?: string };
|
||||
};
|
||||
for (const u of [
|
||||
p.image_url?.url,
|
||||
p.video_url?.url,
|
||||
p.input_audio?.data,
|
||||
]) {
|
||||
if (typeof u === 'string' && u.startsWith('oss://')) urls.add(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (urls.size === 0) return;
|
||||
const { OmniUploadCache } = await import('../../omni/upload-cache.js');
|
||||
const { OmniObjectStore } = await import('../../omni/storage.js');
|
||||
const store = new OmniObjectStore(
|
||||
this.config.cliConfig.storage.getQwenDir(),
|
||||
);
|
||||
const cache = new OmniUploadCache(store.getOmniRootDir());
|
||||
for (const u of urls) await cache.invalidateByUrl(u);
|
||||
} catch {
|
||||
// Hygiene only — never mask the original error.
|
||||
}
|
||||
}
|
||||
|
||||
private async handleError(
|
||||
error: unknown,
|
||||
context: RequestContext,
|
||||
|
||||
@@ -620,6 +620,36 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
`QwenCode/unknown (${process.platform}; ${process.arch})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add the OssResourceResolve header when omni is enabled', () => {
|
||||
const omniCliConfig = {
|
||||
...mockCliConfig,
|
||||
isOmniEnabled: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const omniProvider = new DashScopeOpenAICompatibleProvider(
|
||||
mockContentGeneratorConfig,
|
||||
omniCliConfig,
|
||||
);
|
||||
|
||||
const headers = omniProvider.buildHeaders();
|
||||
|
||||
expect(headers['X-DashScope-OssResourceResolve']).toBe('enable');
|
||||
});
|
||||
|
||||
it('should omit the OssResourceResolve header when omni is disabled', () => {
|
||||
const omniCliConfig = {
|
||||
...mockCliConfig,
|
||||
isOmniEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const omniProvider = new DashScopeOpenAICompatibleProvider(
|
||||
mockContentGeneratorConfig,
|
||||
omniCliConfig,
|
||||
);
|
||||
|
||||
const headers = omniProvider.buildHeaders();
|
||||
|
||||
expect(headers).not.toHaveProperty('X-DashScope-OssResourceResolve');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClient', () => {
|
||||
|
||||
@@ -289,12 +289,19 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
|
||||
const version = this.cliConfig.getCliVersion() || 'unknown';
|
||||
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
||||
const { authType, customHeaders } = this.contentGeneratorConfig;
|
||||
const defaultHeaders = {
|
||||
const defaultHeaders: Record<string, string | undefined> = {
|
||||
'User-Agent': userAgent,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': userAgent,
|
||||
'X-DashScope-AuthType': authType,
|
||||
};
|
||||
// Omni experiment: oss:// media URLs from the temporary-upload channel
|
||||
// are only resolved server-side when this header is present. Static
|
||||
// injection (vs per-request threading) is deliberate — the header is
|
||||
// harmless on requests without oss:// parts.
|
||||
if (this.cliConfig.isOmniEnabled?.()) {
|
||||
defaultHeaders['X-DashScope-OssResourceResolve'] = 'enable';
|
||||
}
|
||||
|
||||
return customHeaders
|
||||
? { ...defaultHeaders, ...customHeaders }
|
||||
|
||||
@@ -686,6 +686,12 @@ export interface SystemPromptLayers {
|
||||
* fixed for the whole session.
|
||||
*/
|
||||
base: string;
|
||||
/**
|
||||
* Stable layer: the omni progressive-media-understanding contract
|
||||
* (omni/media-guidance.ts) — fixed for the whole session (the omni
|
||||
* config and provider do not change in-session).
|
||||
*/
|
||||
mediaGuidance?: string | null;
|
||||
/**
|
||||
* Context layer: concatenated context files (QWEN.md hierarchy, baseline
|
||||
* rules, extension files). Reloaded only on explicit refresh.
|
||||
@@ -710,6 +716,7 @@ export interface SystemPromptLayers {
|
||||
export function assembleSystemPrompt(layers: SystemPromptLayers): string {
|
||||
return (
|
||||
layers.base +
|
||||
buildSystemPromptSuffix(layers.mediaGuidance ?? undefined) +
|
||||
buildSystemPromptSuffix(layers.contextFiles) +
|
||||
buildSystemPromptSuffix(layers.appendPrompt) +
|
||||
(layers.gitStatus ? `\n\n${layers.gitStatus}` : '') +
|
||||
|
||||
@@ -831,6 +831,36 @@ describe('toolHookTriggers', () => {
|
||||
});
|
||||
|
||||
describe('appendAdditionalContext', () => {
|
||||
it('preserves structured media and its ordering when adding hook context', () => {
|
||||
const parts = [
|
||||
{ text: 'resource media-1' },
|
||||
{ fileData: { mimeType: 'image/png', fileUri: 'oss://image' } },
|
||||
];
|
||||
const original = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'exec',
|
||||
response: { output: 'read completed' },
|
||||
parts,
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(appendAdditionalContext(original, 'hook context')).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'exec',
|
||||
response: { output: 'read completed\n\nhook context' },
|
||||
parts,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(original[0].functionResponse.response.output).toBe(
|
||||
'read completed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original content when no additional context is provided', () => {
|
||||
const result = appendAdditionalContext('original content', undefined);
|
||||
expect(result).toBe('original content');
|
||||
|
||||
@@ -640,6 +640,27 @@ export function appendAdditionalContext(
|
||||
return content + '\n\n' + additionalContext;
|
||||
}
|
||||
|
||||
const single =
|
||||
Array.isArray(content) && content.length === 1 ? content[0] : content;
|
||||
if (
|
||||
typeof single === 'object' &&
|
||||
!Array.isArray(single) &&
|
||||
single.functionResponse
|
||||
) {
|
||||
const response = single.functionResponse;
|
||||
const updated: Part = {
|
||||
...single,
|
||||
functionResponse: {
|
||||
...response,
|
||||
response: {
|
||||
...response.response,
|
||||
output: `${response.response?.['output'] ?? ''}\n\n${additionalContext}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
return Array.isArray(content) ? [updated] : updated;
|
||||
}
|
||||
|
||||
// For PartListUnion content, append as an additional text part
|
||||
if (Array.isArray(content)) {
|
||||
return [...content, { text: additionalContext } as Part];
|
||||
|
||||
@@ -128,6 +128,48 @@ export interface LlmFinishedEventValue {
|
||||
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance of a tool-call request. Set exclusively by in-process callers
|
||||
* when they construct the {@link ToolCallRequestInfo} — it is NEVER parsed
|
||||
* from tool parameters, wire protocols, or model output, and it is NEVER
|
||||
* inferred from `isClientInitiated`. A missing origin fails closed as
|
||||
* `{ kind: 'model' }` (the least-privileged origin).
|
||||
*
|
||||
* `fixed_policy` marks calls issued by the omni fixed-policy orchestrator:
|
||||
* they bypass the interactive permission flow (no confirmation dialog, no
|
||||
* plan/auto classification) but still honor PreToolUse hooks and the
|
||||
* PermissionManager tool-enablement check.
|
||||
*/
|
||||
export type ToolExecutionOrigin =
|
||||
| { kind: 'model' }
|
||||
| { kind: 'client' }
|
||||
| {
|
||||
kind: 'fixed_policy';
|
||||
/** ID of the fixed policy that issued this call. */
|
||||
policyId: string;
|
||||
/** Pipeline stage the policy ran in. */
|
||||
stage: 'preprocessing' | 'transport_guard';
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw, successful media-policy tool artifacts captured by the scheduler
|
||||
* BEFORE PostToolUse hook artifacts are merged in — hook-produced artifacts
|
||||
* must never impersonate policy outputs. Carried on
|
||||
* {@link ToolCallResponseInfo.policyArtifacts} for the fixed-policy
|
||||
* orchestrator (and the model-call artifact bridge) to consume.
|
||||
*/
|
||||
export interface PolicyArtifactBatch {
|
||||
/** Canonical tool name that produced the artifacts. */
|
||||
toolName: string;
|
||||
/** The call id of the invocation (the orchestrator uses its staging
|
||||
* invocation id as the call id, so this keys the staging directory). */
|
||||
invocationId: string;
|
||||
/** Origin the call executed under (missing origins fail closed to model
|
||||
* before this batch is built, so this is always concrete). */
|
||||
executionOrigin: ToolExecutionOrigin;
|
||||
/** The tool's own `ToolResult.artifacts`, unmerged and in order. */
|
||||
artifacts: ToolArtifact[];
|
||||
}
|
||||
/** @deprecated Use `LlmErrorEventValue`; retained until a future major release. */
|
||||
export type GeminiErrorEventValue = LlmErrorEventValue;
|
||||
|
||||
@@ -149,6 +191,12 @@ export interface ToolCallRequestInfo {
|
||||
/** Set to true when the LLM response was truncated due to max_tokens. */
|
||||
wasOutputTruncated?: boolean;
|
||||
goalContext?: GoalTurnPermit;
|
||||
/**
|
||||
* Provenance of this request. Only set by in-process callers; absent on
|
||||
* every request materialized from model output or a wire protocol.
|
||||
* Consumers treat a missing value as `{ kind: 'model' }` (fail closed).
|
||||
*/
|
||||
executionOrigin?: ToolExecutionOrigin;
|
||||
/** Parent model tool call for a programmatically dispatched child call. */
|
||||
parentCallId?: string;
|
||||
source?: 'model' | 'code_mode';
|
||||
@@ -182,6 +230,12 @@ export interface ToolCallResponseInfo {
|
||||
approvalRequired?: true;
|
||||
visionBridgeNotice?: string;
|
||||
artifacts?: ToolArtifact[];
|
||||
/**
|
||||
* Raw successful artifacts of a media-policy tool, captured before
|
||||
* PostToolUse hook artifact merging. Absent for non-media-policy tools,
|
||||
* failed calls, and calls that produced no artifacts.
|
||||
*/
|
||||
policyArtifacts?: PolicyArtifactBatch;
|
||||
boundaryArtifact?: ToolResultBoundaryArtifact;
|
||||
}
|
||||
|
||||
|
||||
@@ -870,6 +870,47 @@ export {
|
||||
type StartupEventAttrs,
|
||||
} from './utils/startupEventSink.js';
|
||||
|
||||
// ============================================================================
|
||||
// Omni multimodal experiment — upload-based media delivery
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
isOmniDeliveryActive,
|
||||
processMediaForOmniDelivery,
|
||||
readMediaViaOmniDelivery,
|
||||
parseHttpUrlRef,
|
||||
downloadMediaUrl,
|
||||
effectiveMaxDownloadFileBytes,
|
||||
recognizeMediaFile,
|
||||
formatDisclosureText,
|
||||
formatOmissionText,
|
||||
buildAdditionalMediaParts,
|
||||
buildTranscriptParts,
|
||||
OmniObjectStore,
|
||||
OmniDeliveryError,
|
||||
OmniDownloadError,
|
||||
OmniTransportGuardError,
|
||||
type OmniModality,
|
||||
type OmniMediaDelivery,
|
||||
type OmniAdditionalMediaDelivery,
|
||||
type OmniAdditionalMediaPart,
|
||||
type OmniTokenEstimate,
|
||||
type DownloadedMedia,
|
||||
} from './omni/index.js';
|
||||
export { processToolResultOmniMedia } from './omni/tool-result-media.js';
|
||||
export {
|
||||
resolveMediaPolicyModelAccess,
|
||||
isMediaPolicyToolHiddenFromModel,
|
||||
evaluateMediaPolicyToolCall,
|
||||
type MediaPolicyConfigView,
|
||||
type MediaPolicyCallGateResult,
|
||||
type ResolvedMediaPolicyModelAccess,
|
||||
} from './omni/policy/model-access.js';
|
||||
export type {
|
||||
OmniPolicyToolSettings,
|
||||
OmniPolicyToolModelAccessSettings,
|
||||
OmniPolicyToolsSettings,
|
||||
} from './omni/policy/types.js';
|
||||
export * from './services/session-sources.js';
|
||||
export { RecordSourceTool } from './tools/record-source.js';
|
||||
export { resolveReviewWorkflowConcurrency } from './agents/runtime/review-workflow.js';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Leaf module for the omni delivery activation check, split out of
|
||||
* `omni/index.ts` so lightweight consumers (system-prompt assembly in
|
||||
* client.ts, media-guidance.ts) can evaluate the gate without statically
|
||||
* pulling in the whole delivery pipeline (storage, upload, ffmpeg,
|
||||
* policy orchestrator).
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { getEffectiveOmniUploadConfig } from './upload-config.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:delivery-gate');
|
||||
|
||||
/**
|
||||
* Gate for the omni delivery path. All conditions must hold:
|
||||
*
|
||||
* 1. omni enabled (settings or QWEN_CODE_ENABLE_OMNI=1);
|
||||
* 2. trusted workspace (the pipeline writes .qwen/omni/ and uploads
|
||||
* workspace bytes off-machine);
|
||||
* 3. a resolved DashScope upload channel, either explicitly separated from
|
||||
* inference or inherited from the legacy static DashScope inference
|
||||
* configuration.
|
||||
*
|
||||
* Any failed condition falls back to the pre-omni inline behavior.
|
||||
* Modality support is checked by the caller (fileUtils) alongside the
|
||||
* existing modality gate.
|
||||
*/
|
||||
export function isOmniDeliveryActive(config: Config): boolean {
|
||||
// Optional calls so stub Configs in tests (and embedders constructing
|
||||
// partial configs) don't need the omni accessors to process files.
|
||||
if (!config.isOmniEnabled?.()) return false;
|
||||
if (config.isTrustedFolder?.() === false) {
|
||||
debugLogger.debug('omni delivery inactive: untrusted workspace');
|
||||
return false;
|
||||
}
|
||||
if (!getEffectiveOmniUploadConfig(config)) {
|
||||
debugLogger.debug(
|
||||
'omni delivery inactive: no usable DashScope upload configuration',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatDisclosureText,
|
||||
formatResourceHandleText,
|
||||
formatResourcePathText,
|
||||
isDisclosureText,
|
||||
isKeyframeTimestampLabel,
|
||||
OMNI_DISCLOSURE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_PATH_TEXT_PREFIX,
|
||||
parseResourceHandleText,
|
||||
parseResourcePathText,
|
||||
} from './disclosure.js';
|
||||
|
||||
describe('isKeyframeTimestampLabel', () => {
|
||||
it('matches bare MM:SS and H:MM:SS markers only', () => {
|
||||
expect(isKeyframeTimestampLabel('<00:11>')).toBe(true);
|
||||
expect(isKeyframeTimestampLabel('<02:49>')).toBe(true);
|
||||
expect(isKeyframeTimestampLabel('<1:02:03>')).toBe(true);
|
||||
expect(isKeyframeTimestampLabel('<12:34:56>')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the first-frame header and non-marker text', () => {
|
||||
expect(
|
||||
isKeyframeTimestampLabel('原视频 720s/1922×1080 → 关键帧\n<00:11>'),
|
||||
).toBe(false);
|
||||
expect(isKeyframeTimestampLabel('<00:11> 关键帧')).toBe(false);
|
||||
expect(isKeyframeTimestampLabel('00:11')).toBe(false);
|
||||
expect(isKeyframeTimestampLabel('<scene>')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDisclosureText', () => {
|
||||
it('delivers a bare timestamp marker WITHOUT the degradation prefix', () => {
|
||||
expect(formatDisclosureText('video.mp4', '<00:34>')).toBe('<00:34>');
|
||||
});
|
||||
|
||||
it('prefixes a normal (non-marker) disclosure, including the header', () => {
|
||||
const header = '原视频 720s/1922×1080 → 关键帧,缩放至 336×196\n<00:11>';
|
||||
const out = formatDisclosureText('video.mp4', header);
|
||||
expect(out.startsWith(OMNI_DISCLOSURE_TEXT_PREFIX)).toBe(true);
|
||||
expect(out).toContain('video.mp4');
|
||||
expect(out).toContain('<00:11>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDisclosureText', () => {
|
||||
it('recognizes both prefixed disclosures and bare timestamp markers', () => {
|
||||
expect(isDisclosureText(formatDisclosureText('v.mp4', 'x → y'))).toBe(true);
|
||||
// Bare markers carry no prefix but must migrate with their image.
|
||||
expect(isDisclosureText('<00:34>')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat ordinary model text as a disclosure', () => {
|
||||
expect(isDisclosureText('the blue box says Figs in a Blanket')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource annotation forms', () => {
|
||||
it('handle form parses as a handle, not a path', () => {
|
||||
const text = formatResourceHandleText('pic.png', 'media-3-9f2cabcd');
|
||||
expect(parseResourceHandleText(text)).toBe('media-3-9f2cabcd');
|
||||
// Its 【媒体资源】 marker (not the path form's 【媒体路径】) keeps it out
|
||||
// of the path parser.
|
||||
expect(parseResourcePathText(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('path form parses as a path, not a handle', () => {
|
||||
const p = '/workspace/kf/clip-keyframe-0001.jpg';
|
||||
const text = formatResourcePathText(p);
|
||||
expect(parseResourcePathText(text)).toBe(p);
|
||||
expect(parseResourceHandleText(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a path with a full-width separator VERBATIM (re-readable, R2-6)', () => {
|
||||
// The guidance promises a displayed local path can be re-read or handed
|
||||
// straight to a tool — true only if the model-visible payload IS the real
|
||||
// on-disk path. So a `:` in the name rides verbatim under the path
|
||||
// marker, never escaped: an escaped `odd\:name` would fail fs.lstat with
|
||||
// ENOENT, and the round-trip parse returns the same bytes.
|
||||
const p = '/tmp/odd:name/frame.jpg';
|
||||
const text = formatResourcePathText(p);
|
||||
expect(text).toBe(`${OMNI_RESOURCE_PATH_TEXT_PREFIX}${p}`);
|
||||
expect(parseResourceHandleText(text)).toBeUndefined();
|
||||
expect(parseResourcePathText(text)).toBe(p);
|
||||
});
|
||||
|
||||
it('does not misread a path ending in a handle-shaped suffix as a handle', () => {
|
||||
// A file literally named `.../clip:media-3-9f2cabcd` (full-width colon is
|
||||
// a legal filename char). Disambiguation is by MARKER, not escaping: the
|
||||
// path form's 【媒体路径】 marker means parseResourceHandleText (which keys
|
||||
// on 【媒体资源】) never sees it, so the whole payload is the path. The
|
||||
// genuine handle form of the same suffix still parses as a handle.
|
||||
const pathText = formatResourcePathText('/tmp/clip:media-3-9f2cabcd');
|
||||
expect(parseResourceHandleText(pathText)).toBeUndefined();
|
||||
expect(parseResourcePathText(pathText)).toBe('/tmp/clip:media-3-9f2cabcd');
|
||||
const handleText = formatResourceHandleText('clip', 'media-3-9f2cabcd');
|
||||
expect(parseResourceHandleText(handleText)).toBe('media-3-9f2cabcd');
|
||||
expect(parseResourcePathText(handleText)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-annotation text parses as neither form', () => {
|
||||
expect(parseResourcePathText('just some text')).toBeUndefined();
|
||||
expect(parseResourceHandleText('just some text')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT consume prefixed prose that is not an absolute path', () => {
|
||||
// Ordinary text that merely begins with a resource marker — a pasted
|
||||
// line, an @-mentioned document whose first line is `【媒体路径】清单` — must
|
||||
// not be mistaken for a path annotation, or the exporter would delete it
|
||||
// from request text and fabricate a phantom media entry keyed on it. The
|
||||
// path parser's isAbsolutePathLike guard rejects the non-path payload.
|
||||
expect(parseResourcePathText('【媒体路径】清单')).toBeUndefined();
|
||||
expect(parseResourcePathText('【媒体路径】see attached')).toBeUndefined();
|
||||
expect(
|
||||
parseResourcePathText('【媒体路径】relative/path.mp4'),
|
||||
).toBeUndefined();
|
||||
expect(parseResourceHandleText('【媒体资源】清单')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts absolute paths on any OS shape (POSIX / Windows drive / UNC)', () => {
|
||||
for (const p of [
|
||||
'/movies/film.mkv',
|
||||
'C:\\Users\\jane\\clip.mp4',
|
||||
'D:/media/clip.mp4',
|
||||
'\\\\host\\share\\clip.mp4',
|
||||
]) {
|
||||
const text = formatResourcePathText(p);
|
||||
expect(parseResourcePathText(text)).toBe(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Disclosure text delivery (decision D8): a lossy policy derivative must
|
||||
* reach the model with its disclosure IMMEDIATELY adjacent to the media
|
||||
* Part, so provider converters that relocate media (splitToolMedia) can
|
||||
* move the pair together and the model can attribute the disclosure to
|
||||
* the right resource.
|
||||
*
|
||||
* Deliberately a leaf module — imported by both the omni pipeline and the
|
||||
* OpenAI converter, so it must not pull in either side.
|
||||
*/
|
||||
|
||||
/** Marks a text Part as a media-degradation disclosure. Converters key on
|
||||
* this prefix to keep the disclosure adjacent to its media part. */
|
||||
export const OMNI_DISCLOSURE_TEXT_PREFIX = '【媒体降质】';
|
||||
|
||||
/**
|
||||
* The annotation grammar separates the display name from the payload with
|
||||
* a full-width colon — but a display name (a basename) may itself contain
|
||||
* one. Writers escape the name so a reader can split at the first
|
||||
* UNESCAPED separator instead of guessing; names without the separator
|
||||
* pass through unchanged, so the model-visible text is identical in the
|
||||
* common case.
|
||||
*/
|
||||
export function escapeAnnotationName(name: string): string {
|
||||
return name.replaceAll('\\', '\\\\').replaceAll(':', '\\:');
|
||||
}
|
||||
|
||||
/** Inverse of {@link escapeAnnotationName}. */
|
||||
export function unescapeAnnotationName(escaped: string): string {
|
||||
return escaped.replace(/\\([\s\S])/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an annotation body `<escaped-name>:<payload>` at the first
|
||||
* unescaped separator. Returns undefined when no unescaped separator
|
||||
* exists (not an annotation body).
|
||||
*/
|
||||
export function splitAnnotationBody(
|
||||
body: string,
|
||||
): { name: string; payload: string } | undefined {
|
||||
let escaped = false;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === ':') {
|
||||
return {
|
||||
name: unescapeAnnotationName(body.slice(0, i)),
|
||||
payload: body.slice(i + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare keyframe timestamp label like `<00:11>` or `<1:02:03>` — the
|
||||
* adjacent text for one sampled frame (mirrors read_video's per-frame
|
||||
* `<timestamp>`). It deliberately carries NO 【媒体降质】<name>: prefix: the
|
||||
* shared degradation notice is stated once on the first frame's header, and
|
||||
* repeating the prefix on every one of N frames is pure noise. Anchored so
|
||||
* only a lone marker matches — the first frame's header (which begins with
|
||||
* 原视频…) does not.
|
||||
*/
|
||||
export function isKeyframeTimestampLabel(text: string): boolean {
|
||||
return /^<\d{1,2}:\d{2}(?::\d{2})?>$/.test(text);
|
||||
}
|
||||
|
||||
/** Model-facing disclosure text for one degraded resource. */
|
||||
export function formatDisclosureText(
|
||||
displayName: string,
|
||||
disclosure: string,
|
||||
): string {
|
||||
// Bare per-frame timestamp markers are delivered clean — the one-time
|
||||
// degradation notice already rode on the first keyframe's header.
|
||||
if (isKeyframeTimestampLabel(disclosure)) {
|
||||
return disclosure;
|
||||
}
|
||||
return `${OMNI_DISCLOSURE_TEXT_PREFIX}${escapeAnnotationName(displayName)}:${disclosure}`;
|
||||
}
|
||||
|
||||
/** Whether a text is a disclosure emitted by {@link formatDisclosureText}. */
|
||||
export function isDisclosureText(text: string): boolean {
|
||||
// Bare timestamp markers carry no prefix, but must still travel WITH their
|
||||
// image when splitToolMedia relocates media out of a tool message.
|
||||
return (
|
||||
text.startsWith(OMNI_DISCLOSURE_TEXT_PREFIX) ||
|
||||
isKeyframeTimestampLabel(text)
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks a text Part as an explicit-omission notice: the transport guard
|
||||
* could not bring a resource within limits, so the media was withheld and
|
||||
* this text stands in its place (policy design §10.2). */
|
||||
export const OMNI_OMISSION_TEXT_PREFIX = '【媒体省略】';
|
||||
|
||||
/** Model-facing omission notice for one withheld resource. */
|
||||
export function formatOmissionText(
|
||||
displayName: string,
|
||||
reason: string,
|
||||
): string {
|
||||
return `${OMNI_OMISSION_TEXT_PREFIX}${escapeAnnotationName(displayName)}:${reason}`;
|
||||
}
|
||||
|
||||
/** Marks a text Part as a media transcript: a text derivative (upstream P
|
||||
* §6.2 transcript protocol, `metadata.omniRole: 'transcript'`) produced by
|
||||
* a fixed policy and delivered as text instead of (or alongside) the media
|
||||
* Part. */
|
||||
export const OMNI_TRANSCRIPT_TEXT_PREFIX = '【媒体转写】';
|
||||
|
||||
/** Model-facing transcript text for one media resource. */
|
||||
export function formatTranscriptText(
|
||||
displayName: string,
|
||||
transcript: string,
|
||||
): string {
|
||||
return `${OMNI_TRANSCRIPT_TEXT_PREFIX}${escapeAnnotationName(displayName)}:${transcript}`;
|
||||
}
|
||||
|
||||
/** Marks a text Part as a session resource annotation in the HANDLE form
|
||||
* ({@link formatResourceHandleText}): the opaque `resourceId` minted for
|
||||
* the resource (memory design M §5.2), used for path-less sources
|
||||
* (tool/URL/recall media) whose real locator is an internal object-store
|
||||
* path the model must never see. The path form uses a SEPARATE marker
|
||||
* ({@link OMNI_RESOURCE_PATH_TEXT_PREFIX}) so a verbatim absolute path —
|
||||
* which may legally contain the `:` separator and backslashes — can never
|
||||
* be misread as `<name>:<handle>`. The model references whichever value it
|
||||
* is shown in `omni_recall_media_memory` (and other omni tools that accept
|
||||
* a resourceId / inputPath); recall resolves the path form back to the
|
||||
* session handle. */
|
||||
export const OMNI_RESOURCE_HANDLE_TEXT_PREFIX = '【媒体资源】';
|
||||
|
||||
/** Marks a text Part as a session resource annotation in the PATH form
|
||||
* ({@link formatResourcePathText}): the ABSOLUTE PATH of a model-visible
|
||||
* local file, shown in place of the handle because the model already holds
|
||||
* that path and can re-read it or point tools at it directly. Its own
|
||||
* marker (distinct from {@link OMNI_RESOURCE_HANDLE_TEXT_PREFIX}) lets the
|
||||
* path ride VERBATIM — never escaped — so the displayed reference is
|
||||
* byte-for-byte the real on-disk path on every OS. */
|
||||
export const OMNI_RESOURCE_PATH_TEXT_PREFIX = '【媒体路径】';
|
||||
|
||||
/** Model-facing resource-handle annotation for one delivered resource. */
|
||||
export function formatResourceHandleText(
|
||||
displayName: string,
|
||||
resourceId: string,
|
||||
): string {
|
||||
return `${OMNI_RESOURCE_HANDLE_TEXT_PREFIX}${escapeAnnotationName(displayName)}:${resourceId}`;
|
||||
}
|
||||
|
||||
/** Extract the resourceId from a handle annotation emitted by
|
||||
* {@link formatResourceHandleText}, or undefined for any other text
|
||||
* (including the path form). The handle grammar is harness-minted
|
||||
* (`media-<n>-<hex>`), so parsing keys on it rather than on the displayName
|
||||
* (which may itself contain the separator).
|
||||
*
|
||||
* The split is located at the FIRST UNESCAPED separator via
|
||||
* {@link splitAnnotationBody}: a displayName may itself contain a `:`, which
|
||||
* the writer escapes, so an escape-blind split would cut at the wrong
|
||||
* separator and lose the handle. (The path form is turned away by the marker
|
||||
* check above — it rides its own {@link OMNI_RESOURCE_PATH_TEXT_PREFIX}.) And
|
||||
* only a payload matching the exact handle grammar is accepted, so neither a
|
||||
* genuine path nor a displayName ending in a handle-shaped suffix can
|
||||
* masquerade as a real handle. */
|
||||
export function parseResourceHandleText(text: string): string | undefined {
|
||||
if (!text.startsWith(OMNI_RESOURCE_HANDLE_TEXT_PREFIX)) return undefined;
|
||||
const split = splitAnnotationBody(
|
||||
text.slice(OMNI_RESOURCE_HANDLE_TEXT_PREFIX.length),
|
||||
);
|
||||
if (!split) return undefined;
|
||||
return /^media-\d+-[0-9a-f]+$/.test(split.payload)
|
||||
? split.payload
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing resource annotation for media the model can re-read from a
|
||||
* real local path: the ABSOLUTE PATH stands in for the opaque handle. Used
|
||||
* when the source is a model-visible local file (the model already holds
|
||||
* the path and can read_file it or point tools at it directly), so an
|
||||
* opaque `media-<n>-<hex>` handle would be redundant noise. Recall (both
|
||||
* the active tool and the passive selector) recovers the session handle
|
||||
* from the path via `MediaResourceRegistry.resolveByFileRef` — the binding
|
||||
* is registered regardless of which form is shown. Path-less sources
|
||||
* (tool/URL media, whose real locator is an internal object-store path)
|
||||
* keep the handle form ({@link formatResourceHandleText}): no usable path
|
||||
* exists to show.
|
||||
*
|
||||
* The path rides VERBATIM under its own marker
|
||||
* ({@link OMNI_RESOURCE_PATH_TEXT_PREFIX}) — never escaped — so the
|
||||
* displayed reference is byte-for-byte the on-disk path (re-readable on
|
||||
* every OS), and the marker alone tells {@link parseResourcePathText} it
|
||||
* apart from the handle form.
|
||||
*/
|
||||
export function formatResourcePathText(absolutePath: string): string {
|
||||
return `${OMNI_RESOURCE_PATH_TEXT_PREFIX}${absolutePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `p` has the shape of an absolute filesystem path on any OS —
|
||||
* POSIX (`/…`), a Windows drive (`C:\…` / `C:/…`), or a Windows UNC
|
||||
* (`\\host\share`). Deliberately platform-INDEPENDENT: a trajectory captured
|
||||
* on one OS is parsed on another, so `node:path.isAbsolute` (which only
|
||||
* recognizes the host's grammar) is the wrong test. Used to keep
|
||||
* {@link parseResourcePathText} from consuming ordinary prose that merely
|
||||
* starts with the resource prefix — `formatResourcePathText` is only ever
|
||||
* handed an absolute path.
|
||||
*/
|
||||
export function isAbsolutePathLike(p: string): boolean {
|
||||
return (
|
||||
p.startsWith('/') || // POSIX
|
||||
/^[A-Za-z]:[\\/]/.test(p) || // Windows drive
|
||||
p.startsWith('\\\\') // Windows UNC (unescaped)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the absolute path from a path-form resource annotation emitted by
|
||||
* {@link formatResourcePathText}, or undefined for any other text. Keys on
|
||||
* the path form's OWN marker ({@link OMNI_RESOURCE_PATH_TEXT_PREFIX}), so
|
||||
* the handle form (`【媒体资源】`) is never a candidate and the path rides
|
||||
* verbatim — no unescaping.
|
||||
*
|
||||
* The payload must ALSO look like an absolute path ({@link isAbsolutePathLike}):
|
||||
* `formatResourcePathText` only ever emits one, and the guard stops ordinary
|
||||
* prose that merely opens with the marker (a pasted `【媒体路径】清单` line,
|
||||
* an `@`-mentioned document whose first line starts with it) from being
|
||||
* mistaken for an annotation. This textual guard is necessary but NOT
|
||||
* sufficient — a pasted absolute-path line is byte-identical to a genuine
|
||||
* annotation, so consumers that must never fabricate media (the exporter)
|
||||
* additionally gate on {@link isHarnessAnnotationPart} provenance.
|
||||
*/
|
||||
export function parseResourcePathText(text: string): string | undefined {
|
||||
if (!text.startsWith(OMNI_RESOURCE_PATH_TEXT_PREFIX)) return undefined;
|
||||
const path = text.slice(OMNI_RESOURCE_PATH_TEXT_PREFIX.length);
|
||||
return isAbsolutePathLike(path) ? path : undefined;
|
||||
}
|
||||
|
||||
/** A harness-written path-form annotation Part: the text of
|
||||
* {@link formatResourcePathText} plus a provenance tag. The path form's TEXT
|
||||
* is byte-identical whether the harness emitted it or a user pasted /
|
||||
* `@`-mentioned it, so text alone cannot tell a genuine annotation from
|
||||
* prose. The writer tags its Parts; the exporter consumes a path-form Part
|
||||
* only when this tag survived the transcript. The field rides the recorded
|
||||
* Content (createUserContent preserves unknown Part fields, like `thought`)
|
||||
* but the OpenAI converter rebuilds wire Parts by picking known fields, so
|
||||
* the tag never reaches the model API. Structural (not genai `Part`) so the
|
||||
* cycle-avoiding omni writer can return it as a plain `{ text }` shape. */
|
||||
export interface HarnessPathAnnotationPart {
|
||||
text: string;
|
||||
omniHarnessAnnotation: true;
|
||||
}
|
||||
|
||||
/** Build the path-form annotation Part the writer emits, tagged with
|
||||
* {@link HarnessPathAnnotationPart} provenance. Shared by the writer and
|
||||
* tests so a test models the real emitted shape. */
|
||||
export function harnessPathAnnotationPart(
|
||||
absolutePath: string,
|
||||
): HarnessPathAnnotationPart {
|
||||
return {
|
||||
text: formatResourcePathText(absolutePath),
|
||||
omniHarnessAnnotation: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when `part` carries the {@link HarnessPathAnnotationPart} provenance
|
||||
* tag (mirrors the exporter's `isThoughtPart` field read). */
|
||||
export function isHarnessAnnotationPart(part: unknown): boolean {
|
||||
return (
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
(part as Partial<HarnessPathAnnotationPart>).omniHarnessAnnotation === true
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { isPrivateHost, isPermittedRedirect } from '../utils/fetch.js';
|
||||
import {
|
||||
resolveNetworkTarget,
|
||||
type ResolvedNetworkTarget,
|
||||
} from '../extension/network-policy.js';
|
||||
import { loadUndici, detectRuntime } from '../utils/runtimeFetchOptions.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { prepareOmniDownloadsDir } from './storage.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:download');
|
||||
|
||||
/** Thrown for URL localization failures. Messages must stay free of
|
||||
* credentials and local filesystem paths; they may include the URL host and
|
||||
* HTTP status. */
|
||||
export class OmniDownloadError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'OmniDownloadError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadedMedia {
|
||||
/** Path of the fully-written temp file (inside downloads/). Caller is
|
||||
* responsible for recognition, promotion into objects/, and cleanup. This
|
||||
* is deliberately the ONLY field: the pipeline re-derives identity (hash,
|
||||
* sniff, size) from the file on disk — transfer-time observations such as
|
||||
* the server's Content-Type or the final URL must never be trusted, so
|
||||
* they are not carried here at all. */
|
||||
partPath: string;
|
||||
}
|
||||
|
||||
/** How long the DNS resolve-and-pin step may take before the download is
|
||||
* declared stuck. Without it, resolution would be the only phase with no
|
||||
* watchdog: a resolver that never answers (and a caller that never aborts)
|
||||
* would spin the turn forever. The race abandons rather than cancels the
|
||||
* underlying lookup — it only stops waiting for it. */
|
||||
const RESOLVE_TIMEOUT_MS = 30_000;
|
||||
const HEADER_TIMEOUT_MS = 30_000;
|
||||
const IDLE_TIMEOUT_MS = 60_000;
|
||||
/** Exported so the redirect-budget test can pin the exact fetch count to
|
||||
* this constant rather than to a magic number that drifts. */
|
||||
export const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* Test whether an @-path is a downloadable http(s) URL.
|
||||
*
|
||||
* Deliberately still matches `http://` even though `downloadMediaUrl` refuses
|
||||
* plaintext: recognizing it yields an explicit "must be https" error, whereas
|
||||
* failing to recognize it would let the ref fall through to filesystem
|
||||
* resolution, where the ENOENT skip drops it with no message at all.
|
||||
*/
|
||||
export function parseHttpUrlRef(pathName: string): URL | null {
|
||||
if (!/^https?:\/\//i.test(pathName)) return null;
|
||||
try {
|
||||
return new URL(pathName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip absolute filesystem paths from a message before it enters an
|
||||
* OmniDownloadError, keeping only the basename. fs and stream errors embed
|
||||
* full local paths (`ENOTDIR: not a directory, mkdir '/home/x/…'`), and
|
||||
* OmniDownloadError messages reach the UI and the debug log. The wrap sites
|
||||
* below only interpolate fs/undici error text — never URLs — so a
|
||||
* separator-based pattern cannot eat anything it should keep.
|
||||
*/
|
||||
function scrubPathsFromMessage(message: string): string {
|
||||
return message.replace(
|
||||
/(?:[A-Za-z]:)?(?:[\\/][^\s'"`\\/]+){2,}[\\/]?/g,
|
||||
(match) => path.basename(match),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an error's `cause` chain into "CODE: message" fragments. Undici
|
||||
* reports connection-level failures as a bare `TypeError: fetch failed` with
|
||||
* the actionable reason (ECONNREFUSED, TLS codes) on `cause`; without this
|
||||
* the user would see "fetch failed" and nothing they can act on.
|
||||
*/
|
||||
function formatCauseChain(err: unknown): string | undefined {
|
||||
const parts: string[] = [];
|
||||
let cause: unknown = err instanceof Error ? err.cause : undefined;
|
||||
for (let depth = 0; cause != null && depth < 4; depth++) {
|
||||
const code = (cause as { code?: unknown }).code;
|
||||
const message =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: typeof cause === 'string'
|
||||
? cause
|
||||
: undefined;
|
||||
if (typeof code === 'string' && message && !message.includes(code)) {
|
||||
parts.push(`${code}: ${message}`);
|
||||
} else if (message) {
|
||||
parts.push(message);
|
||||
} else if (typeof code === 'string') {
|
||||
parts.push(code);
|
||||
}
|
||||
cause = cause instanceof Error ? cause.cause : undefined;
|
||||
}
|
||||
return parts.length > 0 ? scrubPathsFromMessage(parts.join('; ')) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a configured HTTP(S) proxy. Mirrors how the rest of the codebase
|
||||
* decides to proxy: config.ts installs a process-wide `EnvHttpProxyAgent` as
|
||||
* the global dispatcher when `--proxy`/`settings.proxy` resolves to a value
|
||||
* (see Config.initialize), and `EnvHttpProxyAgent` itself honors the
|
||||
* `HTTP(S)_PROXY` environment variables — so a proxy is in play if either
|
||||
* the env vars are set or that global dispatcher has been installed.
|
||||
*/
|
||||
function detectConfiguredProxy(
|
||||
undici: Awaited<ReturnType<typeof loadUndici>>,
|
||||
): boolean {
|
||||
for (const key of [
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY',
|
||||
]) {
|
||||
if (process.env[key]) return true;
|
||||
}
|
||||
try {
|
||||
return undici.getGlobalDispatcher() instanceof undici.EnvHttpProxyAgent;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a media URL into `downloads/<id>.part`.
|
||||
*
|
||||
* Policy (mirrors utils/fetch.ts fetchWithPolicy, but streaming — that
|
||||
* helper buffers whole bodies in memory and is unsuitable for GiB media):
|
||||
* - https only (plaintext is refused: media swapped in flight would be
|
||||
* uploaded to a third party under the user's account); private/loopback
|
||||
* hosts refused (SSRF), by hostname text
|
||||
* AND by resolved address — and the vetted address is then PINNED to the
|
||||
* connection via an undici agent whose `connect.lookup` returns only that
|
||||
* address, so the client cannot re-resolve the name after validation. A
|
||||
* preflight check alone is check-then-connect: `fetch` resolves again, and a
|
||||
* low-TTL record answering public once then private would still be
|
||||
* connected to. Validation failure (including a name that will not resolve)
|
||||
* fails closed — these bytes are uploaded to a third party, so an
|
||||
* unverifiable host must not be fetched at all;
|
||||
* - re-validated and re-pinned at every redirect hop (a permitted same-host
|
||||
* redirect can still re-resolve elsewhere between hops);
|
||||
* - Node only: refused outright on runtimes that accept the pinning agent and
|
||||
* ignore it (Bun), since there the pin is unenforceable;
|
||||
* - refused outright behind a configured proxy, for the same reason as the
|
||||
* Bun case: through a proxy CONNECT tunnel the proxy resolves the name
|
||||
* itself and the pinned `connect.lookup` never runs, so the pin would be a
|
||||
* claim we cannot keep — while the bare pinned Agent, dispatching directly,
|
||||
* would silently bypass the proxy users are required to route through;
|
||||
* - redirects followed only when same-host/protocol/port (isPermittedRedirect),
|
||||
* bounded by MAX_REDIRECTS;
|
||||
* - `maxBytes` enforced against BOTH Content-Length and actual bytes written;
|
||||
* - three-timer model: resolve watchdog (30s, covering the DNS
|
||||
* resolve-and-pin step), header timeout (30s, cleared when headers arrive),
|
||||
* plus an idle watchdog reset per chunk (60s) — a slow large body is fine,
|
||||
* a stalled one is not;
|
||||
* - on ANY failure the `.part` file is removed (no half-download survives).
|
||||
*/
|
||||
export async function downloadMediaUrl(params: {
|
||||
url: string;
|
||||
downloadsDir: string;
|
||||
maxBytes: number;
|
||||
signal?: AbortSignal;
|
||||
fetchFn?: typeof fetch;
|
||||
/**
|
||||
* Test seam for the SSRF resolve-and-pin step; production resolves via DNS
|
||||
* and pins the answer (see resolveNetworkTarget). Tests inject a fake so
|
||||
* they neither touch real DNS nor need a listening socket.
|
||||
*/
|
||||
resolveTarget?: (url: string) => Promise<ResolvedNetworkTarget>;
|
||||
/**
|
||||
* Test seam for the proxy gate; production detects via the env vars plus
|
||||
* the global dispatcher (see detectConfiguredProxy). Tests inject a fake
|
||||
* so they neither depend on nor mutate process-wide state.
|
||||
*/
|
||||
detectProxy?: () => boolean;
|
||||
/** Test seams for the three watchdogs. Real timers with tiny injected
|
||||
* values are more faithful here than fake timers, which fight undici's
|
||||
* internals and the stream plumbing. Production always uses the defaults. */
|
||||
resolveTimeoutMs?: number;
|
||||
headerTimeoutMs?: number;
|
||||
idleTimeoutMs?: number;
|
||||
}): Promise<DownloadedMedia> {
|
||||
const {
|
||||
url,
|
||||
downloadsDir,
|
||||
maxBytes,
|
||||
signal,
|
||||
resolveTimeoutMs = RESOLVE_TIMEOUT_MS,
|
||||
headerTimeoutMs = HEADER_TIMEOUT_MS,
|
||||
idleTimeoutMs = IDLE_TIMEOUT_MS,
|
||||
} = params;
|
||||
// `'public'` is what turns on resolution + address vetting + pinning; the
|
||||
// helper is a no-op passthrough for any other policy.
|
||||
const resolveTarget =
|
||||
params.resolveTarget ??
|
||||
((target: string) => resolveNetworkTarget(target, 'public', signal));
|
||||
|
||||
if (isPrivateHost(url)) {
|
||||
throw new OmniDownloadError(
|
||||
`URL host is not publicly routable (refused for safety): ${new URL(url).hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
// isPrivateHost above fails OPEN on unparseable input (returns false), so
|
||||
// this re-parse is where a malformed ref first surfaces — unguarded it
|
||||
// would escape as a raw TypeError instead of the documented error type.
|
||||
// The ref itself stays out of the message (it may embed anything).
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new OmniDownloadError('URL media ref is not a valid URL (refused).');
|
||||
}
|
||||
|
||||
// https only, checked here so the message names the real reason: the
|
||||
// resolve-and-pin step below refuses plaintext (its error would just say
|
||||
// "could not be verified"). Media fetched over http can be swapped in
|
||||
// flight, and these bytes are uploaded to a third party under the user's
|
||||
// account — a URL whose contents cannot be attributed to the named host has
|
||||
// no business being delivered to the model.
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new OmniDownloadError(
|
||||
`URL media must be https (refused for safety): ` +
|
||||
`${parsed.protocol}//${parsed.hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials in the URL are refused explicitly. `resolveNetworkTarget`
|
||||
// would also reject them, but its message says "extension network requests"
|
||||
// (that helper's original caller), which misattributes the reason here. The
|
||||
// check is deliberately silent about the values themselves: OmniDownloadError
|
||||
// messages reach the UI and the debug log.
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new OmniDownloadError(
|
||||
`URL media must not embed credentials (refused for safety): ` +
|
||||
`${parsed.hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse outright on runtimes that cannot bind the connection. Bun accepts
|
||||
// `dispatcher` and silently ignores it (its `fetch` is a native
|
||||
// implementation, and it shims the `undici` module too, so neither the
|
||||
// global nor undici's own `fetch` routes through the agent) — the request
|
||||
// would resolve the name itself and connect wherever it landed, with the
|
||||
// pinned lookup never called. Since this path uploads the fetched bytes to a
|
||||
// third party, an unenforceable pin must be a refusal rather than a claim we
|
||||
// cannot keep. The same runtime fact is merely a missed optimization for
|
||||
// utils/apiPreconnect.ts, which skips non-Node runtimes for this reason.
|
||||
const runtime = detectRuntime();
|
||||
if (runtime !== 'node') {
|
||||
throw new OmniDownloadError(
|
||||
`URL media requires the Node runtime to verify the connection target ` +
|
||||
`(refused for safety on ${runtime}): use a local file path instead of ` +
|
||||
`a URL, or run under Node.`,
|
||||
);
|
||||
}
|
||||
|
||||
const undici = await loadUndici();
|
||||
|
||||
// Refuse outright behind a configured proxy, for the same reason as the
|
||||
// Bun case above: the bare pinned Agent below dispatches DIRECTLY, so it
|
||||
// would silently bypass the proxy config.ts installs process-wide (and any
|
||||
// TLS-inspection policy riding on it) — while routing THROUGH the proxy
|
||||
// would hand name resolution to the proxy's CONNECT handling, where the
|
||||
// pinned `connect.lookup` never runs and the SSRF pin becomes
|
||||
// unenforceable. Either way the promise cannot be kept, so refuse.
|
||||
const proxied = params.detectProxy
|
||||
? params.detectProxy()
|
||||
: detectConfiguredProxy(undici);
|
||||
if (proxied) {
|
||||
throw new OmniDownloadError(
|
||||
`URL media fetching is not supported behind a proxy yet (refused): ` +
|
||||
`the connection pinning that enforces SSRF protection cannot be ` +
|
||||
`applied through a proxy. Download the file manually and use a ` +
|
||||
`local path instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Local filesystem failures (ENOTDIR, EROFS, EACCES) must stay inside the
|
||||
// OmniDownloadError contract, and fs error text embeds the absolute
|
||||
// directory path, which must not reach the UI or the debug log. The
|
||||
// prepare step is also the symlink guard: a link planted at downloads/
|
||||
// would redirect the streamed bytes to an attacker-chosen location.
|
||||
try {
|
||||
await prepareOmniDownloadsDir(downloadsDir);
|
||||
} catch (err) {
|
||||
throw new OmniDownloadError(
|
||||
`Could not prepare the downloads directory for URL media: ${
|
||||
err instanceof Error ? scrubPathsFromMessage(err.message) : String(err)
|
||||
}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
const partPath = path.join(
|
||||
downloadsDir,
|
||||
`${randomBytes(8).toString('hex')}.part`,
|
||||
);
|
||||
|
||||
let currentUrl = url;
|
||||
// Kept outside the loop: the pinned agent must stay open for the whole
|
||||
// streaming read, and each redirect hop replaces it with one pinned to that
|
||||
// hop's freshly vetted address.
|
||||
let dispatcher: import('undici').Dispatcher | undefined;
|
||||
try {
|
||||
for (let redirects = 0; ; redirects++) {
|
||||
// Resolve, vet, and PIN before each connect, every hop. The text-level
|
||||
// gate above cannot see where a public-looking name actually points,
|
||||
// and validating without pinning would only be check-then-connect —
|
||||
// `fetch` would resolve the name a second time and could still be
|
||||
// handed a private address.
|
||||
let target: ResolvedNetworkTarget;
|
||||
const resolveAbort = new AbortController();
|
||||
const resolveTimer = setTimeout(
|
||||
() => resolveAbort.abort(new Error('resolve timeout')),
|
||||
resolveTimeoutMs,
|
||||
);
|
||||
try {
|
||||
const pending = resolveTarget(currentUrl);
|
||||
// The race abandons `pending` on timeout; without this no-op handler
|
||||
// a late rejection from the abandoned lookup would surface as an
|
||||
// unhandled rejection.
|
||||
pending.catch(() => {});
|
||||
target = await Promise.race([
|
||||
pending,
|
||||
new Promise<never>((_, reject) => {
|
||||
resolveAbort.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(resolveAbort.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
if (resolveAbort.signal.aborted) {
|
||||
throw new OmniDownloadError(
|
||||
`Download timed out resolving ${new URL(currentUrl).hostname} ` +
|
||||
`(${resolveTimeoutMs / 1000}s)`,
|
||||
);
|
||||
}
|
||||
// Fail closed: unlike the general web-fetch path, these bytes are
|
||||
// uploaded to a third party, so "could not verify" must mean "do not
|
||||
// fetch" rather than "connect and hope". The resolver's own text is
|
||||
// translated rather than echoed: resolveNetworkTarget phrases its
|
||||
// errors as "Extension network host …" (that helper's original
|
||||
// caller), the same misattribution the credentials check above
|
||||
// avoids — and nothing server-controlled beyond the hostname may
|
||||
// enter the message anyway.
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
const reason = /blocked address/i.test(text)
|
||||
? 'resolved to a non-public address'
|
||||
: /did not resolve/i.test(text)
|
||||
? 'did not resolve'
|
||||
: 'could not be verified';
|
||||
throw new OmniDownloadError(
|
||||
`URL host ${reason} (refused for safety): ` +
|
||||
`${new URL(currentUrl).hostname}`,
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(resolveTimer);
|
||||
}
|
||||
if (!target.lookup) {
|
||||
// No pinned lookup means the connection cannot be bound to the vetted
|
||||
// address, so DNS-rebinding protection would be a claim we cannot
|
||||
// keep. Refuse instead of fetching unpinned.
|
||||
throw new OmniDownloadError(
|
||||
`URL host cannot be safely bound to a verified address ` +
|
||||
`(refused for safety): ${new URL(currentUrl).hostname}`,
|
||||
);
|
||||
}
|
||||
// Default to undici's OWN fetch, not the global one. The dispatcher below
|
||||
// comes from the bundled undici, and Node's built-in fetch may ship a
|
||||
// different major version whose handler-interface check rejects it
|
||||
// (`invalid onError method`) — see runtimeFetchOptions.ts, which pins
|
||||
// undiciFetch for exactly this reason. Here the stakes are higher than a
|
||||
// failed request: if the dispatcher were ever dropped rather than
|
||||
// rejected, the pin would silently not apply, so fetch and Agent must
|
||||
// come from one version.
|
||||
const fetchFn = params.fetchFn ?? (undici.fetch as typeof fetch);
|
||||
// Replace (not accumulate) the previous hop's agent.
|
||||
const previousDispatcher = dispatcher;
|
||||
dispatcher = new undici.Agent({ connect: { lookup: target.lookup } });
|
||||
await previousDispatcher?.close().catch(() => {});
|
||||
const headerAbort = new AbortController();
|
||||
const headerTimer = setTimeout(
|
||||
() => headerAbort.abort(new Error('header timeout')),
|
||||
headerTimeoutMs,
|
||||
);
|
||||
const combined = AbortSignal.any(
|
||||
signal ? [signal, headerAbort.signal] : [headerAbort.signal],
|
||||
);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchFn(currentUrl, {
|
||||
redirect: 'manual',
|
||||
signal: combined,
|
||||
// Not in the DOM RequestInit type; undici reads it (same cast as
|
||||
// services/image-generation-service.ts).
|
||||
dispatcher,
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
// Name the watchdog explicitly: the abort surfaces as a generic
|
||||
// AbortError ("This operation was aborted") with the real reason on
|
||||
// `cause`, so reading err.message alone would make a timeout
|
||||
// indistinguishable from any other failed request.
|
||||
if (headerAbort.signal.aborted) {
|
||||
throw new OmniDownloadError(
|
||||
`Download timed out waiting for response headers from ` +
|
||||
`${new URL(currentUrl).hostname} (${headerTimeoutMs / 1000}s)`,
|
||||
);
|
||||
}
|
||||
// Undici reports connection-level failures as a bare "fetch failed"
|
||||
// TypeError with the actionable reason (ECONNREFUSED, TLS codes) on
|
||||
// `cause` — surface that chain, or the user has nothing to act on.
|
||||
const detail =
|
||||
err instanceof Error
|
||||
? scrubPathsFromMessage(err.message)
|
||||
: String(err);
|
||||
const causeDetail = formatCauseChain(err);
|
||||
throw new OmniDownloadError(
|
||||
`Download request failed for ${new URL(currentUrl).hostname}: ` +
|
||||
`${detail}${causeDetail ? ` (${causeDetail})` : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(headerTimer);
|
||||
}
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
const location = res.headers.get('location');
|
||||
if (!location || redirects >= MAX_REDIRECTS) {
|
||||
throw new OmniDownloadError(
|
||||
`Too many or invalid redirects downloading from ${new URL(currentUrl).hostname}`,
|
||||
);
|
||||
}
|
||||
let redirectUrl: string;
|
||||
try {
|
||||
redirectUrl = new URL(location, currentUrl).toString();
|
||||
} catch {
|
||||
// A malformed Location must surface as a download error, not a
|
||||
// raw TypeError; the header value itself stays out of the message
|
||||
// (server-controlled, and OmniDownloadError reaches the UI).
|
||||
throw new OmniDownloadError(
|
||||
`Redirect with a malformed Location header from ${new URL(currentUrl).hostname}`,
|
||||
);
|
||||
}
|
||||
if (!isPermittedRedirect(currentUrl, redirectUrl)) {
|
||||
throw new OmniDownloadError(
|
||||
`Cross-origin redirect refused: ${new URL(currentUrl).hostname} → ${new URL(redirectUrl).hostname}`,
|
||||
);
|
||||
}
|
||||
currentUrl = redirectUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new OmniDownloadError(
|
||||
`Download failed: HTTP ${res.status} from ${new URL(currentUrl).hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = Number(res.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new OmniDownloadError(
|
||||
`Download exceeds the omni media limit: Content-Length ${contentLength} > ${maxBytes} bytes.`,
|
||||
);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new OmniDownloadError('Download response had no body.');
|
||||
}
|
||||
|
||||
// Stream to disk with byte cap + idle watchdog. Deliberately no hashing
|
||||
// here: the pipeline re-derives identity from the file on disk (see
|
||||
// DownloadedMedia), so a transfer-time hash would be dead work over up
|
||||
// to a GiB of media.
|
||||
let written = 0;
|
||||
const idleAbort = new AbortController();
|
||||
let idleTimer = setTimeout(
|
||||
() => idleAbort.abort(new Error('idle timeout')),
|
||||
idleTimeoutMs,
|
||||
);
|
||||
const streamSignal = AbortSignal.any(
|
||||
signal ? [signal, idleAbort.signal] : [idleAbort.signal],
|
||||
);
|
||||
try {
|
||||
const source = Readable.fromWeb(
|
||||
res.body as import('node:stream/web').ReadableStream,
|
||||
{ signal: streamSignal },
|
||||
);
|
||||
const counter = async function* (src: AsyncIterable<Buffer>) {
|
||||
for await (const chunk of src) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(
|
||||
() => idleAbort.abort(new Error('idle timeout')),
|
||||
idleTimeoutMs,
|
||||
);
|
||||
written += chunk.length;
|
||||
if (written > maxBytes) {
|
||||
throw new OmniDownloadError(
|
||||
`Download exceeds the omni media limit: >${maxBytes} bytes received.`,
|
||||
);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
// The fd must be open BEFORE the pipeline runs: createWriteStream
|
||||
// opens lazily, so a failure on an all-synchronous body (e.g. the
|
||||
// byte cap tripping on the first chunks) could otherwise reject —
|
||||
// and reach the outer `.part` cleanup — before the file was even
|
||||
// created, resurrecting the `.part` after its rm. (On open failure
|
||||
// the stream self-destructs and `once` rejects into the catch.)
|
||||
const destination = createWriteStream(partPath, { mode: 0o600 });
|
||||
await once(destination, 'open');
|
||||
await pipeline(source, counter, destination);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
if (err instanceof OmniDownloadError) throw err;
|
||||
// Same identifiability rule as the header path: the idle abort
|
||||
// reaches here as an AbortError whose message hides the reason.
|
||||
if (idleAbort.signal.aborted) {
|
||||
throw new OmniDownloadError(
|
||||
`Download stalled from ${new URL(currentUrl).hostname}: no data ` +
|
||||
`for ${idleTimeoutMs / 1000}s`,
|
||||
);
|
||||
}
|
||||
// Write-stream failures embed the absolute `.part` path in their
|
||||
// message; scrub before interpolating (same contract as mkdir above).
|
||||
throw new OmniDownloadError(
|
||||
`Download interrupted from ${new URL(currentUrl).hostname}: ${
|
||||
err instanceof Error
|
||||
? scrubPathsFromMessage(err.message)
|
||||
: String(err)
|
||||
}`,
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`downloaded ${written} bytes from ${new URL(currentUrl).hostname}`,
|
||||
);
|
||||
return { partPath };
|
||||
}
|
||||
} catch (err) {
|
||||
await fs.rm(partPath, { force: true });
|
||||
throw err;
|
||||
} finally {
|
||||
// The agent owns a live connection pool; the streaming read above needs it
|
||||
// open until the body is fully consumed, so it can only be closed here.
|
||||
await dispatcher?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { estimateRawResourceTokens } from './estimation.js';
|
||||
import type { RecognizedMedia } from './recognition.js';
|
||||
|
||||
function media(
|
||||
modality: RecognizedMedia['modality'],
|
||||
metadata: RecognizedMedia['metadata'],
|
||||
): RecognizedMedia {
|
||||
return {
|
||||
modality,
|
||||
detectedMimeType: 'x/y',
|
||||
sizeBytes: 1,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe('estimateRawResourceTokens (raw-resource-v1)', () => {
|
||||
it('audio: ceil(seconds × 7)', () => {
|
||||
expect(
|
||||
estimateRawResourceTokens(media('audio', { durationMs: 5000 })),
|
||||
).toEqual({
|
||||
estimatedTokenCount: 35,
|
||||
method: 'raw-resource-v1',
|
||||
status: 'ok',
|
||||
});
|
||||
// 5.1s → ceil(35.7) = 36
|
||||
expect(
|
||||
estimateRawResourceTokens(media('audio', { durationMs: 5100 }))
|
||||
.estimatedTokenCount,
|
||||
).toBe(36);
|
||||
});
|
||||
|
||||
it('image: ceil(w×h / (32×32×2)), frameCount 1', () => {
|
||||
const est = estimateRawResourceTokens(
|
||||
media('image', { width: 800, height: 600 }),
|
||||
);
|
||||
expect(est.estimatedTokenCount).toBe(Math.ceil((800 * 600) / 2048));
|
||||
expect(est.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('animated image: real frameCount from the probe, not 1', () => {
|
||||
// A 480×480 300-frame GIF at one frame would estimate ~113 tokens and
|
||||
// sail under any realistic guard threshold; the real estimate is ~33,750.
|
||||
const est = estimateRawResourceTokens(
|
||||
media('image', { width: 480, height: 480, frameCount: 300 }),
|
||||
);
|
||||
expect(est.estimatedTokenCount).toBe(Math.ceil((480 * 480 * 300) / 2048));
|
||||
expect(est.status).toBe('ok');
|
||||
// Degenerate frame counts fall back to the static single frame.
|
||||
expect(
|
||||
estimateRawResourceTokens(
|
||||
media('image', { width: 480, height: 480, frameCount: 0 }),
|
||||
).estimatedTokenCount,
|
||||
).toBe(Math.ceil((480 * 480) / 2048));
|
||||
});
|
||||
|
||||
it('video: frameCount from duration × frameRate', () => {
|
||||
const est = estimateRawResourceTokens(
|
||||
media('video', {
|
||||
width: 852,
|
||||
height: 480,
|
||||
durationMs: 10_000,
|
||||
frameRate: 30,
|
||||
}),
|
||||
);
|
||||
expect(est.estimatedTokenCount).toBe(Math.ceil((852 * 480 * 300) / 2048));
|
||||
expect(est.method).toBe('raw-resource-v1');
|
||||
});
|
||||
|
||||
it('missing required fields → unavailable, never guessed', () => {
|
||||
expect(estimateRawResourceTokens(media('audio', {})).status).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
expect(
|
||||
estimateRawResourceTokens(media('image', { width: 100 })).status,
|
||||
).toBe('unavailable');
|
||||
expect(
|
||||
estimateRawResourceTokens(
|
||||
media('video', { width: 100, height: 100, durationMs: 1000 }),
|
||||
).status,
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('degenerate present values → unavailable, never a 0-token ok', () => {
|
||||
// A truncated/still-being-written capture can probe as duration 0 —
|
||||
// an 'ok' 0-token estimate would sail under the transport guard while
|
||||
// violating the invariant that 'ok' means a real estimate.
|
||||
for (const metadata of [
|
||||
{ width: 100, height: 100, durationMs: 0, frameRate: 30 },
|
||||
{ width: 0, height: 100, durationMs: 1000, frameRate: 30 },
|
||||
{ width: 100, height: 100, durationMs: 1000, frameRate: NaN },
|
||||
{ width: 100, height: -1, durationMs: 1000, frameRate: 30 },
|
||||
]) {
|
||||
expect(estimateRawResourceTokens(media('video', metadata)).status).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
}
|
||||
expect(
|
||||
estimateRawResourceTokens(media('audio', { durationMs: 0 })).status,
|
||||
).toBe('unavailable');
|
||||
expect(
|
||||
estimateRawResourceTokens(media('image', { width: 0, height: 50 }))
|
||||
.status,
|
||||
).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { RecognizedMedia } from './recognition.js';
|
||||
|
||||
/**
|
||||
* Raw-resource token estimate for an omni media input.
|
||||
*
|
||||
* The formula set is versioned via `method` so a revised estimator (e.g.
|
||||
* one aligned with confirmed server-side accounting) can be added and
|
||||
* switched without touching call sites. Consumers must treat
|
||||
* `status: 'unavailable'` as "no estimate" — never guess missing fields.
|
||||
*/
|
||||
export interface OmniTokenEstimate {
|
||||
/** Estimated token count for the raw resource. */
|
||||
estimatedTokenCount: number;
|
||||
/** Estimator identity+version that produced this value. */
|
||||
method: 'raw-resource-v1';
|
||||
status: 'ok' | 'unavailable';
|
||||
}
|
||||
|
||||
const AUDIO_TOKENS_PER_SECOND = 7;
|
||||
/** Pixels per visual token: 32×32 patch, 2 pixels per patch cell. */
|
||||
const VISUAL_PIXELS_PER_TOKEN = 32 * 32 * 2;
|
||||
|
||||
/** Positive, finite number — rejects 0, negatives, NaN and Infinity. */
|
||||
function usable(value: number | undefined): value is number {
|
||||
return value !== undefined && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens from recognized raw-resource metadata (design doc §6.4):
|
||||
*
|
||||
* audioTokens = ceil(durationSeconds × 7)
|
||||
* visualTokens = ceil(width × height × frameCount / (32 × 32 × 2))
|
||||
*
|
||||
* - static image → frameCount 1; animated image (GIF/APNG/animated WebP)
|
||||
* → real frameCount from the probe when reported
|
||||
* - video → frameCount from ceil(duration × frameRate); audio track of a
|
||||
* video is NOT separately estimated in v1 (the visual term dominates and
|
||||
* the raw formula is already a conservative upper bound)
|
||||
* - any required field missing OR degenerate (zero/negative/non-finite) →
|
||||
* status 'unavailable'. `'ok'` means a real estimate: a truncated capture
|
||||
* probing as duration 0 must not produce a 0-token 'ok' that sails under
|
||||
* the transport guard.
|
||||
*/
|
||||
export function estimateRawResourceTokens(
|
||||
media: RecognizedMedia,
|
||||
): OmniTokenEstimate {
|
||||
const unavailable: OmniTokenEstimate = {
|
||||
estimatedTokenCount: 0,
|
||||
method: 'raw-resource-v1',
|
||||
status: 'unavailable',
|
||||
};
|
||||
|
||||
switch (media.modality) {
|
||||
case 'audio': {
|
||||
const durationMs = media.metadata.durationMs;
|
||||
if (!usable(durationMs)) return unavailable;
|
||||
return {
|
||||
estimatedTokenCount: Math.ceil(
|
||||
(durationMs / 1000) * AUDIO_TOKENS_PER_SECOND,
|
||||
),
|
||||
method: 'raw-resource-v1',
|
||||
status: 'ok',
|
||||
};
|
||||
}
|
||||
case 'image': {
|
||||
const { width, height, frameCount } = media.metadata;
|
||||
if (!usable(width) || !usable(height)) return unavailable;
|
||||
// Animated images (GIF/APNG/animated WebP) carry their real frame
|
||||
// count from the probe — a 300-frame GIF estimated as one frame would
|
||||
// sail under the transport guard at ~1/300 of its real cost. Static
|
||||
// images (and containers whose frame count ffprobe does not report)
|
||||
// keep frameCount 1 per the design formula.
|
||||
const frames = usable(frameCount) ? frameCount : 1;
|
||||
return {
|
||||
estimatedTokenCount: Math.ceil(
|
||||
(width * height * frames) / VISUAL_PIXELS_PER_TOKEN,
|
||||
),
|
||||
method: 'raw-resource-v1',
|
||||
status: 'ok',
|
||||
};
|
||||
}
|
||||
case 'video': {
|
||||
const { width, height, durationMs, frameRate } = media.metadata;
|
||||
if (
|
||||
!usable(width) ||
|
||||
!usable(height) ||
|
||||
!usable(durationMs) ||
|
||||
!usable(frameRate)
|
||||
) {
|
||||
return unavailable;
|
||||
}
|
||||
const frameCount = Math.ceil((durationMs / 1000) * frameRate);
|
||||
return {
|
||||
estimatedTokenCount: Math.ceil(
|
||||
(width * height * frameCount) / VISUAL_PIXELS_PER_TOKEN,
|
||||
),
|
||||
method: 'raw-resource-v1',
|
||||
status: 'ok',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return unavailable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MediaMemoryService } from '../services/media-memory/index.js';
|
||||
import {
|
||||
formatDisclosureText,
|
||||
formatOmissionText,
|
||||
formatResourceHandleText,
|
||||
formatResourcePathText,
|
||||
formatTranscriptText,
|
||||
harnessPathAnnotationPart,
|
||||
} from './disclosure.js';
|
||||
import {
|
||||
exportOmniTrajectory,
|
||||
serializeOmniTrajectory,
|
||||
writeOmniTrajectoryJsonl,
|
||||
type OmniTrajectoryExecutionRecord,
|
||||
type OmniTrajectoryFileRecord,
|
||||
type OmniTrajectoryTurnRecord,
|
||||
} from './export.js';
|
||||
|
||||
describe('exportOmniTrajectory', () => {
|
||||
let tmpDir: string;
|
||||
let omniRootDir: string;
|
||||
let transcriptPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-export-'));
|
||||
omniRootDir = path.join(tmpDir, 'omni');
|
||||
await fs.mkdir(omniRootDir, { recursive: true });
|
||||
transcriptPath = path.join(tmpDir, 'session.jsonl');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function transcriptLine(record: Record<string, unknown>): string {
|
||||
return JSON.stringify({ sessionId: 's-1', ...record });
|
||||
}
|
||||
|
||||
async function writeTranscript(lines: string[]): Promise<void> {
|
||||
await fs.writeFile(transcriptPath, lines.join('\n') + '\n', 'utf8');
|
||||
}
|
||||
|
||||
/** Seed memory with one user file and one policy execution over it. */
|
||||
async function seedMemory(invocationId = 'call-42'): Promise<void> {
|
||||
const memory = new MediaMemoryService(omniRootDir);
|
||||
const source = (await memory.recordFileRecognized({
|
||||
fileRef: '/movies/film.mkv',
|
||||
sha256: 'a'.repeat(64),
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 5000 },
|
||||
sizeBytes: 100,
|
||||
mimeType: 'video/x-matroska',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: 'film.mkv' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
}))!;
|
||||
await memory.commitPolicySucceeded({
|
||||
invocationId,
|
||||
source,
|
||||
executionOrigin: { kind: 'model' },
|
||||
toolName: 'omni_extract_keyframes',
|
||||
finalArguments: { count: 8 },
|
||||
omniConfigHash: 'fp-' + '1'.repeat(61),
|
||||
startedAt: '2026-08-13T00:00:00.000Z',
|
||||
completedAt: '2026-08-13T00:00:09.000Z',
|
||||
outputs: [
|
||||
{
|
||||
kind: 'media',
|
||||
objectPath: path.join(
|
||||
omniRootDir,
|
||||
'objects',
|
||||
'sha256',
|
||||
'bb',
|
||||
`${'b'.repeat(64)}.jpg`,
|
||||
),
|
||||
sha256: 'b'.repeat(64),
|
||||
mediaType: 'image',
|
||||
metadata: { width: 1280, height: 694 },
|
||||
sizeBytes: 5,
|
||||
mimeType: 'image/jpeg',
|
||||
role: 'keyframe',
|
||||
disclosure: 'sampled 8 frames',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed a second, UNRELATED file — a session filter must not export it. */
|
||||
async function seedUnrelatedMemory(): Promise<void> {
|
||||
const memory = new MediaMemoryService(omniRootDir);
|
||||
await memory.recordFileRecognized({
|
||||
fileRef: '/other/unrelated.png',
|
||||
sha256: 'c'.repeat(64),
|
||||
mediaType: 'image',
|
||||
metadata: { width: 8, height: 8 },
|
||||
sizeBytes: 10,
|
||||
mimeType: 'image/png',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: 'unrelated.png' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('assembles turns with media annotations and joins by identity keys', async () => {
|
||||
await seedMemory();
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: '分析这部电影 @film.mkv' },
|
||||
{ text: formatResourceHandleText('film.mkv', 'media-1-ab') },
|
||||
{ text: formatDisclosureText('film.mkv', '原 1080p → 480p') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: '我先抽取关键帧。' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-42',
|
||||
name: 'omni_extract_keyframes',
|
||||
// A gate-possible call shape: the model passes a handle,
|
||||
// never inputPath+resourceId together.
|
||||
args: { resourceId: 'media-1-ab', count: 8 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
expect(turn).toBeDefined();
|
||||
// Exact equality: annotation parts must not leak into request prose.
|
||||
expect(turn!.request.text).toBe('分析这部电影 @film.mkv');
|
||||
expect(turn!.request.media).toEqual([
|
||||
{
|
||||
name: 'film.mkv',
|
||||
resourceId: 'media-1-ab',
|
||||
disclosures: ['原 1080p → 480p'],
|
||||
transcripts: [],
|
||||
},
|
||||
]);
|
||||
// The assistant's visible reply is captured alongside its calls.
|
||||
expect(turn!.response.text).toBe('我先抽取关键帧。');
|
||||
expect(turn!.response.toolCalls).toEqual([
|
||||
{
|
||||
callId: 'call-42',
|
||||
name: 'omni_extract_keyframes',
|
||||
args: { resourceId: 'media-1-ab', count: 8 },
|
||||
},
|
||||
]);
|
||||
|
||||
const execution = records.find((r) => r.kind === 'execution') as
|
||||
| OmniTrajectoryExecutionRecord
|
||||
| undefined;
|
||||
expect(execution).toBeDefined();
|
||||
// The join contract: turn.toolCalls[].callId ↔ execution.invocationId.
|
||||
expect(execution!.invocationId).toBe('call-42');
|
||||
expect(execution!.omniConfigHash).toBe('fp-' + '1'.repeat(61));
|
||||
expect(execution!.outputs).toEqual([
|
||||
{
|
||||
kind: 'derived_media',
|
||||
role: 'keyframe',
|
||||
sha256: 'b'.repeat(64),
|
||||
mimeType: 'image/jpeg',
|
||||
sizeBytes: 5,
|
||||
disclosure: 'sampled 8 frames',
|
||||
},
|
||||
]);
|
||||
|
||||
const files = records.filter(
|
||||
(r): r is OmniTrajectoryFileRecord => r.kind === 'file',
|
||||
);
|
||||
// Source + derivative, each with its durable identity.
|
||||
expect(files.map((f) => f.sha256).sort()).toEqual([
|
||||
'a'.repeat(64),
|
||||
'b'.repeat(64),
|
||||
]);
|
||||
const sourceFile = files.find((f) => f.origin === 'user')!;
|
||||
expect(sourceFile.source).toEqual({
|
||||
protocol: 'local',
|
||||
locator: 'film.mkv',
|
||||
});
|
||||
// The fixed-policy join: execution.sourceVersionId resolves to the
|
||||
// source file's version, and the derivative carries its lineage.
|
||||
expect(execution!.sourceVersionId).toBe(sourceFile.fileVersionId);
|
||||
const derived = files.find((f) => f.sha256 === 'b'.repeat(64))!;
|
||||
expect(derived.producedByExecutionId).toBe(execution!.executionId);
|
||||
expect(derived.parentVersionId).toBe(sourceFile.fileVersionId);
|
||||
});
|
||||
|
||||
it('consumes the PATH form of a model-visible local source', async () => {
|
||||
// A local file the model read is annotated with its absolute path and
|
||||
// no session handle (the exporter has no live registry). The exporter
|
||||
// must still register the media entry — keyed by the path's basename,
|
||||
// which is the file record's local source locator — or the turn drops
|
||||
// its media and the session filter never seeds the file's closure.
|
||||
await seedMemory();
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: '分析这部电影' },
|
||||
// Tagged as harness-written: that provenance is what licenses the
|
||||
// exporter to consume a path-shaped part (see the untagged case).
|
||||
harnessPathAnnotationPart('/movies/film.mkv'),
|
||||
{ text: formatDisclosureText('film.mkv', '原 1080p → 480p') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
expect(turn).toBeDefined();
|
||||
// The path-form line is consumed as an annotation, not leaked as prose.
|
||||
expect(turn!.request.text).toBe('分析这部电影');
|
||||
// Media entry keyed by basename; no resourceId (transcript-only reader).
|
||||
expect(turn!.request.media).toEqual([
|
||||
{
|
||||
name: 'film.mkv',
|
||||
disclosures: ['原 1080p → 480p'],
|
||||
transcripts: [],
|
||||
},
|
||||
]);
|
||||
|
||||
// The seeded source file survives the session filter, since the media
|
||||
// name matches its local source locator.
|
||||
const files = records.filter(
|
||||
(r): r is OmniTrajectoryFileRecord => r.kind === 'file',
|
||||
);
|
||||
const sourceFile = files.find((f) => f.origin === 'user');
|
||||
expect(sourceFile?.source).toEqual({
|
||||
protocol: 'local',
|
||||
locator: 'film.mkv',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT consume prefixed prose that is not an absolute path', async () => {
|
||||
// A user line that merely opens with the resource prefix (a paste, or an
|
||||
// @-mentioned document whose first line is 【媒体资源】清单) is ordinary
|
||||
// prose: it must stay in request.text and never fabricate a media entry
|
||||
// that could pull an unrelated file's closure into the export.
|
||||
await seedUnrelatedMemory(); // a file whose locator is 'unrelated.png'
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
parts: [{ text: '【媒体资源】清单' }, { text: '请分析' }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
expect(turn).toBeDefined();
|
||||
expect(turn!.request.media).toEqual([]);
|
||||
expect(turn!.request.text).toContain('【媒体资源】清单');
|
||||
// No phantom entry, so the unrelated file is not dragged into the export.
|
||||
const files = records.filter((r) => r.kind === 'file');
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves a trailing-whitespace filename in the PATH form', async () => {
|
||||
// A filename may legally END in whitespace (POSIX). The path form carries
|
||||
// it at the end of the annotation part; a full trim on export would
|
||||
// truncate the name, so the media entry would no longer match the file
|
||||
// record's local locator and the file (plus its closure) would drop out.
|
||||
const memory = new MediaMemoryService(omniRootDir);
|
||||
await memory.recordFileRecognized({
|
||||
fileRef: '/movies/film.mkv ',
|
||||
sha256: 'a'.repeat(64),
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 5000 },
|
||||
sizeBytes: 100,
|
||||
mimeType: 'video/x-matroska',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: 'film.mkv ' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
parts: [harnessPathAnnotationPart('/movies/film.mkv ')],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
// Trailing space preserved, so the name matches the file's local locator.
|
||||
expect(turn!.request.media).toEqual([
|
||||
{ name: 'film.mkv ', disclosures: [], transcripts: [] },
|
||||
]);
|
||||
const files = records.filter(
|
||||
(r): r is OmniTrajectoryFileRecord => r.kind === 'file',
|
||||
);
|
||||
expect(files.find((f) => f.origin === 'user')?.source).toEqual({
|
||||
protocol: 'local',
|
||||
locator: 'film.mkv ',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT consume an UNTAGGED path-shaped line (provenance gate)', async () => {
|
||||
// R3-4: a path-form annotation's TEXT is byte-identical whether the
|
||||
// harness emitted it or a user pasted / @-mentioned it, so text alone
|
||||
// cannot tell a genuine annotation from prose. The exporter consumes a
|
||||
// 【媒体路径】 part ONLY when it carries the harness provenance tag; an
|
||||
// untagged look-alike stays prose and never fabricates a media entry that
|
||||
// would drag the matching file's closure into the export.
|
||||
await seedMemory(); // a file whose local locator is 'film.mkv'
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
// Same text harnessPathAnnotationPart produces, minus the tag.
|
||||
parts: [{ text: formatResourcePathText('/movies/film.mkv') }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
expect(turn).toBeDefined();
|
||||
// Not consumed: the line survives as prose and no media entry is minted.
|
||||
expect(turn!.request.text).toContain('/movies/film.mkv');
|
||||
expect(turn!.request.media).toEqual([]);
|
||||
// No phantom entry, so the matching file is not dragged into the export.
|
||||
const files = records.filter((r) => r.kind === 'file');
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('consumes a Windows drive-letter PATH form via a cross-OS basename', async () => {
|
||||
// R3-1: the exporter may run on a different OS than the trajectory was
|
||||
// captured on, so `path.basename` (host-grammar only) is the wrong tool
|
||||
// for a Windows path parsed on POSIX — it returns the whole `C:\…`
|
||||
// string, misses the file's local locator, and drops the file's closure.
|
||||
// The basename split must recognize BOTH separators.
|
||||
const memory = new MediaMemoryService(omniRootDir);
|
||||
await memory.recordFileRecognized({
|
||||
fileRef: 'C:\\Users\\jane\\clip.mp4',
|
||||
sha256: 'd'.repeat(64),
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 5000 },
|
||||
sizeBytes: 100,
|
||||
mimeType: 'video/mp4',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: 'clip.mp4' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
timestamp: '2026-08-13T00:00:00.000Z',
|
||||
message: {
|
||||
parts: [harnessPathAnnotationPart('C:\\Users\\jane\\clip.mp4')],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records.find((r) => r.kind === 'turn') as
|
||||
| OmniTrajectoryTurnRecord
|
||||
| undefined;
|
||||
expect(turn).toBeDefined();
|
||||
// Basename split on the backslash, so the name matches the local locator.
|
||||
expect(turn!.request.media).toEqual([
|
||||
{ name: 'clip.mp4', disclosures: [], transcripts: [] },
|
||||
]);
|
||||
const files = records.filter(
|
||||
(r): r is OmniTrajectoryFileRecord => r.kind === 'file',
|
||||
);
|
||||
expect(files.find((f) => f.origin === 'user')?.source).toEqual({
|
||||
protocol: 'local',
|
||||
locator: 'clip.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves model-emitted tool arguments verbatim (no scrubbing)', async () => {
|
||||
// The gate explicitly permits a model-authored inputPath (exactly one
|
||||
// of inputPath|resourceId); the recorded assistant turn predates the
|
||||
// harness's runtime-key injection, so whatever is here IS what the
|
||||
// model emitted and must survive the export unchanged.
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'compress it' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-77',
|
||||
name: 'omni_downsample_image',
|
||||
args: { inputPath: '/data/photo.png', quality: 7 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
|
||||
expect(turn.response.toolCalls).toEqual([
|
||||
{
|
||||
callId: 'call-77',
|
||||
name: 'omni_downsample_image',
|
||||
args: { inputPath: '/data/photo.png', quality: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures omissions, multi-line transcripts, and colon-bearing names', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'continue please' },
|
||||
{ text: formatOmissionText('huge.mkv', 'video exceeds limit') },
|
||||
{
|
||||
// Multi-line transcript payload must survive intact.
|
||||
text: formatTranscriptText(
|
||||
'talk.wav',
|
||||
'[00:00-03:00] first segment\n[03:00-06:00] second segment',
|
||||
),
|
||||
},
|
||||
{
|
||||
// A display name containing the separator (escaped by the
|
||||
// builder) must round-trip without mis-splitting.
|
||||
text: formatDisclosureText('报告:最终版.mkv', '原 1080p → 480p'),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: '好的。' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
|
||||
expect(turn.request.text).toBe('continue please');
|
||||
expect(turn.request.media).toEqual([
|
||||
{
|
||||
name: 'huge.mkv',
|
||||
disclosures: [],
|
||||
omitted: 'video exceeds limit',
|
||||
transcripts: [],
|
||||
},
|
||||
{
|
||||
name: 'talk.wav',
|
||||
disclosures: [],
|
||||
transcripts: [
|
||||
'[00:00-03:00] first segment\n[03:00-06:00] second segment',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '报告:最终版.mkv',
|
||||
disclosures: ['原 1080p → 480p'],
|
||||
transcripts: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the recall payload from its system record, not from reminders', async () => {
|
||||
const recall = { status: 'partial', entries: [{ entryId: 'e-1' }] };
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{
|
||||
// A reminder QUOTING annotation grammar must be inert: the
|
||||
// wrapper is stripped before annotation matching.
|
||||
text:
|
||||
'<system-reminder>\n【媒体资源】draft.png:media-3-9f2c\n' +
|
||||
'</system-reminder>ask about the clip',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'system',
|
||||
subtype: 'omni_recall',
|
||||
systemPayload: recall,
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: 'answering from memory' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
|
||||
expect(turn.request.recall).toEqual(recall);
|
||||
expect(turn.request.text).toBe('ask about the clip');
|
||||
// The quoted handle line was NOT harvested as a media annotation.
|
||||
expect(turn.request.media).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes thought parts from the exported response text', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'q' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'SECRET-CHAIN-OF-THOUGHT', thought: true },
|
||||
{ text: 'visible answer' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
|
||||
expect(turn.response.text).toBe('visible answer');
|
||||
});
|
||||
|
||||
it('harvests annotations delivered inside tool_result records', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'grab the poster' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'tool_result',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'downloaded 1 file' },
|
||||
{ text: formatResourceHandleText('poster.png', 'media-2-cd') },
|
||||
{ text: formatDisclosureText('poster.png', '原 4096px → 768px') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: 'got it' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
|
||||
expect(turn.request.media).toEqual([
|
||||
{
|
||||
name: 'poster.png',
|
||||
resourceId: 'media-2-cd',
|
||||
disclosures: ['原 4096px → 768px'],
|
||||
transcripts: [],
|
||||
},
|
||||
]);
|
||||
// Tool output prose is not request text.
|
||||
expect(turn.request.text).toBe('grab the poster');
|
||||
});
|
||||
|
||||
it('joins code-mode tool results and harvests nested media annotations on the active branch', async () => {
|
||||
await seedMemory('exec-1:code:1');
|
||||
await seedUnrelatedMemory();
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
uuid: 'u',
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'process the clip' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
uuid: 'a',
|
||||
parentUuid: 'u',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'exec-1',
|
||||
name: 'exec',
|
||||
args: { source: 'await tools.omni_extract_keyframes({});' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
uuid: 't',
|
||||
parentUuid: 'a',
|
||||
type: 'tool_result',
|
||||
message: {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'exec-1:code:1',
|
||||
name: 'omni_extract_keyframes',
|
||||
response: { output: 'processed' },
|
||||
parts: [
|
||||
{
|
||||
text: formatResourceHandleText('preview.png', 'media-2-cd'),
|
||||
},
|
||||
{
|
||||
text: formatDisclosureText('preview.png', 'sampled frames'),
|
||||
},
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'image/png',
|
||||
fileUri: 'oss://preview',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turn = records[0] as OmniTrajectoryTurnRecord;
|
||||
expect(turn.response.toolCalls.map((call) => call.callId)).toEqual([
|
||||
'exec-1',
|
||||
]);
|
||||
expect(turn.request.media[0]).toMatchObject({
|
||||
name: 'preview.png',
|
||||
resourceId: 'media-2-cd',
|
||||
disclosures: ['sampled frames'],
|
||||
});
|
||||
expect(
|
||||
records.filter((record) => record.kind === 'execution'),
|
||||
).toHaveLength(1);
|
||||
expect(records.filter((record) => record.kind === 'file')).toHaveLength(2);
|
||||
// Rewind away from the child result: its execution cannot seed this export.
|
||||
await fs.appendFile(
|
||||
transcriptPath,
|
||||
transcriptLine({
|
||||
uuid: 'rewound',
|
||||
parentUuid: 'a',
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: 'cancelled' }] },
|
||||
}) + '\n',
|
||||
);
|
||||
const rewound = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
expect(rewound.filter((record) => record.kind === 'execution')).toEqual([]);
|
||||
expect(rewound.filter((record) => record.kind === 'file')).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits multiple user records into distinct turns with isolated state', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'first question' },
|
||||
{ text: formatResourceHandleText('a.mp4', 'media-1-aa') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: 'first answer' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'second question' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
message: { parts: [{ text: 'second answer' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turns = records.filter(
|
||||
(r): r is OmniTrajectoryTurnRecord => r.kind === 'turn',
|
||||
);
|
||||
|
||||
expect(turns.map((t) => t.turnIndex)).toEqual([0, 1]);
|
||||
expect(turns[0]!.request.media).toHaveLength(1);
|
||||
// Per-turn media/toolCalls state does not bleed into the next turn.
|
||||
expect(turns[1]!.request.media).toEqual([]);
|
||||
expect(turns[1]!.response.text).toBe('second answer');
|
||||
});
|
||||
|
||||
it('does not open a turn on a subtyped user record (cron/notification)', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
subtype: 'cron',
|
||||
message: { parts: [{ text: 'scheduled ping' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'real question' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turns = records.filter(
|
||||
(r): r is OmniTrajectoryTurnRecord => r.kind === 'turn',
|
||||
);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0]!.request.text).toBe('real question');
|
||||
});
|
||||
|
||||
it('replays only the active parentUuid chain, skipping rewound branches', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
parentUuid: null,
|
||||
message: { parts: [{ text: 'q1' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
parentUuid: 'u1',
|
||||
message: { parts: [{ text: 'answer 1' }] },
|
||||
}),
|
||||
// Abandoned by a rewind: still in the file, off the active chain.
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
uuid: 'u2-dead',
|
||||
parentUuid: 'a1',
|
||||
message: { parts: [{ text: 'q2-ABANDONED' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
uuid: 'a2-dead',
|
||||
parentUuid: 'u2-dead',
|
||||
message: { parts: [{ text: 'answer 2 abandoned' }] },
|
||||
}),
|
||||
// The rewind re-rooted here.
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
uuid: 'u2',
|
||||
parentUuid: 'a1',
|
||||
message: { parts: [{ text: 'q2-survivor' }] },
|
||||
}),
|
||||
transcriptLine({
|
||||
type: 'assistant',
|
||||
uuid: 'a2',
|
||||
parentUuid: 'u2',
|
||||
message: { parts: [{ text: 'answer 2' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
const turns = records.filter(
|
||||
(r): r is OmniTrajectoryTurnRecord => r.kind === 'turn',
|
||||
);
|
||||
|
||||
expect(turns.map((t) => t.request.text)).toEqual(['q1', 'q2-survivor']);
|
||||
expect(turns.map((t) => t.response.text)).toEqual(['answer 1', 'answer 2']);
|
||||
});
|
||||
|
||||
it('exports only memory records reachable from this session', async () => {
|
||||
await seedMemory();
|
||||
await seedUnrelatedMemory();
|
||||
// A text-only transcript: nothing in memory is reachable.
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'just a question, no media' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
|
||||
expect(records.filter((r) => r.kind !== 'turn')).toEqual([]);
|
||||
|
||||
// A session that carried the film DOES pull the whole chain — but
|
||||
// still not the unrelated file.
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'analyse' },
|
||||
{ text: formatResourceHandleText('film.mkv', 'media-1-ab') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const withMedia = await exportOmniTrajectory({
|
||||
omniRootDir,
|
||||
transcriptPath,
|
||||
});
|
||||
const files = withMedia.filter(
|
||||
(r): r is OmniTrajectoryFileRecord => r.kind === 'file',
|
||||
);
|
||||
expect(files.map((f) => f.sha256).sort()).toEqual([
|
||||
'a'.repeat(64),
|
||||
'b'.repeat(64),
|
||||
]);
|
||||
expect(withMedia.filter((r) => r.kind === 'execution')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('degrades to transcript-only WITHOUT touching a corrupt ledger', async () => {
|
||||
const ledgerPath = path.join(omniRootDir, 'memory.json');
|
||||
await fs.writeFile(ledgerPath, '{broken');
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'hello' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const records = await exportOmniTrajectory({ omniRootDir, transcriptPath });
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]!.kind).toBe('turn');
|
||||
// Pure-reader contract: the live document is untouched — no
|
||||
// corruption self-heal rename, no `.corrupt-*` backup that would
|
||||
// silently block the GC for a whole retention window.
|
||||
await expect(fs.readFile(ledgerPath, 'utf8')).resolves.toBe('{broken');
|
||||
const siblings = await fs.readdir(omniRootDir);
|
||||
expect(siblings.filter((n) => n.includes('.corrupt-'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an unreadable transcript instead of degrading', async () => {
|
||||
// The documented asymmetry: memory degrades, transcript REJECTS —
|
||||
// there is no trajectory without it.
|
||||
await expect(
|
||||
exportOmniTrajectory({
|
||||
omniRootDir,
|
||||
transcriptPath: path.join(tmpDir, 'missing.jsonl'),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('re-exports byte-identically and writes parseable JSONL', async () => {
|
||||
await seedMemory();
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'q' },
|
||||
{ text: formatResourceHandleText('film.mkv', 'media-1-ab') },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const a = serializeOmniTrajectory(
|
||||
await exportOmniTrajectory({ omniRootDir, transcriptPath }),
|
||||
);
|
||||
const b = serializeOmniTrajectory(
|
||||
await exportOmniTrajectory({ omniRootDir, transcriptPath }),
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
|
||||
const outPath = path.join(tmpDir, 'nested', 'out.jsonl');
|
||||
const { records } = await writeOmniTrajectoryJsonl({
|
||||
omniRootDir,
|
||||
transcriptPath,
|
||||
outPath,
|
||||
});
|
||||
const lines = (await fs.readFile(outPath, 'utf8'))
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
expect(lines).toHaveLength(records);
|
||||
// Every line parses on its own — the whole point of JSONL.
|
||||
for (const line of lines) expect(() => JSON.parse(line)).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses to write the trajectory over the source transcript', async () => {
|
||||
await writeTranscript([
|
||||
transcriptLine({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'q' }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
writeOmniTrajectoryJsonl({
|
||||
omniRootDir,
|
||||
transcriptPath,
|
||||
outPath: transcriptPath,
|
||||
}),
|
||||
).rejects.toThrow(/must differ/);
|
||||
// The transcript is intact.
|
||||
await expect(fs.readFile(transcriptPath, 'utf8')).resolves.toContain(
|
||||
'"type":"user"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,833 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type { MediaMemorySnapshot } from '../services/media-memory/types.js';
|
||||
import {
|
||||
OMNI_DISCLOSURE_TEXT_PREFIX,
|
||||
OMNI_OMISSION_TEXT_PREFIX,
|
||||
OMNI_TRANSCRIPT_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_HANDLE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_PATH_TEXT_PREFIX,
|
||||
isHarnessAnnotationPart,
|
||||
parseResourceHandleText,
|
||||
parseResourcePathText,
|
||||
splitAnnotationBody,
|
||||
unescapeAnnotationName,
|
||||
} from './disclosure.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:export');
|
||||
|
||||
/**
|
||||
* Trajectory export (S6, issue #8190 / formerly #8196): one experiment
|
||||
* session as training-consumable JSONL.
|
||||
*
|
||||
* The exporter is a pure READER over two artifacts that already exist —
|
||||
* the session's chat-record JSONL and the project's `memory.json` — so it
|
||||
* adds no collection points and cannot affect runtime behavior. The
|
||||
* memory ledger is read RAW (no store-side corruption self-heal: an
|
||||
* export must never rename the live document); an unreadable ledger
|
||||
* degrades the export to transcript-only records.
|
||||
*
|
||||
* ## Record kinds (the `kind` field of every line)
|
||||
*
|
||||
* - `turn` — one user request → model response cycle from the transcript:
|
||||
* the request text (system reminders stripped), the media annotations
|
||||
* the model saw (handles, disclosures, omissions, transcripts —
|
||||
* harvested from user AND tool_result records), the recall payload the
|
||||
* sideQuery injected (recorded by the runtime since this exporter
|
||||
* shipped; older transcripts simply have no `recall`), the assistant's
|
||||
* VISIBLE text (thought parts are excluded) and its tool calls with
|
||||
* arguments preserved verbatim.
|
||||
* - `execution` — one policy execution from memory, with full provenance
|
||||
* (arguments, config hash, source/output identities, reuse marker).
|
||||
* - `file` — one file version from memory: the durable identity
|
||||
* (`fileVersionId`, `sha256`), modality, origin, and a source locator
|
||||
* REDUCED to its safe form (URLs and managed keys verbatim; local
|
||||
* locators are already basenames by construction — M §5.2's no-path
|
||||
* discipline is enforced at collection time, not here).
|
||||
*
|
||||
* ## Session scope
|
||||
*
|
||||
* `memory.json` is project-wide; a single-session export includes only
|
||||
* the records reachable from THIS session's transcript: executions whose
|
||||
* `invocationId` matches a turn's tool callId, files whose display
|
||||
* locator matches a media annotation, and everything reachable from
|
||||
* those through the identity graph (source versions, produced
|
||||
* derivatives, and the executions between them) — computed as a closure,
|
||||
* so fixed-policy chains (extract-audio → transcribe) stay complete.
|
||||
*
|
||||
* ## Branches
|
||||
*
|
||||
* Chat records form a `parentUuid` chain and `/rewind` re-roots it,
|
||||
* leaving abandoned records in the append-only file. The exporter
|
||||
* replays only the ACTIVE chain (walked back from the final record) —
|
||||
* a rewound branch must not export as a genuine turn. Records without
|
||||
* uuids (hand-built fixtures, foreign transcripts) fall back to append
|
||||
* order.
|
||||
*
|
||||
* ## The join contract (deliberately explicit, not implicit)
|
||||
*
|
||||
* Model/client-origin executions join `turn.response.toolCalls[].callId`
|
||||
* ↔ `execution.invocationId` — both sides record the scheduler's callId.
|
||||
* Fixed-policy executions have no transcript-side call (they run inside
|
||||
* delivery); they join through identities instead:
|
||||
* `execution.sourceVersionId` → `file.fileVersionId`, and a turn's media
|
||||
* name matches `file.sourceLocator`'s display form. The exporter does NOT
|
||||
* fabricate a turn↔fixed-policy edge from timestamps — a wrong edge in
|
||||
* training data is worse than an explicit join key.
|
||||
*
|
||||
* Media bytes are never embedded; `sha256` is the pointer (fetch from
|
||||
* `objects/` while retention lasts). Every `execution` line carries
|
||||
* `omniConfigHash` so downstream can split trajectories by processing
|
||||
* configuration instead of mixing distributions.
|
||||
*
|
||||
* ## What is and is not scrubbed
|
||||
*
|
||||
* Transcript-side content is preserved VERBATIM — the recorded assistant
|
||||
* turn predates the gate's runtime-key injection, so its tool-call
|
||||
* arguments are exactly what the model emitted (including an `inputPath`
|
||||
* the model legitimately passed to an omni tool); scrubbing them would
|
||||
* corrupt the training signal. The memory side excludes the reserved
|
||||
* runtime keys (`inputPath`/`outputDir`/`resourceId`) from
|
||||
* `finalArguments` at collection time — before this exporter ever runs.
|
||||
* Callers publishing exports off the machine own user/model-authored
|
||||
* absolute paths (they ARE the trajectory).
|
||||
*
|
||||
* ## Known limitation
|
||||
*
|
||||
* Non-text request parts (pasted images, inline blobs) contribute no
|
||||
* annotation and are not represented in `turn.request` beyond any omni
|
||||
* annotations delivered beside them.
|
||||
*/
|
||||
|
||||
// ─── Line shapes ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface OmniTrajectoryMediaAnnotation {
|
||||
/** Display name from the annotation (basename or URL base). */
|
||||
name: string;
|
||||
/** Session handle — set only when a 【媒体资源】 annotation showed the HANDLE
|
||||
* form (path-less media). The path form (【媒体路径】, model-visible local
|
||||
* media) leaves this unset: it shows the absolute path, and the exporter is
|
||||
* a pure reader with no live registry, so the entry is keyed by the path
|
||||
* basename instead. A present annotation therefore does NOT imply a
|
||||
* resourceId. */
|
||||
resourceId?: string;
|
||||
disclosures: string[];
|
||||
/** Omission notice text, when the transport guard withheld the bytes. */
|
||||
omitted?: string;
|
||||
/** Transcript-protocol text delivered in place of (or beside) media. */
|
||||
transcripts: string[];
|
||||
}
|
||||
|
||||
export interface OmniTrajectoryTurnRecord {
|
||||
kind: 'turn';
|
||||
sessionId: string;
|
||||
/** 0-based index over the session's user requests. */
|
||||
turnIndex: number;
|
||||
timestamp?: string;
|
||||
request: {
|
||||
text: string;
|
||||
media: OmniTrajectoryMediaAnnotation[];
|
||||
/** The sideQuery recall payload the runtime injected (recorded as a
|
||||
* system record at injection time), verbatim JSON when present. */
|
||||
recall?: unknown;
|
||||
};
|
||||
response: {
|
||||
text: string;
|
||||
toolCalls: Array<{
|
||||
callId?: string;
|
||||
name: string;
|
||||
/** Arguments exactly as the model emitted them. */
|
||||
args?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OmniTrajectoryExecutionRecord {
|
||||
kind: 'execution';
|
||||
executionId: string;
|
||||
/** Scheduler callId for model/client calls (the turn-side join key);
|
||||
* a random invocation id for fixed-policy runs. */
|
||||
invocationId: string;
|
||||
executionOrigin: unknown;
|
||||
toolName: string;
|
||||
toolVersion?: string;
|
||||
finalArguments: Record<string, unknown>;
|
||||
omniConfigHash: string;
|
||||
sourceVersionId: string;
|
||||
rootFileId: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
/** Present when this execution reused another's outputs (M §11.3). */
|
||||
reusedExecutionId?: string;
|
||||
outputs: Array<{
|
||||
kind: string;
|
||||
role?: string;
|
||||
sha256?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
disclosure?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OmniTrajectoryFileRecord {
|
||||
kind: 'file';
|
||||
fileId: string;
|
||||
fileVersionId: string;
|
||||
rootFileId: string;
|
||||
sha256: string;
|
||||
mediaType: string;
|
||||
origin: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string;
|
||||
source: { protocol: string; locator: string };
|
||||
producedByExecutionId?: string;
|
||||
parentVersionId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type OmniTrajectoryRecord =
|
||||
| OmniTrajectoryTurnRecord
|
||||
| OmniTrajectoryExecutionRecord
|
||||
| OmniTrajectoryFileRecord;
|
||||
|
||||
// ─── Transcript side ──────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal structural view of one chat record (the exporter must not
|
||||
* import the CLI's ChatRecord type — core cannot depend on cli). */
|
||||
interface TranscriptRecordView {
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
timestamp?: string;
|
||||
uuid?: string;
|
||||
parentUuid?: string | null;
|
||||
message?: { parts?: unknown[] };
|
||||
systemPayload?: unknown;
|
||||
}
|
||||
|
||||
function textOfPart(part: unknown): string | undefined {
|
||||
if (typeof part === 'string') return part;
|
||||
if (typeof part === 'object' && part !== null && 'text' in part) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === 'string' ? text : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function functionResponseOfPart(
|
||||
part: unknown,
|
||||
): { id?: unknown; parts?: unknown[] } | undefined {
|
||||
if (typeof part !== 'object' || part === null) return undefined;
|
||||
const response = (part as { functionResponse?: unknown }).functionResponse;
|
||||
if (typeof response !== 'object' || response === null) return undefined;
|
||||
const { id, parts } = response as { id?: unknown; parts?: unknown };
|
||||
return { id, ...(Array.isArray(parts) ? { parts } : {}) };
|
||||
}
|
||||
|
||||
function isThoughtPart(part: unknown): boolean {
|
||||
return (
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
(part as { thought?: unknown }).thought === true
|
||||
);
|
||||
}
|
||||
|
||||
function functionCallOfPart(
|
||||
part: unknown,
|
||||
):
|
||||
| { callId?: string; name: string; args?: Record<string, unknown> }
|
||||
| undefined {
|
||||
if (typeof part !== 'object' || part === null) return undefined;
|
||||
const fc = (part as { functionCall?: unknown }).functionCall;
|
||||
if (typeof fc !== 'object' || fc === null) return undefined;
|
||||
const { id, name, args } = fc as {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
args?: unknown;
|
||||
};
|
||||
if (typeof name !== 'string') return undefined;
|
||||
// Arguments are preserved verbatim: the assistant turn is recorded
|
||||
// BEFORE the gate injects runtime keys, so whatever is here is what
|
||||
// the model itself emitted — scrubbing it would delete a legitimate
|
||||
// model-authored `inputPath` and corrupt the call record.
|
||||
const cleanArgs =
|
||||
typeof args === 'object' && args !== null
|
||||
? { ...(args as Record<string, unknown>) }
|
||||
: undefined;
|
||||
return {
|
||||
...(typeof id === 'string' ? { callId: id } : {}),
|
||||
name,
|
||||
...(cleanArgs ? { args: cleanArgs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove every `<system-reminder>…</system-reminder>` block. Does NOT trim:
|
||||
* callers decide, because a path-form annotation carries a whitespace-sensitive
|
||||
* filename at the END and a full trim would truncate it (see
|
||||
* `consumeRecordParts`). */
|
||||
function stripSystemReminders(text: string): string {
|
||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
||||
}
|
||||
|
||||
interface TurnAccumulator {
|
||||
turnIndex: number;
|
||||
timestamp?: string;
|
||||
requestTexts: string[];
|
||||
media: Map<string, OmniTrajectoryMediaAnnotation>;
|
||||
recall?: unknown;
|
||||
responseTexts: string[];
|
||||
toolCalls: OmniTrajectoryTurnRecord['response']['toolCalls'];
|
||||
}
|
||||
|
||||
function mediaFor(
|
||||
turn: TurnAccumulator,
|
||||
name: string,
|
||||
): OmniTrajectoryMediaAnnotation {
|
||||
let media = turn.media.get(name);
|
||||
if (!media) {
|
||||
media = { name, disclosures: [], transcripts: [] };
|
||||
turn.media.set(name, media);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one text PART as a media annotation if it is one. Writers emit
|
||||
* each annotation as a standalone part (`formatDisclosureText` and
|
||||
* friends produce the whole part text), so matching at part granularity
|
||||
* keeps multi-line payloads intact and never harvests look-alike lines
|
||||
* out of surrounding prose. Returns true when the part was an
|
||||
* annotation.
|
||||
*/
|
||||
function consumeAnnotationPart(
|
||||
turn: TurnAccumulator,
|
||||
text: string,
|
||||
isHarnessAnnotation: boolean,
|
||||
): boolean {
|
||||
if (text.startsWith(OMNI_RESOURCE_HANDLE_TEXT_PREFIX)) {
|
||||
const resourceId = parseResourceHandleText(text);
|
||||
if (resourceId) {
|
||||
// Handle form. Name = body minus the end-anchored `:<resourceId>`
|
||||
// (the id grammar is harness-minted, so this parse never guesses at
|
||||
// the name).
|
||||
const body = text.slice(OMNI_RESOURCE_HANDLE_TEXT_PREFIX.length);
|
||||
const name = unescapeAnnotationName(
|
||||
body.slice(0, body.length - resourceId.length - 1),
|
||||
);
|
||||
mediaFor(turn, name).resourceId = resourceId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith(OMNI_RESOURCE_PATH_TEXT_PREFIX)) {
|
||||
// Path form (model-visible local media): the annotation shows the file's
|
||||
// ABSOLUTE PATH and carries no session handle. The exporter is a pure
|
||||
// reader with no live registry (bindings never persist), so it cannot
|
||||
// recover a resourceId — but the media entry must still exist, or the
|
||||
// turn drops its media and the session filter never seeds the file's
|
||||
// memory closure.
|
||||
//
|
||||
// Consume it ONLY when the Part is provenance-tagged as harness-written.
|
||||
// A user paste, or an @-mentioned TEXT file whose content opens with this
|
||||
// marker, is byte-identical prose; consuming it would delete the user's
|
||||
// line from request.text and fabricate a phantom media entry that drags
|
||||
// an unrelated file's closure into the export.
|
||||
if (!isHarnessAnnotation) return false;
|
||||
const filePath = parseResourcePathText(text);
|
||||
if (filePath) {
|
||||
// Key the entry by the path's BASENAME, derived OS-shape-independently:
|
||||
// the grammar is deliberately cross-OS (a trajectory captured on one
|
||||
// host is parsed on another), so host `path.basename` would leave a
|
||||
// Windows drive/UNC path whole and the file-record join — on the local
|
||||
// source locator, `displayName` defaulting to the basename (index.ts) —
|
||||
// would never fire. This split preserves a trailing-whitespace filename
|
||||
// exactly as the recorded locator does.
|
||||
mediaFor(turn, filePath.split(/[\\/]/).pop() ?? filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const [prefix, apply] of [
|
||||
[
|
||||
OMNI_DISCLOSURE_TEXT_PREFIX,
|
||||
(m: OmniTrajectoryMediaAnnotation, payload: string) => {
|
||||
m.disclosures.push(payload);
|
||||
},
|
||||
],
|
||||
[
|
||||
OMNI_OMISSION_TEXT_PREFIX,
|
||||
(m: OmniTrajectoryMediaAnnotation, payload: string) => {
|
||||
m.omitted = payload;
|
||||
},
|
||||
],
|
||||
[
|
||||
OMNI_TRANSCRIPT_TEXT_PREFIX,
|
||||
(m: OmniTrajectoryMediaAnnotation, payload: string) => {
|
||||
m.transcripts.push(payload);
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
if (!text.startsWith(prefix)) continue;
|
||||
const split = splitAnnotationBody(text.slice(prefix.length));
|
||||
if (!split) return false;
|
||||
apply(mediaFor(turn, split.name), split.payload);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Harvest media annotations (and request prose, when `collectProse`)
|
||||
* from a record's text parts into the accumulating turn. */
|
||||
function consumeRecordParts(
|
||||
turn: TurnAccumulator,
|
||||
record: TranscriptRecordView,
|
||||
collectProse: boolean,
|
||||
): void {
|
||||
const parts = (record.message?.parts ?? []).flatMap((part) => [
|
||||
part,
|
||||
...(functionResponseOfPart(part)?.parts ?? []),
|
||||
]);
|
||||
for (const part of parts) {
|
||||
const raw = textOfPart(part);
|
||||
if (raw === undefined) continue;
|
||||
// Reminders are harness plumbing, never annotation carriers — strip
|
||||
// them BEFORE annotation matching so a reminder quoting an
|
||||
// annotation line cannot be harvested as one.
|
||||
const withoutReminders = stripSystemReminders(raw);
|
||||
// Annotation matching left-trims only: the path form carries the file's
|
||||
// ABSOLUTE PATH at the end of the part, and a filename may legally end in
|
||||
// whitespace (POSIX), so a full trim would truncate the name — dropping the
|
||||
// media entry and, with it, the file's memory closure from the export.
|
||||
const forAnnotation = withoutReminders.replace(/^\s+/, '');
|
||||
if (
|
||||
consumeAnnotationPart(turn, forAnnotation, isHarnessAnnotationPart(part))
|
||||
)
|
||||
continue;
|
||||
// Prose: safe to fully trim.
|
||||
const prose = withoutReminders.trim();
|
||||
if (!prose) continue;
|
||||
if (collectProse) turn.requestTexts.push(prose);
|
||||
}
|
||||
}
|
||||
|
||||
function finishTurn(
|
||||
turn: TurnAccumulator,
|
||||
sessionId: string,
|
||||
): OmniTrajectoryTurnRecord {
|
||||
return {
|
||||
kind: 'turn',
|
||||
sessionId,
|
||||
turnIndex: turn.turnIndex,
|
||||
...(turn.timestamp !== undefined ? { timestamp: turn.timestamp } : {}),
|
||||
request: {
|
||||
text: turn.requestTexts.join('\n'),
|
||||
media: [...turn.media.values()],
|
||||
...(turn.recall !== undefined ? { recall: turn.recall } : {}),
|
||||
},
|
||||
response: {
|
||||
text: turn.responseTexts.join('\n'),
|
||||
toolCalls: turn.toolCalls,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the append-only record list to the ACTIVE parentUuid chain.
|
||||
* `/rewind` re-roots the chain and leaves the abandoned records in the
|
||||
* file — replaying them would export phantom turns. Records without
|
||||
* uuids (fixtures, foreign transcripts) fall back to the full list.
|
||||
*/
|
||||
function activeChainRecords(
|
||||
records: TranscriptRecordView[],
|
||||
): TranscriptRecordView[] {
|
||||
const byUuid = new Map<string, TranscriptRecordView>();
|
||||
for (const record of records) {
|
||||
if (typeof record.uuid === 'string') byUuid.set(record.uuid, record);
|
||||
}
|
||||
if (byUuid.size === 0) return records;
|
||||
let leaf: TranscriptRecordView | undefined;
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
if (typeof records[i].uuid === 'string') {
|
||||
leaf = records[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!leaf) return records;
|
||||
const active = new Set<string>();
|
||||
let current: TranscriptRecordView | undefined = leaf;
|
||||
while (current?.uuid && !active.has(current.uuid)) {
|
||||
active.add(current.uuid);
|
||||
current =
|
||||
typeof current.parentUuid === 'string'
|
||||
? byUuid.get(current.parentUuid)
|
||||
: undefined;
|
||||
}
|
||||
// Preserve file order; keep uuid-less records (defensive: they cannot
|
||||
// be on a dead branch that rewind knows about).
|
||||
return records.filter(
|
||||
(r) => typeof r.uuid !== 'string' || active.has(r.uuid),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTurnRecords(
|
||||
transcriptLines: TranscriptRecordView[],
|
||||
): OmniTrajectoryTurnRecord[] {
|
||||
const turns: OmniTrajectoryTurnRecord[] = [];
|
||||
let sessionId = '';
|
||||
let current: TurnAccumulator | undefined;
|
||||
|
||||
for (const record of activeChainRecords(transcriptLines)) {
|
||||
if (typeof record.sessionId === 'string' && sessionId === '') {
|
||||
sessionId = record.sessionId;
|
||||
}
|
||||
if (record.type === 'user' && record.subtype === undefined) {
|
||||
if (current) turns.push(finishTurn(current, sessionId));
|
||||
current = {
|
||||
turnIndex: turns.length,
|
||||
...(record.timestamp !== undefined
|
||||
? { timestamp: record.timestamp }
|
||||
: {}),
|
||||
requestTexts: [],
|
||||
media: new Map(),
|
||||
responseTexts: [],
|
||||
toolCalls: [],
|
||||
};
|
||||
consumeRecordParts(current, record, true);
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
if (record.type === 'system' && record.subtype === 'omni_recall') {
|
||||
current.recall = record.systemPayload;
|
||||
continue;
|
||||
}
|
||||
if (record.type === 'tool_result') {
|
||||
// Tool-delivered media carries its annotations in the tool_result
|
||||
// record's parts — harvest them; tool output prose is NOT request
|
||||
// text.
|
||||
consumeRecordParts(current, record, false);
|
||||
continue;
|
||||
}
|
||||
if (record.type === 'assistant') {
|
||||
for (const part of record.message?.parts ?? []) {
|
||||
// Thought parts are internal chain-of-thought the user never saw
|
||||
// — exporting them as "the response" would mislabel the training
|
||||
// signal.
|
||||
if (isThoughtPart(part)) continue;
|
||||
const text = textOfPart(part);
|
||||
if (text !== undefined && text.trim()) {
|
||||
current.responseTexts.push(text);
|
||||
continue;
|
||||
}
|
||||
const call = functionCallOfPart(part);
|
||||
if (call) current.toolCalls.push(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current) turns.push(finishTurn(current, sessionId));
|
||||
return turns;
|
||||
}
|
||||
|
||||
// ─── Memory side ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MemoryRecords {
|
||||
files: OmniTrajectoryFileRecord[];
|
||||
executions: OmniTrajectoryExecutionRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the memory ledger WITHOUT the store's corruption self-heal. The
|
||||
* store backs a corrupt document up (renaming the live file) and
|
||||
* continues on empty — correct for recall, but an EXPORT must never
|
||||
* mutate the thing it reads (and the backup would silently block the GC
|
||||
* for a whole retention window). Any failure degrades to transcript-only
|
||||
* output.
|
||||
*/
|
||||
async function readSnapshotRaw(
|
||||
omniRootDir: string,
|
||||
): Promise<MediaMemorySnapshot | undefined> {
|
||||
const filePath = path.join(omniRootDir, 'memory.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return undefined; // Missing ledger: a media-less project is normal.
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MediaMemorySnapshot;
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
[parsed.files, parsed.versions, parsed.executions, parsed.entries].some(
|
||||
(c) => typeof c !== 'object' || c === null || Array.isArray(c),
|
||||
)
|
||||
) {
|
||||
throw new Error('unexpected snapshot shape');
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
debugLogger.debug(
|
||||
`trajectory export: memory unreadable, exporting transcript only: ` +
|
||||
`${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMemoryRecords(snapshot: MediaMemorySnapshot): MemoryRecords {
|
||||
const files: OmniTrajectoryFileRecord[] = [];
|
||||
const executions: OmniTrajectoryExecutionRecord[] = [];
|
||||
for (const version of Object.values(snapshot.versions)) {
|
||||
try {
|
||||
const file = snapshot.files[version.fileId];
|
||||
if (!file) continue;
|
||||
files.push({
|
||||
kind: 'file',
|
||||
fileId: version.fileId,
|
||||
fileVersionId: version.fileVersionId,
|
||||
rootFileId: file.rootFileId,
|
||||
sha256: version.sha256,
|
||||
mediaType: version.mediaType,
|
||||
origin: file.origin,
|
||||
sizeBytes: version.sizeBytes,
|
||||
mimeType: version.mimeType,
|
||||
source: version.source,
|
||||
...(version.producedByExecutionId !== undefined
|
||||
? { producedByExecutionId: version.producedByExecutionId }
|
||||
: {}),
|
||||
...(version.parentVersionId !== undefined
|
||||
? { parentVersionId: version.parentVersionId }
|
||||
: {}),
|
||||
createdAt: version.createdAt,
|
||||
});
|
||||
} catch {
|
||||
// One malformed record loses that record, not the export.
|
||||
}
|
||||
}
|
||||
for (const execution of Object.values(snapshot.executions)) {
|
||||
try {
|
||||
const outputs = execution.outputRefs.flatMap((entryId) => {
|
||||
const entry = snapshot.entries[entryId];
|
||||
if (!entry) return [];
|
||||
return [
|
||||
{
|
||||
kind: entry.kind,
|
||||
...(entry.role !== undefined ? { role: entry.role } : {}),
|
||||
...(entry.artifactRef?.managedId?.startsWith('sha256/')
|
||||
? { sha256: entry.artifactRef.managedId.slice('sha256/'.length) }
|
||||
: {}),
|
||||
...(entry.artifactRef?.mimeType !== undefined
|
||||
? { mimeType: entry.artifactRef.mimeType }
|
||||
: {}),
|
||||
...(entry.artifactRef?.sizeBytes !== undefined
|
||||
? { sizeBytes: entry.artifactRef.sizeBytes }
|
||||
: {}),
|
||||
...(entry.disclosure !== undefined
|
||||
? { disclosure: entry.disclosure }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
executions.push({
|
||||
kind: 'execution',
|
||||
executionId: execution.executionId,
|
||||
invocationId: execution.invocationId,
|
||||
executionOrigin: execution.executionOrigin,
|
||||
toolName: execution.toolName,
|
||||
...(execution.toolVersion !== undefined
|
||||
? { toolVersion: execution.toolVersion }
|
||||
: {}),
|
||||
finalArguments: execution.finalArguments,
|
||||
omniConfigHash: execution.omniConfigHash,
|
||||
sourceVersionId: execution.sourceVersionId,
|
||||
rootFileId: execution.rootFileId,
|
||||
startedAt: execution.startedAt,
|
||||
completedAt: execution.completedAt,
|
||||
...(execution.reusedExecutionId !== undefined
|
||||
? { reusedExecutionId: execution.reusedExecutionId }
|
||||
: {}),
|
||||
outputs,
|
||||
});
|
||||
} catch {
|
||||
// Same stance: skip the malformed execution.
|
||||
}
|
||||
}
|
||||
return { files, executions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce project-wide memory records to the ones reachable from this
|
||||
* session's transcript. Seeds: executions joined by callId, files whose
|
||||
* display locator matches a media annotation name. Closure: an included
|
||||
* execution pulls in its source version's file and every file it
|
||||
* produced; an included file pulls in the executions that read it and
|
||||
* the executions that produced its versions. Fixed-policy chains
|
||||
* (extract-audio → transcribe) stay complete without timestamp guessing.
|
||||
*/
|
||||
function filterToSession(
|
||||
memory: MemoryRecords,
|
||||
turns: OmniTrajectoryTurnRecord[],
|
||||
transcriptLines: TranscriptRecordView[],
|
||||
): MemoryRecords {
|
||||
const callIds = new Set<string>();
|
||||
// Code-mode child calls are recorded as tool results under the outer exec
|
||||
// call. Join their execution IDs without inventing model-authored arguments.
|
||||
for (const record of activeChainRecords(transcriptLines)) {
|
||||
if (record.type !== 'tool_result') continue;
|
||||
for (const part of record.message?.parts ?? []) {
|
||||
const id = functionResponseOfPart(part)?.id;
|
||||
if (typeof id === 'string') callIds.add(id);
|
||||
}
|
||||
}
|
||||
const mediaNames = new Set<string>();
|
||||
for (const turn of turns) {
|
||||
for (const call of turn.response.toolCalls) {
|
||||
if (call.callId) callIds.add(call.callId);
|
||||
}
|
||||
for (const media of turn.request.media) mediaNames.add(media.name);
|
||||
}
|
||||
|
||||
const filesByVersion = new Map(memory.files.map((f) => [f.fileVersionId, f]));
|
||||
const versionsByFile = new Map<string, OmniTrajectoryFileRecord[]>();
|
||||
for (const f of memory.files) {
|
||||
const list = versionsByFile.get(f.fileId) ?? [];
|
||||
list.push(f);
|
||||
versionsByFile.set(f.fileId, list);
|
||||
}
|
||||
|
||||
const includedFiles = new Set<string>(); // fileId
|
||||
const includedExecutions = new Set<string>(); // executionId
|
||||
|
||||
const includeFile = (fileId: string | undefined): boolean => {
|
||||
if (!fileId || includedFiles.has(fileId)) return false;
|
||||
if (!versionsByFile.has(fileId)) return false;
|
||||
includedFiles.add(fileId);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Seeds.
|
||||
for (const f of memory.files) {
|
||||
if (mediaNames.has(f.source.locator)) includeFile(f.fileId);
|
||||
}
|
||||
for (const e of memory.executions) {
|
||||
if (callIds.has(e.invocationId)) includedExecutions.add(e.executionId);
|
||||
}
|
||||
|
||||
// Closure to a fixed point.
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const e of memory.executions) {
|
||||
const sourceFile = filesByVersion.get(e.sourceVersionId)?.fileId;
|
||||
if (!includedExecutions.has(e.executionId)) {
|
||||
// An execution joins when it read an included file.
|
||||
if (sourceFile && includedFiles.has(sourceFile)) {
|
||||
includedExecutions.add(e.executionId);
|
||||
changed = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// An included execution pulls in its source file…
|
||||
if (includeFile(sourceFile)) changed = true;
|
||||
}
|
||||
for (const f of memory.files) {
|
||||
if (includedFiles.has(f.fileId)) continue;
|
||||
// …and every file one of its versions produced.
|
||||
if (
|
||||
f.producedByExecutionId !== undefined &&
|
||||
includedExecutions.has(f.producedByExecutionId)
|
||||
) {
|
||||
includeFile(f.fileId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files: memory.files.filter((f) => includedFiles.has(f.fileId)),
|
||||
executions: memory.executions.filter((e) =>
|
||||
includedExecutions.has(e.executionId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Entry points ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExportOmniTrajectoryOptions {
|
||||
/** `.qwen/omni` root holding memory.json. */
|
||||
omniRootDir: string;
|
||||
/** Path to the session's chat-record JSONL. */
|
||||
transcriptPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trajectory records for one session. Turn records come first
|
||||
* (transcript order), then file records, then execution records — a
|
||||
* deterministic order so re-exports are byte-identical.
|
||||
*
|
||||
* An unreadable memory store degrades to transcript-only output with a
|
||||
* debug note (the transcript half is still valid training signal); an
|
||||
* unreadable transcript is an error — there is no trajectory without it.
|
||||
*/
|
||||
export async function exportOmniTrajectory(
|
||||
options: ExportOmniTrajectoryOptions,
|
||||
): Promise<OmniTrajectoryRecord[]> {
|
||||
const raw = await fs.readFile(options.transcriptPath, 'utf8');
|
||||
const transcriptLines: TranscriptRecordView[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
transcriptLines.push(JSON.parse(line) as TranscriptRecordView);
|
||||
} catch {
|
||||
// One corrupt transcript line loses that line, not the export.
|
||||
}
|
||||
}
|
||||
const turns = buildTurnRecords(transcriptLines);
|
||||
const records: OmniTrajectoryRecord[] = [...turns];
|
||||
|
||||
const snapshot = await readSnapshotRaw(options.omniRootDir);
|
||||
const memoryRecords = snapshot
|
||||
? filterToSession(buildMemoryRecords(snapshot), turns, transcriptLines)
|
||||
: { files: [], executions: [] };
|
||||
// files before executions, each sorted by id — deterministic re-export.
|
||||
records.push(
|
||||
...memoryRecords.files.sort((a, b) =>
|
||||
a.fileVersionId.localeCompare(b.fileVersionId),
|
||||
),
|
||||
...memoryRecords.executions.sort((a, b) =>
|
||||
a.executionId.localeCompare(b.executionId),
|
||||
),
|
||||
);
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Serialize to JSONL (one record per line, trailing newline). */
|
||||
export function serializeOmniTrajectory(
|
||||
records: OmniTrajectoryRecord[],
|
||||
): string {
|
||||
return records.map((r) => JSON.stringify(r)).join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Convenience wrapper: export and write to a file. */
|
||||
export async function writeOmniTrajectoryJsonl(
|
||||
options: ExportOmniTrajectoryOptions & { outPath: string },
|
||||
): Promise<{ records: number }> {
|
||||
const outPath = path.resolve(options.outPath);
|
||||
if (outPath === path.resolve(options.transcriptPath)) {
|
||||
// Writing the trajectory over the raw transcript would irreversibly
|
||||
// destroy the non-reconstructible source this pipeline exists to
|
||||
// capture.
|
||||
throw new Error('trajectory outPath must differ from the transcript path');
|
||||
}
|
||||
const records = await exportOmniTrajectory(options);
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, serializeOmniTrajectory(records), 'utf8');
|
||||
return { records: records.length };
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
assertOmniRuntimeDependencies,
|
||||
isFfmpegAvailable,
|
||||
isFfprobeAvailable,
|
||||
probeMediaMetadata,
|
||||
resetFfmpegCachesForTests,
|
||||
runFfmpeg,
|
||||
} from './ffmpeg.js';
|
||||
|
||||
type ExecCallback = (
|
||||
error: Error | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
|
||||
function mockExecResult(
|
||||
handler: (
|
||||
command: string,
|
||||
args: string[],
|
||||
) => {
|
||||
error?: Error & { code?: number | string };
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
},
|
||||
): void {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
args: string[],
|
||||
_options: unknown,
|
||||
callback: ExecCallback,
|
||||
) => {
|
||||
const result = handler(command, args);
|
||||
callback(result.error ?? null, result.stdout ?? '', result.stderr ?? '');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetFfmpegCachesForTests();
|
||||
execFileMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFfmpegCachesForTests();
|
||||
});
|
||||
|
||||
describe('availability checks', () => {
|
||||
it('returns true when the binary exits 0 and caches the result', async () => {
|
||||
mockExecResult(() => ({ stdout: 'ffmpeg version 7' }));
|
||||
await expect(isFfmpegAvailable()).resolves.toBe(true);
|
||||
await expect(isFfmpegAvailable()).resolves.toBe(true);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when the binary is missing', async () => {
|
||||
mockExecResult(() => ({
|
||||
error: Object.assign(new Error('spawn ffprobe ENOENT'), {
|
||||
code: 'ENOENT',
|
||||
}),
|
||||
}));
|
||||
await expect(isFfprobeAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('shares one probe among concurrent callers', async () => {
|
||||
mockExecResult(() => ({ stdout: 'ok' }));
|
||||
const results = await Promise.all([
|
||||
isFfmpegAvailable(),
|
||||
isFfmpegAvailable(),
|
||||
isFfmpegAvailable(),
|
||||
]);
|
||||
expect(results).toEqual([true, true, true]);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOmniRuntimeDependencies', () => {
|
||||
it('passes when both binaries are present', async () => {
|
||||
mockExecResult(() => ({ stdout: 'ok' }));
|
||||
await expect(assertOmniRuntimeDependencies()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws an actionable FatalConfigError naming the missing binary', async () => {
|
||||
mockExecResult((command) =>
|
||||
command === 'ffmpeg'
|
||||
? { stdout: 'ok' }
|
||||
: {
|
||||
error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
},
|
||||
);
|
||||
await expect(assertOmniRuntimeDependencies()).rejects.toThrow(
|
||||
/ffprobe was not found on PATH.*brew install ffmpeg/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('names both binaries when neither is present', async () => {
|
||||
mockExecResult(() => ({
|
||||
error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
}));
|
||||
await expect(assertOmniRuntimeDependencies()).rejects.toThrow(
|
||||
/ffmpeg and ffprobe was not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeMediaMetadata (video)', () => {
|
||||
it('parses format and first-video-stream fields', async () => {
|
||||
mockExecResult((command) => {
|
||||
expect(command).toBe('ffprobe');
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mov,mp4,m4a,3gp,3g2,mj2', duration: '5.5' },
|
||||
streams: [
|
||||
{ codec_type: 'audio', codec_name: 'aac' },
|
||||
{
|
||||
codec_type: 'video',
|
||||
codec_name: 'h264',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
avg_frame_rate: '30000/1001',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
await expect(probeMediaMetadata('/v.mp4', 'video')).resolves.toEqual({
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
durationMs: 5500,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30000 / 1001,
|
||||
codec: 'h264',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to r_frame_rate when avg is 0/0', async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
codec_type: 'video',
|
||||
avg_frame_rate: '0/0',
|
||||
r_frame_rate: '25/1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const result = await probeMediaMetadata('/v.mp4', 'video');
|
||||
expect(result.frameRate).toBe(25);
|
||||
expect(result.durationMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on non-zero ffprobe exit', async () => {
|
||||
mockExecResult(() => ({
|
||||
error: Object.assign(new Error('bad'), { code: 1 }),
|
||||
stderr: 'moov atom not found',
|
||||
}));
|
||||
await expect(probeMediaMetadata('/broken.mp4', 'video')).rejects.toThrow(
|
||||
/ffprobe failed \(exit 1\).*moov atom/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unparseable ffprobe output', async () => {
|
||||
mockExecResult(() => ({ stdout: 'not-json' }));
|
||||
await expect(probeMediaMetadata('/v.mp4', 'video')).rejects.toThrow(
|
||||
/unparseable output/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeMediaMetadata per-modality branches', () => {
|
||||
it("reads the AUDIO stream (not video) for modality 'audio'", async () => {
|
||||
// A file carrying both streams proves the audio branch selects the
|
||||
// audio stream: reading videoStream?.codec_name here would yield 'h264'.
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mov,mp4', duration: '12.5' },
|
||||
streams: [
|
||||
{ codec_type: 'video', codec_name: 'h264', width: 640, height: 480 },
|
||||
{
|
||||
codec_type: 'audio',
|
||||
codec_name: 'aac',
|
||||
sample_rate: '44100',
|
||||
channels: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
await expect(probeMediaMetadata('/a.m4a', 'audio')).resolves.toEqual({
|
||||
formatName: 'mov,mp4',
|
||||
durationMs: 12_500,
|
||||
codec: 'aac',
|
||||
sampleRateHz: 44_100,
|
||||
channels: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the format-level bit rate and falls back to the stream', async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mp3', duration: '10', bit_rate: '320000' },
|
||||
streams: [
|
||||
{ codec_type: 'audio', codec_name: 'mp3', bit_rate: '128000' },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
await expect(probeMediaMetadata('/a.mp3', 'audio')).resolves.toMatchObject({
|
||||
bitRate: 320_000,
|
||||
});
|
||||
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mp3', duration: '10' },
|
||||
streams: [
|
||||
{ codec_type: 'audio', codec_name: 'mp3', bit_rate: '128000' },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
await expect(probeMediaMetadata('/a.mp3', 'audio')).resolves.toMatchObject({
|
||||
bitRate: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the video bit rate for modality video', async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mp4', duration: '5', bit_rate: '2500000' },
|
||||
streams: [{ codec_type: 'video', codec_name: 'h264' }],
|
||||
}),
|
||||
}));
|
||||
await expect(probeMediaMetadata('/v.mp4', 'video')).resolves.toMatchObject({
|
||||
bitRate: 2_500_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits bitRate/sampleRateHz/channels when unusable', async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'wav', duration: '3', bit_rate: 'N/A' },
|
||||
streams: [
|
||||
{
|
||||
codec_type: 'audio',
|
||||
codec_name: 'pcm_s16le',
|
||||
sample_rate: 'N/A',
|
||||
channels: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const result = await probeMediaMetadata('/a.wav', 'audio');
|
||||
expect(result.bitRate).toBeUndefined();
|
||||
expect(result.sampleRateHz).toBeUndefined();
|
||||
expect(result.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads only dimensions for modality 'image' (no duration)", async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'png_pipe', duration: '0.04' },
|
||||
streams: [
|
||||
{ codec_type: 'video', codec_name: 'png', width: 1920, height: 1080 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
// Images must not report a duration even when ffprobe invents one.
|
||||
await expect(probeMediaMetadata('/i.png', 'image')).resolves.toEqual({
|
||||
formatName: 'png_pipe',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports frameCount for animated images (nb_frames)', async () => {
|
||||
// Animated GIF/APNG/WebP report nb_frames on the video stream; the token
|
||||
// estimator needs the real count — a 300-frame GIF estimated as a single
|
||||
// frame would sail under the transport guard at ~1/300 of its real cost.
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'gif' },
|
||||
streams: [
|
||||
{
|
||||
codec_type: 'video',
|
||||
codec_name: 'gif',
|
||||
width: 480,
|
||||
height: 480,
|
||||
nb_frames: '300',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
await expect(probeMediaMetadata('/anim.gif', 'image')).resolves.toEqual({
|
||||
formatName: 'gif',
|
||||
width: 480,
|
||||
height: 480,
|
||||
codec: 'gif',
|
||||
frameCount: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits frameCount when nb_frames is absent or unusable', async () => {
|
||||
for (const nb of [undefined, '0', 'N/A']) {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'webp' },
|
||||
streams: [
|
||||
{
|
||||
codec_type: 'video',
|
||||
codec_name: 'webp',
|
||||
width: 64,
|
||||
height: 64,
|
||||
...(nb === undefined ? {} : { nb_frames: nb }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const result = await probeMediaMetadata('/i.webp', 'image');
|
||||
expect(result.frameCount).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("audio with no audio stream yields undefined codec, not the video's", async () => {
|
||||
mockExecResult(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: { format_name: 'mp4', duration: '3' },
|
||||
streams: [{ codec_type: 'video', codec_name: 'h264' }],
|
||||
}),
|
||||
}));
|
||||
const result = await probeMediaMetadata('/silent.mp4', 'audio');
|
||||
expect(result.codec).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFfmpeg', () => {
|
||||
it('invokes ffmpeg with the given args and resolves code 0 on success', async () => {
|
||||
mockExecResult(() => ({ stderr: 'frame= 100' }));
|
||||
await expect(
|
||||
runFfmpeg(['-y', '-i', '/in.mov', '/out.mp4']),
|
||||
).resolves.toEqual({
|
||||
code: 0,
|
||||
stderr: 'frame= 100',
|
||||
});
|
||||
const [command, args, options] = execFileMock.mock.calls[0];
|
||||
expect(command).toBe('ffmpeg');
|
||||
expect(args).toEqual(['-y', '-i', '/in.mov', '/out.mp4']);
|
||||
expect(options).toMatchObject({ maxBuffer: 16 * 1024 * 1024 });
|
||||
expect(options).not.toHaveProperty('timeout');
|
||||
});
|
||||
|
||||
it('threads timeoutMs and signal into execFile options', async () => {
|
||||
mockExecResult(() => ({}));
|
||||
const signal = new AbortController().signal;
|
||||
await runFfmpeg(['-version'], { signal, timeoutMs: 120_000 });
|
||||
const options = execFileMock.mock.calls[0][2];
|
||||
expect(options).toMatchObject({ timeout: 120_000, signal });
|
||||
});
|
||||
|
||||
it('never rejects: a failing run resolves with the exit code and stderr', async () => {
|
||||
mockExecResult(() => ({
|
||||
error: Object.assign(new Error('exit 187'), { code: 187 }),
|
||||
stderr: 'Conversion failed!',
|
||||
}));
|
||||
await expect(runFfmpeg(['-i', '/in.mov'])).resolves.toEqual({
|
||||
code: 187,
|
||||
stderr: 'Conversion failed!',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a non-numeric error code (e.g. ENOENT/abort kill) to 1', async () => {
|
||||
mockExecResult(() => ({
|
||||
error: Object.assign(new Error('spawn ffmpeg ENOENT'), {
|
||||
code: 'ENOENT',
|
||||
}),
|
||||
}));
|
||||
await expect(runFfmpeg(['-version'])).resolves.toEqual({
|
||||
code: 1,
|
||||
stderr: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile, type ExecFileOptions } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { FatalConfigError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Lightweight wrapper around execFile mirroring utils/pdf.ts execCommand.
|
||||
* Kept local to avoid exporting pdf.ts internals or importing shell-utils
|
||||
* (circular dependency risk in vitest mock environments).
|
||||
*/
|
||||
function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions = {},
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{ encoding: 'utf8', ...options },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve({
|
||||
stdout: String(stdout ?? ''),
|
||||
stderr: String(stderr ?? ''),
|
||||
code: typeof error.code === 'number' ? error.code : 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
stdout: String(stdout ?? ''),
|
||||
stderr: String(stderr ?? ''),
|
||||
code: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Availability cache: binary name → settled result or in-flight probe.
|
||||
* Concurrent callers share one probe subprocess per binary. */
|
||||
const availabilityCache = new Map<string, boolean | Promise<boolean>>();
|
||||
|
||||
async function probeBinary(binary: string): Promise<boolean> {
|
||||
const cached = availabilityCache.get(binary);
|
||||
if (typeof cached === 'boolean') return cached;
|
||||
if (cached) return cached;
|
||||
|
||||
const probe = (async () => {
|
||||
try {
|
||||
const { code } = await execCommand(binary, ['-version'], {
|
||||
timeout: 5000,
|
||||
});
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})().then(
|
||||
(result) => {
|
||||
availabilityCache.set(binary, result);
|
||||
return result;
|
||||
},
|
||||
() => {
|
||||
// Never cache a rejection — allow the next caller to retry.
|
||||
availabilityCache.delete(binary);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
availabilityCache.set(binary, probe);
|
||||
return probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `ffmpeg` is available on PATH. Cached for the process
|
||||
* lifetime; concurrent callers share one probe subprocess.
|
||||
*/
|
||||
export async function isFfmpegAvailable(): Promise<boolean> {
|
||||
return probeBinary('ffmpeg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `ffprobe` is available on PATH. Cached for the process
|
||||
* lifetime; concurrent callers share one probe subprocess.
|
||||
*/
|
||||
export async function isFfprobeAvailable(): Promise<boolean> {
|
||||
return probeBinary('ffprobe');
|
||||
}
|
||||
|
||||
/** Reset availability caches. Used by tests only. */
|
||||
export function resetFfmpegCachesForTests(): void {
|
||||
availabilityCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Omni runtime hard prerequisite: ffmpeg AND ffprobe must both be present
|
||||
* when the omni pipeline is enabled. Throws FatalConfigError with an
|
||||
* actionable message so startup fails fast instead of erroring midway
|
||||
* through the first video interaction.
|
||||
*/
|
||||
export async function assertOmniRuntimeDependencies(): Promise<void> {
|
||||
const [ffmpeg, ffprobe] = await Promise.all([
|
||||
isFfmpegAvailable(),
|
||||
isFfprobeAvailable(),
|
||||
]);
|
||||
if (ffmpeg && ffprobe) return;
|
||||
const missing = [
|
||||
...(ffmpeg ? [] : ['ffmpeg']),
|
||||
...(ffprobe ? [] : ['ffprobe']),
|
||||
].join(' and ');
|
||||
throw new FatalConfigError(
|
||||
`Omni multimodal support is enabled (omni.enabled / QWEN_CODE_ENABLE_OMNI=1) ` +
|
||||
`but ${missing} was not found on PATH. Install ffmpeg (e.g. ` +
|
||||
`"brew install ffmpeg" on macOS, "apt-get install ffmpeg" on Debian/Ubuntu) ` +
|
||||
`or disable omni support.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome of one ffmpeg run (see {@link runFfmpeg}). */
|
||||
export interface FfmpegRunResult {
|
||||
/** Process exit code (non-zero on failure, including timeout kill). */
|
||||
code: number;
|
||||
/** Captured stderr (ffmpeg writes its diagnostics there). */
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ffmpeg with the given arguments. Never rejects — callers branch on
|
||||
* the exit code, and MUST check `signal?.aborted` explicitly afterwards
|
||||
* (an aborted run also surfaces as a non-zero code, but the two need
|
||||
* different error messages). `timeoutMs` kills the process when exceeded,
|
||||
* which likewise surfaces as a non-zero exit code.
|
||||
*/
|
||||
export async function runFfmpeg(
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<FfmpegRunResult> {
|
||||
const { code, stderr } = await execCommand('ffmpeg', args, {
|
||||
// Transcodes are long-running; stderr carries progress lines, so give
|
||||
// it more headroom than the probe calls.
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
...(options?.timeoutMs !== undefined && { timeout: options.timeoutMs }),
|
||||
...(options?.signal && { signal: options.signal }),
|
||||
});
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
/** Media metadata extracted via ffprobe (fields populated per modality). */
|
||||
export interface MediaProbeResult {
|
||||
/** Container/format name reported by ffprobe (e.g. "mov,mp4,m4a,..."). */
|
||||
formatName?: string;
|
||||
/** Duration in milliseconds (video/audio), when reported. */
|
||||
durationMs?: number;
|
||||
/** Width in pixels (video: first video stream; image: the image). */
|
||||
width?: number;
|
||||
/** Height in pixels. */
|
||||
height?: number;
|
||||
/** Average frame rate (fps) of the first video stream (video only). */
|
||||
frameRate?: number;
|
||||
/** Codec name of the primary stream for the modality. */
|
||||
codec?: string;
|
||||
/** Frame count of the primary video stream (image: >1 means animated —
|
||||
* GIF/APNG/animated WebP; absent when the container does not report it). */
|
||||
frameCount?: number;
|
||||
/** Overall bit rate in bits/second (format-level; audio/video). */
|
||||
bitRate?: number;
|
||||
/** Sample rate in Hz of the first audio stream (audio only). */
|
||||
sampleRateHz?: number;
|
||||
/** Channel count of the first audio stream (audio only). */
|
||||
channels?: number;
|
||||
}
|
||||
|
||||
/** Parse an ffprobe rational like "30000/1001" (or plain "25") into fps. */
|
||||
function parseFrameRate(raw: string | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parts = raw.split('/');
|
||||
const num = Number(parts[0]);
|
||||
const den = parts.length > 1 ? Number(parts[1]) : 1;
|
||||
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fps = num / den;
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : undefined;
|
||||
}
|
||||
|
||||
/** Image containers/codecs that can hold more than one frame. Only these
|
||||
* warrant the decode-and-count fallback below — a plain JPEG/BMP without
|
||||
* nb_frames is single-frame by construction. */
|
||||
function isAnimationCapableImage(
|
||||
formatName: string | undefined,
|
||||
codecName: string | undefined,
|
||||
): boolean {
|
||||
const tokens = new Set([
|
||||
...(formatName?.split(',').map((t) => t.trim()) ?? []),
|
||||
...(codecName ? [codecName] : []),
|
||||
]);
|
||||
return ['gif', 'webp', 'png', 'apng'].some((t) => tokens.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count an image stream's frames by decoding it (`-count_frames` →
|
||||
* `nb_read_frames`). The fallback for animation-capable containers whose
|
||||
* headers carry no frame count (animated WebP, APNG). Returns NaN when
|
||||
* counting fails or aborts — the caller's finite-and-positive guard then
|
||||
* omits frameCount; the image tools' sharp `pages` check remains as the
|
||||
* second, independent animated-input backstop.
|
||||
*/
|
||||
async function countImageFrames(
|
||||
filePath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-count_frames',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=nb_read_frames',
|
||||
'-print_format',
|
||||
'json',
|
||||
filePath,
|
||||
],
|
||||
{
|
||||
timeout: 15_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
...(signal && { signal }),
|
||||
},
|
||||
);
|
||||
if (signal?.aborted || code !== 0) return NaN;
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
streams?: Array<{ nb_read_frames?: string }>;
|
||||
};
|
||||
return Number(parsed.streams?.[0]?.nb_read_frames);
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a local media file with ffprobe. Throws on non-zero exit or
|
||||
* unparseable output — the omni pipeline treats a failed probe as a
|
||||
* recognition failure (fail closed), not as "probably fine". Error
|
||||
* messages carry the file's basename only (they can reach model-visible
|
||||
* content). ffprobe handles images too (single video stream, no duration),
|
||||
* so the omni path needs no sharp dependency.
|
||||
*/
|
||||
export async function probeMediaMetadata(
|
||||
filePath: string,
|
||||
modality: 'image' | 'audio' | 'video',
|
||||
signal?: AbortSignal,
|
||||
): Promise<MediaProbeResult> {
|
||||
const { stdout, code, stderr } = await execCommand(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
filePath,
|
||||
],
|
||||
{ timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...(signal && { signal }) },
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
throw new Error(`ffprobe aborted for ${path.basename(filePath)}`);
|
||||
}
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`ffprobe failed (exit ${code}) for ${path.basename(filePath)}: ${stderr.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
let parsed: {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string };
|
||||
streams?: Array<{
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
avg_frame_rate?: string;
|
||||
r_frame_rate?: string;
|
||||
nb_frames?: string;
|
||||
sample_rate?: string;
|
||||
channels?: number;
|
||||
bit_rate?: string;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`ffprobe produced unparseable output for ${path.basename(filePath)}`,
|
||||
);
|
||||
}
|
||||
const videoStream = parsed.streams?.find((s) => s.codec_type === 'video');
|
||||
const audioStream = parsed.streams?.find((s) => s.codec_type === 'audio');
|
||||
const durationSeconds = Number(parsed.format?.duration);
|
||||
const durationMs =
|
||||
Number.isFinite(durationSeconds) && durationSeconds >= 0
|
||||
? Math.round(durationSeconds * 1000)
|
||||
: undefined;
|
||||
const parsePositiveInt = (raw: string | undefined): number | undefined => {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
||||
};
|
||||
// Prefer the format-level bit rate; fall back to the primary stream's.
|
||||
const bitRateFor = (stream?: { bit_rate?: string }): number | undefined =>
|
||||
parsePositiveInt(parsed.format?.bit_rate) ??
|
||||
parsePositiveInt(stream?.bit_rate);
|
||||
|
||||
const base: MediaProbeResult = { formatName: parsed.format?.format_name };
|
||||
switch (modality) {
|
||||
case 'image': {
|
||||
// Animated images (GIF/APNG/animated WebP) report nb_frames on their
|
||||
// video stream; a single-frame image reports 1 or omits it. The token
|
||||
// estimator needs the real count — an animated GIF estimated as one
|
||||
// frame sails under the transport guard at ~1/300 of its real cost.
|
||||
let nbFrames = Number(videoStream?.nb_frames);
|
||||
if (
|
||||
!(Number.isFinite(nbFrames) && nbFrames > 0) &&
|
||||
isAnimationCapableImage(
|
||||
parsed.format?.format_name,
|
||||
videoStream?.codec_name,
|
||||
)
|
||||
) {
|
||||
// Animation-capable container without a reported nb_frames:
|
||||
// ffprobe leaves it out for WebP and APNG (their headers carry no
|
||||
// frame count), so `missing` must not be read as `single-frame` —
|
||||
// that would fail OPEN through every animated-image gate (the D9
|
||||
// still-image exclusion and both image tools' refusals). Decode
|
||||
// the stream once to count the real frames.
|
||||
nbFrames = await countImageFrames(filePath, signal);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
width: videoStream?.width,
|
||||
height: videoStream?.height,
|
||||
codec: videoStream?.codec_name,
|
||||
...(Number.isFinite(nbFrames) && nbFrames > 0
|
||||
? { frameCount: nbFrames }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
case 'audio': {
|
||||
const channels = audioStream?.channels;
|
||||
return {
|
||||
...base,
|
||||
durationMs,
|
||||
codec: audioStream?.codec_name,
|
||||
bitRate: bitRateFor(audioStream),
|
||||
sampleRateHz: parsePositiveInt(audioStream?.sample_rate),
|
||||
...(typeof channels === 'number' && channels > 0 ? { channels } : {}),
|
||||
};
|
||||
}
|
||||
case 'video':
|
||||
default:
|
||||
return {
|
||||
...base,
|
||||
durationMs,
|
||||
width: videoStream?.width,
|
||||
height: videoStream?.height,
|
||||
frameRate:
|
||||
parseFrameRate(videoStream?.avg_frame_rate) ??
|
||||
parseFrameRate(videoStream?.r_frame_rate),
|
||||
codec: videoStream?.codec_name,
|
||||
bitRate: bitRateFor(videoStream),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MediaMemoryService,
|
||||
MediaResourceRegistry,
|
||||
} from '../services/media-memory/index.js';
|
||||
import { MEDIA_MEMORY_FILE_NAME } from '../services/media-memory/store.js';
|
||||
import { OmniObjectStore } from './storage.js';
|
||||
import {
|
||||
effectiveOmniStorageMaxTotalBytes,
|
||||
isOmniDerivationSuspended,
|
||||
resetGcLatchForTests,
|
||||
runOmniGcOnce,
|
||||
settleOmniGc,
|
||||
} from './gc.js';
|
||||
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
describe('runOmniGcOnce', () => {
|
||||
let qwenDir: string;
|
||||
let store: OmniObjectStore;
|
||||
let memory: MediaMemoryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetGcLatchForTests();
|
||||
qwenDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-gc-'));
|
||||
store = new OmniObjectStore(qwenDir);
|
||||
memory = new MediaMemoryService(store.getOmniRootDir());
|
||||
await fs.mkdir(store.getObjectsDir(), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(qwenDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Materialize one object file with controllable age and size. */
|
||||
async function writeObject(
|
||||
sha256: string,
|
||||
ageDays: number,
|
||||
sizeBytes = 4,
|
||||
): Promise<string> {
|
||||
const shardDir = path.join(store.getObjectsDir(), sha256.slice(0, 2));
|
||||
await fs.mkdir(shardDir, { recursive: true });
|
||||
const filePath = path.join(shardDir, `${sha256}.bin`);
|
||||
await fs.writeFile(filePath, Buffer.alloc(sizeBytes, 1));
|
||||
const mtime = new Date(Date.now() - ageDays * DAY_MS);
|
||||
await fs.utimes(filePath, mtime, mtime);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Record a policy execution whose media output references `sha256`,
|
||||
* making it a GC root through `entries[].artifactRef.managedId`. */
|
||||
async function referenceViaEntry(sha256: string): Promise<void> {
|
||||
const source = (await memory.recordFileRecognized({
|
||||
fileRef: '/movies/film.mkv',
|
||||
sha256: 'e'.repeat(64),
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 1000 },
|
||||
sizeBytes: 10,
|
||||
mimeType: 'video/x-matroska',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: 'film.mkv' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
}))!;
|
||||
await memory.commitPolicySucceeded({
|
||||
invocationId: 'aabbccdd00112233',
|
||||
source,
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'p',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
toolName: 'omni_downscale_video',
|
||||
finalArguments: {},
|
||||
omniConfigHash: 'fp-' + '0'.repeat(61),
|
||||
startedAt: '2026-08-13T00:00:00.000Z',
|
||||
completedAt: '2026-08-13T00:00:01.000Z',
|
||||
outputs: [
|
||||
{
|
||||
kind: 'media',
|
||||
objectPath: store.objectPathFor(sha256, '.bin'),
|
||||
sha256,
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 1000 },
|
||||
sizeBytes: 4,
|
||||
mimeType: 'video/mp4',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function gcOptions(overrides?: Partial<Parameters<typeof runOmniGcOnce>[0]>) {
|
||||
return {
|
||||
store,
|
||||
memoryService: memory,
|
||||
retentionDays: 14,
|
||||
maxTotalBytes: 1024 * 1024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('sweeps an expired unreferenced object and keeps a referenced one', async () => {
|
||||
const dead = 'a'.repeat(64);
|
||||
const kept = 'b'.repeat(64);
|
||||
const deadPath = await writeObject(dead, 30);
|
||||
const keptPath = await writeObject(kept, 30);
|
||||
await referenceViaEntry(kept);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result).toMatchObject({ ran: true, deletedObjects: 1 });
|
||||
await expect(fs.access(deadPath)).rejects.toThrow();
|
||||
await expect(fs.access(keptPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a young unreferenced object (retention grace)', async () => {
|
||||
// The window is what makes "promoted, memory commit still in flight"
|
||||
// and cross-process races safe — a fresh orphan must survive.
|
||||
const young = 'c'.repeat(64);
|
||||
const p = await writeObject(young, 2);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a managed source locator as a root, not just artifactRefs', async () => {
|
||||
// Tool/URL media anchor their file identity in the object store —
|
||||
// their only reference is `versions[].source.locator`. A GC that only
|
||||
// reads artifactRefs would delete exactly the objects whose store copy
|
||||
// is the only copy.
|
||||
const anchored = 'd'.repeat(64);
|
||||
const p = await writeObject(anchored, 30);
|
||||
await memory.recordFileRecognized({
|
||||
fileRef: store.objectPathFor(anchored, '.bin'),
|
||||
sha256: anchored,
|
||||
mediaType: 'image',
|
||||
metadata: { width: 8, height: 8 },
|
||||
sizeBytes: 4,
|
||||
mimeType: 'image/png',
|
||||
origin: 'tool',
|
||||
source: { protocol: 'managed', locator: `sha256/${anchored}` },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('roots a URL-origin version through its in-store fileRef', async () => {
|
||||
// URL media keep the ORIGINAL URL as source.locator (protocol 'url'),
|
||||
// but their staging download is deleted the turn it lands — the store
|
||||
// copy named by fileRef is the only persistent bytes. A root set that
|
||||
// only reads managed locators would delete exactly those objects
|
||||
// while the ledger still vouches for them (hard rule 2).
|
||||
const urlAnchored = '5'.repeat(64);
|
||||
const p = await writeObject(urlAnchored, 30);
|
||||
await memory.recordFileRecognized({
|
||||
fileRef: store.objectPathFor(urlAnchored, '.bin'),
|
||||
sha256: urlAnchored,
|
||||
mediaType: 'video',
|
||||
metadata: { durationMs: 1000 },
|
||||
sizeBytes: 4,
|
||||
mimeType: 'video/mp4',
|
||||
origin: 'user',
|
||||
source: { protocol: 'url', locator: 'https://example.com/clip.mp4' },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
|
||||
// Both passes must keep it: pass 1 (expired) and pass 2 (budget).
|
||||
const result = await runOmniGcOnce(gcOptions({ maxTotalBytes: 1 }));
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('protects objects the live session registry still points at', async () => {
|
||||
const live = 'f'.repeat(64);
|
||||
const p = await writeObject(live, 30);
|
||||
const registry = new MediaResourceRegistry();
|
||||
registry.bind({
|
||||
fileId: 'f1',
|
||||
fileVersionId: 'v1',
|
||||
rootFileId: 'f1',
|
||||
fileRef: store.objectPathFor(live, '.bin'),
|
||||
mediaType: 'image',
|
||||
});
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions({ registry }));
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a registry fileRef OUTSIDE the store, even with a matching name', async () => {
|
||||
// The registry-root scope guard: a handle on a user file protects
|
||||
// nothing — only locators inside objects/ count. A same-named file
|
||||
// outside the store must not shield the store copy.
|
||||
const sha = '6'.repeat(64);
|
||||
const p = await writeObject(sha, 30);
|
||||
const outside = path.join(qwenDir, `${sha}.bin`);
|
||||
await fs.writeFile(outside, 'user copy');
|
||||
const registry = new MediaResourceRegistry();
|
||||
registry.bind({
|
||||
fileId: 'f1',
|
||||
fileVersionId: 'v1',
|
||||
rootFileId: 'f1',
|
||||
fileRef: outside,
|
||||
mediaType: 'image',
|
||||
});
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions({ registry }));
|
||||
|
||||
expect(result.deletedObjects).toBe(1);
|
||||
await expect(fs.access(p)).rejects.toThrow();
|
||||
await expect(fs.access(outside)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes NOTHING when the ledger was corrupt (recovery-backup guard)', async () => {
|
||||
// A corrupt document does not read as null: the store SELF-HEALS it
|
||||
// (rename to `.corrupt-<ts>`, continue on empty). What blocks this
|
||||
// run is the corruption-recovery guard — an empty post-heal ledger
|
||||
// must not read as "nothing is referenced".
|
||||
await writeObject('a'.repeat(64), 400);
|
||||
await fs.writeFile(
|
||||
path.join(store.getOmniRootDir(), MEDIA_MEMORY_FILE_NAME),
|
||||
'{not json',
|
||||
);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(store.getObjectsDir(), 'aa', `${'a'.repeat(64)}.bin`),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes NOTHING when the root set is unknowable (refs === null)', async () => {
|
||||
// Hard rule 1 proper: a service that cannot READ the ledger (EACCES,
|
||||
// I/O error — distinct from corrupt-and-healed) reports null, and
|
||||
// null must never be treated as an empty root set.
|
||||
const p = await writeObject('a'.repeat(64), 400);
|
||||
const unreadableService = {
|
||||
collectManagedRefs: async () => null,
|
||||
} as unknown as MediaMemoryService;
|
||||
|
||||
const result = await runOmniGcOnce(
|
||||
gcOptions({ memoryService: unreadableService }),
|
||||
);
|
||||
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('roots an entry artifactRef on its own (no version locator for it)', async () => {
|
||||
// `entries[].artifactRef.managedId` must be an independent root: a
|
||||
// hand-written ledger references the object ONLY through an entry —
|
||||
// no version record backs it up (the service's own commit would
|
||||
// double-root, masking a regression in this branch).
|
||||
const sha = '8'.repeat(64);
|
||||
const p = await writeObject(sha, 30);
|
||||
await fs.writeFile(
|
||||
path.join(store.getOmniRootDir(), MEDIA_MEMORY_FILE_NAME),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
files: {},
|
||||
versions: {},
|
||||
executions: {},
|
||||
entries: {
|
||||
'e-1': {
|
||||
kind: 'derived_media',
|
||||
artifactRef: { storage: 'managed', managedId: `sha256/${sha}` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('over budget: deletes oldest unreferenced objects regardless of age', async () => {
|
||||
const older = '1'.repeat(64);
|
||||
const newer = '2'.repeat(64);
|
||||
await writeObject(older, 5, 600); // younger than retention, but budget
|
||||
await writeObject(newer, 1, 600);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions({ maxTotalBytes: 800 }));
|
||||
|
||||
// Oldest goes first; once within budget the newer one survives.
|
||||
expect(result.deletedObjects).toBe(1);
|
||||
await expect(
|
||||
fs.access(path.join(store.getObjectsDir(), '11', `${older}.bin`)),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(store.getObjectsDir(), '22', `${newer}.bin`)),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('budget pass spares an object the FRESH ledger references (stale-snapshot race)', async () => {
|
||||
// The initial root snapshot goes stale while the sweep runs; a commit
|
||||
// landing in that gap must not lose its object. The budget pass
|
||||
// re-reads the ledger right before deleting — simulate the race with
|
||||
// a service whose second read knows the new reference.
|
||||
const contested = '7'.repeat(64);
|
||||
const p = await writeObject(contested, 5, 600);
|
||||
let reads = 0;
|
||||
const racingService = {
|
||||
collectManagedRefs: async () =>
|
||||
++reads === 1 ? new Set<string>() : new Set([contested]),
|
||||
} as unknown as MediaMemoryService;
|
||||
|
||||
const result = await runOmniGcOnce(
|
||||
gcOptions({ memoryService: racingService, maxTotalBytes: 100 }),
|
||||
);
|
||||
|
||||
expect(reads).toBeGreaterThanOrEqual(2);
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
// Still over budget with (now-)referenced bytes only → suspended.
|
||||
expect(result.derivationsSuspended).toBe(true);
|
||||
});
|
||||
|
||||
it('budget pass skips an object whose mtime was touched during the sweep', async () => {
|
||||
// putFile's dedup touch precedes every new commit; an object touched
|
||||
// after the sweep began signals an in-flight reference. The delete
|
||||
// loop re-stats each candidate — the touched one must survive even
|
||||
// though the budget still wants its bytes. (`first` is expired and
|
||||
// goes in pass 1; young `second` is only reachable by the budget
|
||||
// pass, whose cascade-triggered touch must spare it.)
|
||||
const first = '3'.repeat(64);
|
||||
const second = '4'.repeat(64);
|
||||
await writeObject(first, 30, 600);
|
||||
const secondPath = await writeObject(second, 5, 600);
|
||||
|
||||
const result = await runOmniGcOnce(
|
||||
gcOptions({
|
||||
maxTotalBytes: 100,
|
||||
uploadCache: {
|
||||
// Fires while `first` is being deleted — before `second`'s turn.
|
||||
// A +10s timestamp keeps the assertion immune to filesystems
|
||||
// that truncate mtime to whole seconds (observed on the CI
|
||||
// runner: touch-with-now floored below the sweep start).
|
||||
removeBySha256: async () => {
|
||||
const future = new Date(Date.now() + 10_000);
|
||||
await fs.utimes(secondPath, future, future);
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.deletedObjects).toBe(1);
|
||||
await expect(fs.access(secondPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('over budget with only referenced objects: suspends derivations, deletes nothing', async () => {
|
||||
const kept = 'b'.repeat(64);
|
||||
await writeObject(kept, 30, 2048);
|
||||
await referenceViaEntry(kept);
|
||||
const root = store.getOmniRootDir();
|
||||
expect(isOmniDerivationSuspended(root)).toBe(false);
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions({ maxTotalBytes: 100 }));
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
expect(result.derivationsSuspended).toBe(true);
|
||||
expect(isOmniDerivationSuspended(root)).toBe(true);
|
||||
|
||||
// A later run under a raised budget clears the suspension. Re-arm
|
||||
// ONLY the run latch — the flag must stand until the relaxed sweep
|
||||
// itself clears it, or this assertion proves nothing.
|
||||
resetGcLatchForTests({ keepSuspension: true });
|
||||
expect(isOmniDerivationSuspended(root)).toBe(true);
|
||||
const relaxed = await runOmniGcOnce(gcOptions({ maxTotalBytes: 10_000 }));
|
||||
expect(relaxed.derivationsSuspended).toBe(false);
|
||||
expect(isOmniDerivationSuspended(root)).toBe(false);
|
||||
});
|
||||
|
||||
it('cascades a deletion into the upload and degradation caches', async () => {
|
||||
const dead = 'a'.repeat(64);
|
||||
const deadPath = await writeObject(dead, 30);
|
||||
const uploadRemoved: string[] = [];
|
||||
const degradedRemoved: string[] = [];
|
||||
let bytesGoneAtCascade: boolean | undefined;
|
||||
await runOmniGcOnce(
|
||||
gcOptions({
|
||||
uploadCache: {
|
||||
removeBySha256: async (sha: string) => {
|
||||
// Load-bearing ORDER: the bytes must be gone before the
|
||||
// cache entry — the reverse could re-serve a deleted object
|
||||
// from cache.
|
||||
bytesGoneAtCascade = await fs.access(deadPath).then(
|
||||
() => false,
|
||||
() => true,
|
||||
);
|
||||
uploadRemoved.push(sha);
|
||||
},
|
||||
} as never,
|
||||
degradationCache: {
|
||||
removeByOriginalSha256: async (sha: string) => {
|
||||
degradedRemoved.push(`orig:${sha}`);
|
||||
},
|
||||
removeByDegradedSha256: async (sha: string) => {
|
||||
degradedRemoved.push(`deg:${sha}`);
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(uploadRemoved).toEqual([dead]);
|
||||
expect(degradedRemoved).toEqual([`orig:${dead}`, `deg:${dead}`]);
|
||||
expect(bytesGoneAtCascade).toBe(true);
|
||||
});
|
||||
|
||||
it('accounts a failed rm as a survivor (budget keeps its bytes)', async () => {
|
||||
const stuck = 'a'.repeat(64);
|
||||
const p = await writeObject(stuck, 30, 600);
|
||||
const rmSpy = vi
|
||||
.spyOn(fs, 'rm')
|
||||
.mockRejectedValue(new Error('EPERM: operation not permitted'));
|
||||
try {
|
||||
const result = await runOmniGcOnce(gcOptions({ maxTotalBytes: 100 }));
|
||||
|
||||
// Nothing was deleted, nothing was double-counted, and the
|
||||
// undeletable bytes still count against the budget.
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
expect(result.deletedBytes).toBe(0);
|
||||
expect(result.remainingBytes).toBe(600);
|
||||
expect(result.derivationsSuspended).toBe(true);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
await expect(fs.access(p)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keys the latch and the suspension per store root', async () => {
|
||||
// Two roots in ONE process: each gets its own sweep and its own
|
||||
// suspension verdict — the Map/Set keying is what this pins.
|
||||
const otherQwenDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-gc2-'));
|
||||
try {
|
||||
const otherStore = new OmniObjectStore(otherQwenDir);
|
||||
const otherMemory = new MediaMemoryService(otherStore.getOmniRootDir());
|
||||
await fs.mkdir(otherStore.getObjectsDir(), { recursive: true });
|
||||
|
||||
// Root A: over budget with only referenced bytes → suspended.
|
||||
const kept = 'b'.repeat(64);
|
||||
await writeObject(kept, 30, 2048);
|
||||
await referenceViaEntry(kept);
|
||||
const first = await runOmniGcOnce(gcOptions({ maxTotalBytes: 100 }));
|
||||
|
||||
// Root B: one expired orphan, comfortable budget → swept, clean.
|
||||
const orphan = 'a'.repeat(64);
|
||||
const shard = path.join(otherStore.getObjectsDir(), orphan.slice(0, 2));
|
||||
await fs.mkdir(shard, { recursive: true });
|
||||
const orphanPath = path.join(shard, `${orphan}.bin`);
|
||||
await fs.writeFile(orphanPath, Buffer.alloc(4, 1));
|
||||
const old = new Date(Date.now() - 30 * 24 * 3600_000);
|
||||
await fs.utimes(orphanPath, old, old);
|
||||
const second = await runOmniGcOnce({
|
||||
store: otherStore,
|
||||
memoryService: otherMemory,
|
||||
retentionDays: 14,
|
||||
maxTotalBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
// Distinct runs, not the first root's settled result.
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.deletedObjects).toBe(1);
|
||||
expect(isOmniDerivationSuspended(store.getOmniRootDir())).toBe(true);
|
||||
expect(isOmniDerivationSuspended(otherStore.getOmniRootDir())).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(otherQwenDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('effectiveOmniStorageMaxTotalBytes floors the budget at 10× the media limit', () => {
|
||||
const configFor = (budget: number, singleMedia?: number) =>
|
||||
({
|
||||
getOmniStorageMaxTotalBytes: () => budget,
|
||||
getOmniMaxUploadFileBytes: () => singleMedia,
|
||||
}) as never;
|
||||
|
||||
// Below the floor: clamped up (storage design §7 — the budget must
|
||||
// hold at least ten normal uploads or it reads as a broken pipeline).
|
||||
expect(effectiveOmniStorageMaxTotalBytes(configFor(1000, 500))).toBe(5000);
|
||||
// At or above the floor: honored verbatim.
|
||||
expect(effectiveOmniStorageMaxTotalBytes(configFor(5000, 500))).toBe(5000);
|
||||
expect(effectiveOmniStorageMaxTotalBytes(configFor(9999, 500))).toBe(9999);
|
||||
// No explicit media limit: the guard default (1 GiB) drives the floor.
|
||||
expect(effectiveOmniStorageMaxTotalBytes(configFor(1000))).toBe(
|
||||
10 * 1024 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs once per store root per process', async () => {
|
||||
await writeObject('a'.repeat(64), 30);
|
||||
const first = await runOmniGcOnce(gcOptions());
|
||||
// Second call returns the SAME settled run — no re-sweep.
|
||||
const again = await runOmniGcOnce(gcOptions());
|
||||
expect(first.deletedObjects).toBe(1);
|
||||
expect(again).toBe(first);
|
||||
});
|
||||
|
||||
it('settleOmniGc waits out an in-flight sweep before the gate reads the flag', async () => {
|
||||
// The startup wiring is fire-and-forget; the budget gate must not
|
||||
// consult isOmniDerivationSuspended while the sweep is still running
|
||||
// (observed E2E: the first derivation of a fresh process slipped past
|
||||
// the budget because the verdict landed ~900ms later).
|
||||
const kept = 'b'.repeat(64);
|
||||
await writeObject(kept, 30, 2048);
|
||||
await referenceViaEntry(kept);
|
||||
const root = store.getOmniRootDir();
|
||||
|
||||
// Fire-and-forget, exactly like the index.ts wiring — no await.
|
||||
void runOmniGcOnce(gcOptions({ maxTotalBytes: 100 }));
|
||||
await settleOmniGc(root);
|
||||
|
||||
expect(isOmniDerivationSuspended(root)).toBe(true);
|
||||
// A root with no pending run settles immediately.
|
||||
await expect(settleOmniGc('/nonexistent')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never descends into a symlinked shard', async () => {
|
||||
// A link planted inside objects/ must not redirect the sweep at
|
||||
// arbitrary external paths (same guard the recovery scan carries).
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-gc-out-'));
|
||||
const victim = path.join(outside, `${'9'.repeat(64)}.bin`);
|
||||
await fs.writeFile(victim, 'x');
|
||||
const old = new Date(Date.now() - 400 * DAY_MS);
|
||||
await fs.utimes(victim, old, old);
|
||||
await fs.symlink(outside, path.join(store.getObjectsDir(), '99'));
|
||||
|
||||
const result = await runOmniGcOnce(gcOptions());
|
||||
|
||||
expect(result.deletedObjects).toBe(0);
|
||||
await expect(fs.access(victim)).resolves.toBeUndefined();
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MediaMemoryService } from '../services/media-memory/service.js';
|
||||
import type { MediaResourceRegistry } from '../services/media-memory/registry.js';
|
||||
import type { OmniObjectStore } from './storage.js';
|
||||
import type { OmniUploadCache } from './upload-cache.js';
|
||||
import type { OmniDegradationCache } from './policy/degradation-cache.js';
|
||||
import { effectiveMaxUploadFileBytes } from './guard.js';
|
||||
import { MEDIA_MEMORY_FILE_NAME } from '../services/media-memory/store.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:gc');
|
||||
|
||||
/**
|
||||
* Mark-and-sweep GC over the content-addressed object store (storage
|
||||
* design §6.2). The design's two hard rules shape everything here:
|
||||
*
|
||||
* 1. **An unreadable ledger deletes nothing.** The root set comes from the
|
||||
* memory snapshot; if that snapshot cannot be read, the GC has no way
|
||||
* to tell "unreferenced" from "unknown" and skips the run entirely.
|
||||
* 2. **A referenced object is never deleted, even over budget.** Deleting
|
||||
* it would leave memory records pointing at nothing — recall would
|
||||
* report `artifact_unavailable` for artifacts the user paid for and the
|
||||
* ledger still vouches for. Budget pressure is answered by suspending
|
||||
* NEW derivations (policy design §8.4's budget-stop semantics), never
|
||||
* by rewriting history.
|
||||
*
|
||||
* Sweep order is oldest-first so the budget pass converges on the objects
|
||||
* least likely to be re-used. Every deletion cascades into the upload
|
||||
* cache and the degradation cache — a cache hit must never point at bytes
|
||||
* that are gone (same invariant the recovery scan's corrupt-object path
|
||||
* already enforces).
|
||||
*
|
||||
* Multi-process safety is layered. New BYTES are covered by the
|
||||
* retention window (deletion in pass 1 requires BOTH "no reference in
|
||||
* the snapshot" and "older than the window", so another process's
|
||||
* just-promoted object survives on age alone — same argument as startup
|
||||
* recovery). New REFERENCES to old bytes are covered by touch-on-dedup:
|
||||
* every reference is preceded by a `putFile` whose dedup hit refreshes
|
||||
* the object's mtime before the commit lands, and both passes re-stat a
|
||||
* candidate at delete time (the budget pass — where age protects
|
||||
* nothing — additionally re-reads the ledger for a fresh root set).
|
||||
* The residual window is the moment between the re-stat and the rm,
|
||||
* down from the whole sweep duration. The once-per-root latch is
|
||||
* process-local by design; two processes GCing the same store can only
|
||||
* disagree toward "delete less".
|
||||
*/
|
||||
|
||||
/** Object-file shape inside a shard: `<64-hex>.<extension>`. Anything else
|
||||
* (promotion temps, strays) belongs to the recovery scan, not the GC. */
|
||||
const OBJECT_NAME_RE = /^([0-9a-f]{64})\.[A-Za-z0-9][A-Za-z0-9.]*$/;
|
||||
|
||||
export interface OmniGcOptions {
|
||||
store: OmniObjectStore;
|
||||
memoryService: MediaMemoryService;
|
||||
/** Live session bindings — roots alongside the snapshot (a resource
|
||||
* delivered this turn may be bound before its memory commit lands). */
|
||||
registry?: MediaResourceRegistry;
|
||||
uploadCache?: OmniUploadCache;
|
||||
degradationCache?: OmniDegradationCache;
|
||||
/** Days an unreferenced object survives after promotion. */
|
||||
retentionDays: number;
|
||||
/** Soft byte budget for objects/; over-budget triggers oldest-first
|
||||
* deletion of unreferenced objects regardless of age. */
|
||||
maxTotalBytes: number;
|
||||
/** Test seam: "now" in epoch ms. */
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export interface OmniGcResult {
|
||||
/** False when the run was skipped (unreadable snapshot / latch). */
|
||||
ran: boolean;
|
||||
deletedObjects: number;
|
||||
deletedBytes: number;
|
||||
/** Bytes remaining in objects/ after the sweep. */
|
||||
remainingBytes: number;
|
||||
/** True when the store is still over budget with only referenced
|
||||
* objects left — new derivations are suspended until a later run
|
||||
* clears it. */
|
||||
derivationsSuspended: boolean;
|
||||
}
|
||||
|
||||
const SKIPPED: OmniGcResult = {
|
||||
ran: false,
|
||||
deletedObjects: 0,
|
||||
deletedBytes: 0,
|
||||
remainingBytes: 0,
|
||||
derivationsSuspended: false,
|
||||
};
|
||||
|
||||
/** One GC per store root per process lifetime (same latch pattern as
|
||||
* startup recovery); the suspension flag lives beside it so the
|
||||
* orchestrator can consult it without holding a GC reference. */
|
||||
const gcOnce = new Map<string, Promise<OmniGcResult>>();
|
||||
const suspendedRoots = new Set<string>();
|
||||
|
||||
/** Test-only: allow re-running GC within one process. Pass
|
||||
* `keepSuspension` to re-arm the run latch while leaving the suspension
|
||||
* flag standing — the only way to prove a later run actually CLEARS it
|
||||
* (a full reset would make that assertion vacuous). */
|
||||
export function resetGcLatchForTests(options?: {
|
||||
keepSuspension?: boolean;
|
||||
}): void {
|
||||
gcOnce.clear();
|
||||
if (!options?.keepSuspension) suspendedRoots.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective byte budget for the sweep (storage design §7): never below
|
||||
* 10× the transport guard's single-media ceiling. A budget smaller than
|
||||
* a handful of normal uploads cannot hold legitimate experiment
|
||||
* artifacts — the first referenced object would tip it into permanent
|
||||
* derivation suspension, which reads as a broken pipeline rather than a
|
||||
* configuration mistake.
|
||||
*/
|
||||
export function effectiveOmniStorageMaxTotalBytes(config: Config): number {
|
||||
const configured = config.getOmniStorageMaxTotalBytes();
|
||||
const floor = 10 * effectiveMaxUploadFileBytes(config);
|
||||
if (configured < floor) {
|
||||
debugLogger.warn(
|
||||
`omni.storage.maxTotalBytes (${configured}) is below 10× the ` +
|
||||
`transport guard's single-media limit; using ${floor} bytes ` +
|
||||
`instead (the budget must hold at least ten normal uploads).`,
|
||||
);
|
||||
return floor;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether new policy derivations are suspended for this store (storage
|
||||
* design §6.2: over budget with only referenced objects left). Consulted
|
||||
* by the orchestrator before executing a policy tool; reuse and cache
|
||||
* hits stay allowed — they produce no new bytes.
|
||||
*/
|
||||
export function isOmniDerivationSuspended(omniRootDir: string): boolean {
|
||||
return suspendedRoots.has(path.resolve(omniRootDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Await this process's in-flight GC for a store root, if one was started.
|
||||
* The startup wiring runs the GC fire-and-forget so delivery is never
|
||||
* blocked on a sweep — but the budget gate MUST NOT be consulted while
|
||||
* the sweep that sets the suspension flag is still running, or the first
|
||||
* derivation of every fresh process races past the budget (observed in
|
||||
* E2E: one headless run wrote new derivatives before the over-budget
|
||||
* verdict landed ~900ms later). Awaiting the settled run costs nothing
|
||||
* when the GC already finished and never rejects (runOmniGcOnce catches).
|
||||
*/
|
||||
export async function settleOmniGc(omniRootDir: string): Promise<void> {
|
||||
const existing = gcOnce.get(path.resolve(omniRootDir));
|
||||
if (existing) await existing;
|
||||
}
|
||||
|
||||
/** Real-directory guard (lstat, symlinks rejected) — the sweep deletes
|
||||
* files under every directory it descends into, and a symlinked shard
|
||||
* would redirect those deletions outside the store. Same rationale as
|
||||
* the recovery scan's guard. */
|
||||
async function isRealDirectory(p: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.lstat(p)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ObjectStat {
|
||||
sha256: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
async function listObjects(objectsDir: string): Promise<ObjectStat[]> {
|
||||
const out: ObjectStat[] = [];
|
||||
if (!(await isRealDirectory(objectsDir))) return out;
|
||||
let shards: string[];
|
||||
try {
|
||||
shards = await fs.readdir(objectsDir);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const shard of shards) {
|
||||
const shardDir = path.join(objectsDir, shard);
|
||||
if (!(await isRealDirectory(shardDir))) continue;
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(shardDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
const match = OBJECT_NAME_RE.exec(name);
|
||||
if (!match) continue;
|
||||
const filePath = path.join(shardDir, name);
|
||||
try {
|
||||
const st = await fs.lstat(filePath);
|
||||
if (!st.isFile()) continue;
|
||||
out.push({
|
||||
sha256: match[1],
|
||||
filePath,
|
||||
mtimeMs: st.mtimeMs,
|
||||
sizeBytes: st.size,
|
||||
});
|
||||
} catch {
|
||||
// Raced away; nothing to account for.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deleteObject(
|
||||
object: ObjectStat,
|
||||
options: OmniGcOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await fs.rm(object.filePath, { force: true });
|
||||
} catch (err) {
|
||||
debugLogger.debug(
|
||||
`gc: failed to delete ${object.sha256}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Cascade AFTER the bytes are gone: a cache entry pointing at a missing
|
||||
// object degrades to a miss (both caches already tolerate that), while
|
||||
// the reverse order could re-serve a deleted object from cache.
|
||||
try {
|
||||
await options.uploadCache?.removeBySha256(object.sha256);
|
||||
} catch {
|
||||
// Cache hygiene is best-effort; a stale entry degrades to a miss.
|
||||
}
|
||||
try {
|
||||
await options.degradationCache?.removeByOriginalSha256(object.sha256);
|
||||
await options.degradationCache?.removeByDegradedSha256(object.sha256);
|
||||
} catch {
|
||||
// Same best-effort stance.
|
||||
}
|
||||
debugLogger.debug(
|
||||
`gc: deleted unreferenced object ${object.sha256} (${object.sizeBytes} bytes)`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the GC once per store root per process. Runs AFTER startup
|
||||
* recovery (the recovery scan owns staging/downloads/quarantine and
|
||||
* temp-file hygiene; the GC owns only committed objects).
|
||||
*/
|
||||
export function runOmniGcOnce(options: OmniGcOptions): Promise<OmniGcResult> {
|
||||
const rootKey = path.resolve(options.store.getOmniRootDir());
|
||||
const existing = gcOnce.get(rootKey);
|
||||
if (existing) return existing;
|
||||
const run = sweep(options, rootKey).catch((err) => {
|
||||
debugLogger.debug(
|
||||
`gc: run failed, skipped: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return SKIPPED;
|
||||
});
|
||||
gcOnce.set(rootKey, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the memory ledger was recently lost to corruption. The store's
|
||||
* own recovery (C8) backs a corrupt document up as
|
||||
* `memory.json.corrupt-<ts>` and continues on an EMPTY snapshot — correct
|
||||
* for recall (degrade to a miss, start a fresh ledger), catastrophic if
|
||||
* the GC trusted it: an empty ledger reads as "nothing is referenced" and
|
||||
* the sweep would erase the entire store off one corruption event. A
|
||||
* fresh backup therefore blocks the run; once the newest backup is older
|
||||
* than the retention window, the empty ledger has stood long enough to be
|
||||
* the real state of the world.
|
||||
*/
|
||||
async function recentlyRecoveredFromCorruption(
|
||||
omniRootDir: string,
|
||||
retentionDays: number,
|
||||
nowMs: number,
|
||||
): Promise<boolean> {
|
||||
const prefix = `${MEDIA_MEMORY_FILE_NAME}.corrupt-`;
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(omniRootDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const cutoff = nowMs - retentionDays * 24 * 3600_000;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
const stamp = Number(name.slice(prefix.length));
|
||||
if (Number.isFinite(stamp) && stamp >= cutoff) return true;
|
||||
try {
|
||||
const st = await fs.lstat(path.join(omniRootDir, name));
|
||||
if (st.mtimeMs >= cutoff) return true;
|
||||
} catch {
|
||||
// Unreadable backup: treat as recent (fail-closed).
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function sweep(
|
||||
options: OmniGcOptions,
|
||||
rootKey: string,
|
||||
): Promise<OmniGcResult> {
|
||||
const nowMsForGuard = options.nowMs ?? Date.now();
|
||||
const refs = await options.memoryService.collectManagedRefs();
|
||||
if (refs === null) {
|
||||
// Hard rule 1: roots unknown → delete nothing, and leave any prior
|
||||
// suspension in place (we cannot prove the pressure is gone either).
|
||||
debugLogger.debug('gc: memory snapshot unreadable, skipping run');
|
||||
return SKIPPED;
|
||||
}
|
||||
// Reading the snapshot above may itself have just performed the
|
||||
// corruption recovery — the backup check must come AFTER it.
|
||||
if (
|
||||
await recentlyRecoveredFromCorruption(
|
||||
options.store.getOmniRootDir(),
|
||||
options.retentionDays,
|
||||
nowMsForGuard,
|
||||
)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
'gc: memory ledger recently recovered from corruption, skipping run',
|
||||
);
|
||||
return SKIPPED;
|
||||
}
|
||||
// Live session handles count as roots too. Only locators inside the
|
||||
// object store matter here — a handle on a user file protects nothing
|
||||
// (GC never touches paths outside objects/ in the first place).
|
||||
const objectsDir = options.store.getObjectsDir();
|
||||
for (const fileRef of options.registry?.activeFileRefs() ?? []) {
|
||||
const name = path.basename(fileRef);
|
||||
const match = OBJECT_NAME_RE.exec(name);
|
||||
if (match && fileRef.startsWith(objectsDir)) refs.add(match[1]);
|
||||
}
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const cutoffMs = nowMs - options.retentionDays * 24 * 3600_000;
|
||||
const objects = await listObjects(objectsDir);
|
||||
|
||||
let deletedObjects = 0;
|
||||
let deletedBytes = 0;
|
||||
const survivors: ObjectStat[] = [];
|
||||
|
||||
/** Freshness re-check at delete time. The root snapshot and the object
|
||||
* listing are minutes-stale by the time a candidate is deleted, and a
|
||||
* concurrent commit can reference OLD bytes in that gap (its putFile
|
||||
* dedup-touch refreshes the object's mtime BEFORE the commit lands —
|
||||
* ordering invariant). Re-statting narrows the race from "whole sweep
|
||||
* duration" to the moment between this stat and the rm. */
|
||||
const touchedSinceListing = async (
|
||||
object: ObjectStat,
|
||||
freshCutoffMs: number,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return (await fs.lstat(object.filePath)).mtimeMs >= freshCutoffMs;
|
||||
} catch {
|
||||
// Raced away — nothing left to delete either.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Pass 1: expired and unreferenced.
|
||||
for (const object of objects) {
|
||||
if (refs.has(object.sha256) || object.mtimeMs >= cutoffMs) {
|
||||
survivors.push(object);
|
||||
continue;
|
||||
}
|
||||
if (await touchedSinceListing(object, cutoffMs)) {
|
||||
survivors.push(object);
|
||||
continue;
|
||||
}
|
||||
if (await deleteObject(object, options)) {
|
||||
deletedObjects += 1;
|
||||
deletedBytes += object.sizeBytes;
|
||||
} else {
|
||||
survivors.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: budget. Oldest unreferenced objects go regardless of age;
|
||||
// referenced objects are untouchable (hard rule 2). Because age no
|
||||
// longer protects anything here, the pass re-reads the ledger for a
|
||||
// FRESH root set and skips objects touched since the sweep began —
|
||||
// both signals catch a commit that landed while pass 1 ran.
|
||||
let remainingBytes = survivors.reduce((sum, o) => sum + o.sizeBytes, 0);
|
||||
if (remainingBytes > options.maxTotalBytes) {
|
||||
const freshRefs = await options.memoryService.collectManagedRefs();
|
||||
if (freshRefs === null) {
|
||||
// Ledger became unreadable mid-run: fail closed — delete nothing
|
||||
// more. The store is still over budget, so suspension holds.
|
||||
debugLogger.debug(
|
||||
'gc: ledger unreadable at budget pass, skipping budget deletions',
|
||||
);
|
||||
} else {
|
||||
const unreferenced = survivors
|
||||
.filter((o) => !refs.has(o.sha256) && !freshRefs.has(o.sha256))
|
||||
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
for (const object of unreferenced) {
|
||||
if (remainingBytes <= options.maxTotalBytes) break;
|
||||
if (await touchedSinceListing(object, nowMs)) continue;
|
||||
if (await deleteObject(object, options)) {
|
||||
deletedObjects += 1;
|
||||
deletedBytes += object.sizeBytes;
|
||||
remainingBytes -= object.sizeBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const derivationsSuspended = remainingBytes > options.maxTotalBytes;
|
||||
if (derivationsSuspended) {
|
||||
suspendedRoots.add(rootKey);
|
||||
debugLogger.warn(
|
||||
`omni object store over budget with only referenced objects left ` +
|
||||
`(${remainingBytes} > ${options.maxTotalBytes} bytes); new policy ` +
|
||||
`derivations are suspended for this session. Raise ` +
|
||||
`omni.storage.maxTotalBytes or delete memory records you no ` +
|
||||
`longer need — either change takes effect at the next session ` +
|
||||
`start (the GC runs once per process).`,
|
||||
);
|
||||
} else {
|
||||
suspendedRoots.delete(rootKey);
|
||||
}
|
||||
|
||||
if (deletedObjects > 0) {
|
||||
debugLogger.debug(
|
||||
`gc: removed ${deletedObjects} object(s) / ${deletedBytes} bytes; ` +
|
||||
`${remainingBytes} bytes remain`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
ran: true,
|
||||
deletedObjects,
|
||||
deletedBytes,
|
||||
remainingBytes,
|
||||
derivationsSuspended,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
assertWithinByteLimit,
|
||||
assertWithinDurationLimit,
|
||||
assertWithinTokenLimit,
|
||||
effectiveMaxUploadFileBytes,
|
||||
OmniTransportGuardError,
|
||||
} from './guard.js';
|
||||
import type { RecognizedMedia } from './recognition.js';
|
||||
|
||||
function cfg(overrides: {
|
||||
maxBytes?: number;
|
||||
maxTokens?: number;
|
||||
maxDurationSeconds?: number;
|
||||
}): Config {
|
||||
return {
|
||||
getOmniMaxUploadFileBytes: vi.fn().mockReturnValue(overrides.maxBytes),
|
||||
getOmniMaxEstimatedTokens: vi.fn().mockReturnValue(overrides.maxTokens),
|
||||
getOmniMaxDurationSeconds: vi
|
||||
.fn()
|
||||
.mockReturnValue(overrides.maxDurationSeconds),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
const AUDIO_5S: RecognizedMedia = {
|
||||
modality: 'audio',
|
||||
detectedMimeType: 'audio/mpeg',
|
||||
sizeBytes: 40_000,
|
||||
metadata: { durationMs: 5000 },
|
||||
};
|
||||
|
||||
const VIDEO_8MIN: RecognizedMedia = {
|
||||
modality: 'video',
|
||||
detectedMimeType: 'video/mp4',
|
||||
sizeBytes: 36_000_000,
|
||||
metadata: { width: 852, height: 480, durationMs: 506_000, frameRate: 30 },
|
||||
};
|
||||
|
||||
describe('byte guard', () => {
|
||||
it('uses the default 1 GiB when unset or non-positive', () => {
|
||||
// Assert the literal, not the exported constant: settingsSchema.ts
|
||||
// hardcodes 1073741824 as the documented default (the DashScope
|
||||
// temporary-upload per-file cap), so a drifted constant must fail here
|
||||
// rather than ship green by comparing the implementation to itself.
|
||||
expect(effectiveMaxUploadFileBytes(cfg({}))).toBe(1024 * 1024 * 1024);
|
||||
expect(effectiveMaxUploadFileBytes(cfg({ maxBytes: 0 }))).toBe(
|
||||
1024 * 1024 * 1024,
|
||||
);
|
||||
expect(effectiveMaxUploadFileBytes(cfg({ maxBytes: -5 }))).toBe(
|
||||
1024 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects above the configured limit with an explanatory message', () => {
|
||||
expect(() =>
|
||||
assertWithinByteLimit(cfg({ maxBytes: 1000 }), 2000, 'clip.mp4'),
|
||||
).toThrow(
|
||||
/clip\.mp4.*2000 bytes > 1000 bytes.*omni\.processing\.transportGuard\.maxUploadFileBytes/,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes at or below the limit', () => {
|
||||
expect(() =>
|
||||
assertWithinByteLimit(cfg({ maxBytes: 1000 }), 1000, 'clip.mp4'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token guard', () => {
|
||||
it('disabled (unset/0) attaches the estimate but never rejects', () => {
|
||||
const est = assertWithinTokenLimit(cfg({}), VIDEO_8MIN, 'p3.mp4');
|
||||
expect(est.status).toBe('ok');
|
||||
expect(est.estimatedTokenCount).toBeGreaterThan(196_608);
|
||||
expect(
|
||||
assertWithinTokenLimit(cfg({ maxTokens: 0 }), VIDEO_8MIN, 'p3.mp4')
|
||||
.status,
|
||||
).toBe('ok');
|
||||
});
|
||||
|
||||
it('enabled: rejects above threshold with estimate, threshold, and setting name', () => {
|
||||
expect(() =>
|
||||
assertWithinTokenLimit(cfg({ maxTokens: 196_608 }), VIDEO_8MIN, 'p3.mp4'),
|
||||
).toThrow(OmniTransportGuardError);
|
||||
expect(() =>
|
||||
assertWithinTokenLimit(cfg({ maxTokens: 196_608 }), VIDEO_8MIN, 'p3.mp4'),
|
||||
).toThrow(
|
||||
/p3\.mp4.*raw-resource-v1.*196608.*omni\.processing\.transportGuard\.maxEstimatedTokens/,
|
||||
);
|
||||
});
|
||||
|
||||
it('enabled: passes small inputs (5s audio ≈ 35 tokens)', () => {
|
||||
const est = assertWithinTokenLimit(
|
||||
cfg({ maxTokens: 196_608 }),
|
||||
AUDIO_5S,
|
||||
'tone.mp3',
|
||||
);
|
||||
expect(est.estimatedTokenCount).toBe(35);
|
||||
});
|
||||
|
||||
it('unavailable estimates never reject', () => {
|
||||
const noMeta: RecognizedMedia = { ...AUDIO_5S, metadata: {} };
|
||||
const est = assertWithinTokenLimit(
|
||||
cfg({ maxTokens: 10 }),
|
||||
noMeta,
|
||||
'tone.mp3',
|
||||
);
|
||||
expect(est.status).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration guard', () => {
|
||||
const FILM_98MIN: RecognizedMedia = {
|
||||
modality: 'video',
|
||||
detectedMimeType: 'video/mp4',
|
||||
sizeBytes: 474_864_706,
|
||||
metadata: { width: 1920, height: 1040, durationMs: 5_895_767 },
|
||||
};
|
||||
|
||||
it('is disabled when unset or non-positive', () => {
|
||||
for (const c of [cfg({}), cfg({ maxDurationSeconds: 0 })]) {
|
||||
expect(() =>
|
||||
assertWithinDurationLimit(c, FILM_98MIN, 'film'),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that clears the byte limit but is still too long', () => {
|
||||
// The real failure this guard exists for: 2.44 GB downscaled to 474 MB
|
||||
// passes a 1 GiB byte ceiling, uploads, and is then refused by the
|
||||
// provider for duration — after paying for the transcode.
|
||||
const config = cfg({ maxDurationSeconds: 3600 });
|
||||
expect(() =>
|
||||
assertWithinByteLimit(config, FILM_98MIN.sizeBytes, 'film'),
|
||||
).not.toThrow();
|
||||
expect(() => assertWithinDurationLimit(config, FILM_98MIN, 'film')).toThrow(
|
||||
OmniTransportGuardError,
|
||||
);
|
||||
});
|
||||
|
||||
it('names duration as the reason and says downscaling will not help', () => {
|
||||
try {
|
||||
assertWithinDurationLimit(
|
||||
cfg({ maxDurationSeconds: 3600 }),
|
||||
FILM_98MIN,
|
||||
'film',
|
||||
);
|
||||
throw new Error('expected a guard rejection');
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
expect(message).toContain('duration limit');
|
||||
expect(message).toContain('5896s > 3600s');
|
||||
expect(message).toMatch(/Clip a shorter span/);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts media within the limit', () => {
|
||||
expect(() =>
|
||||
assertWithinDurationLimit(
|
||||
cfg({ maxDurationSeconds: 3600 }),
|
||||
VIDEO_8MIN,
|
||||
'clip',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('never rejects when the probe reported no duration', () => {
|
||||
const noDuration: RecognizedMedia = {
|
||||
modality: 'video',
|
||||
detectedMimeType: 'video/mp4',
|
||||
sizeBytes: 10,
|
||||
metadata: {},
|
||||
};
|
||||
expect(() =>
|
||||
assertWithinDurationLimit(
|
||||
cfg({ maxDurationSeconds: 1 }),
|
||||
noDuration,
|
||||
'x',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
estimateRawResourceTokens,
|
||||
type OmniTokenEstimate,
|
||||
} from './estimation.js';
|
||||
import type { RecognizedMedia } from './recognition.js';
|
||||
|
||||
/** Default per-file upload ceiling: 1 GiB (DashScope instant-upload cap). */
|
||||
export const DEFAULT_OMNI_MAX_UPLOAD_FILE_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
/** Thrown when a transport guard rejects an input. Fail closed: callers
|
||||
* surface the message; there is no silent degradation. Messages must stay
|
||||
* free of absolute paths (they can reach model-visible content). */
|
||||
export class OmniTransportGuardError extends Error {
|
||||
/**
|
||||
* Session handle for the SOURCE, when delivery had already bound one
|
||||
* before the guard's verdict (memory design M §5.2). A rejection is the
|
||||
* case where recall matters most — the model never sees the media, so
|
||||
* this handle is its only remaining route to the content — and every
|
||||
* throw site sits downstream of the bind, so dropping it would strand
|
||||
* the resource for the rest of the session.
|
||||
*/
|
||||
sessionResourceId?: string;
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = 'OmniTransportGuardError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the effective byte ceiling (undefined/<=0 config → default). */
|
||||
export function effectiveMaxUploadFileBytes(config: Config): number {
|
||||
const configured = config.getOmniMaxUploadFileBytes?.();
|
||||
return configured !== undefined && configured > 0
|
||||
? configured
|
||||
: DEFAULT_OMNI_MAX_UPLOAD_FILE_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-dimension guard. Runs on a cheap stat BEFORE any hashing/probing.
|
||||
*/
|
||||
export function assertWithinByteLimit(
|
||||
config: Config,
|
||||
sizeBytes: number,
|
||||
displayName: string,
|
||||
): void {
|
||||
const maxBytes = effectiveMaxUploadFileBytes(config);
|
||||
if (sizeBytes > maxBytes) {
|
||||
throw new OmniTransportGuardError(
|
||||
`${displayName} exceeds the omni upload limit: ${sizeBytes} bytes > ` +
|
||||
`${maxBytes} bytes (omni.processing.transportGuard.maxUploadFileBytes). ` +
|
||||
`Reduce the file size before retrying.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration-dimension guard. Runs alongside the token check (both need
|
||||
* probe metadata) and BEFORE store/upload.
|
||||
*
|
||||
* Byte and token limits alone cannot express the provider's duration cap,
|
||||
* and that gap is not theoretical: a 98-minute film downscaled to 474 MB
|
||||
* clears a 1 GB byte limit, gets uploaded, and is then rejected by the API
|
||||
* for being too long. The guard reported success, the creator got an
|
||||
* opaque 400, and a full transcode was paid for nothing. Duration is a
|
||||
* transport limit like any other, so it belongs here — where exceeding it
|
||||
* turns into an honest omission that names duration as the reason.
|
||||
*
|
||||
* Threshold semantics mirror the token guard
|
||||
* (`omni.processing.transportGuard.maxDurationSeconds`): unset / 0 /
|
||||
* negative disables it; a missing duration never rejects (metadata is
|
||||
* never guessed).
|
||||
*/
|
||||
export function assertWithinDurationLimit(
|
||||
config: Config,
|
||||
media: RecognizedMedia,
|
||||
displayName: string,
|
||||
): void {
|
||||
const maxSeconds = config.getOmniMaxDurationSeconds?.();
|
||||
if (maxSeconds === undefined || maxSeconds <= 0) return;
|
||||
const durationMs = media.metadata.durationMs;
|
||||
if (durationMs === undefined || !Number.isFinite(durationMs)) return;
|
||||
const seconds = durationMs / 1000;
|
||||
if (seconds > maxSeconds) {
|
||||
throw new OmniTransportGuardError(
|
||||
`${displayName} exceeds the omni duration limit: ` +
|
||||
`${Math.round(seconds)}s > ${maxSeconds}s ` +
|
||||
`(omni.processing.transportGuard.maxDurationSeconds). ` +
|
||||
`Clip a shorter span — downscaling cannot bring a long file ` +
|
||||
`within a duration limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-dimension guard. Runs AFTER recognition (needs probe metadata) and
|
||||
* BEFORE store/upload, so an oversized input costs one probe — not a copy
|
||||
* and a multi-minute upload.
|
||||
*
|
||||
* Threshold semantics (`omni.processing.transportGuard.maxEstimatedTokens`):
|
||||
* - unset / 0 / negative → guard disabled (the estimation formula is still
|
||||
* pending confirmation with the model provider; estimates are attached
|
||||
* for observability but must not reject until a threshold is set);
|
||||
* - positive → hard fail-closed limit against the versioned estimator.
|
||||
*
|
||||
* An `unavailable` estimate never rejects — per design, missing metadata
|
||||
* must not be guessed, and the transport guard only acts on real numbers.
|
||||
*/
|
||||
export function assertWithinTokenLimit(
|
||||
config: Config,
|
||||
media: RecognizedMedia,
|
||||
displayName: string,
|
||||
): OmniTokenEstimate {
|
||||
const estimate = estimateRawResourceTokens(media);
|
||||
const maxTokens = config.getOmniMaxEstimatedTokens?.();
|
||||
if (maxTokens === undefined || maxTokens <= 0) return estimate;
|
||||
if (estimate.status !== 'ok') return estimate;
|
||||
if (estimate.estimatedTokenCount > maxTokens) {
|
||||
throw new OmniTransportGuardError(
|
||||
`${displayName} exceeds the omni estimated-token limit: ` +
|
||||
`~${estimate.estimatedTokenCount} tokens (${estimate.method}) > ` +
|
||||
`${maxTokens} (omni.processing.transportGuard.maxEstimatedTokens). ` +
|
||||
`Reduce duration/resolution or raise the limit.`,
|
||||
);
|
||||
}
|
||||
return estimate;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { atomicWriteFile } from '../utils/atomicFileWrite.js';
|
||||
import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/** Per-cache-file operation serializer: cache instances are constructed
|
||||
* per use site, and safe-tool batches run deliveries concurrently in one
|
||||
* process — unserialized load-modify-save would drop entries. Module
|
||||
* scope is deliberate: two instances on the same file must share the
|
||||
* chain. Cross-process writes remain last-writer-wins (documented).
|
||||
* Exported for other omni-owned JSON documents with the same
|
||||
* load-modify-save discipline (media-memory store). */
|
||||
const fileOps = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function serializeFileOperation<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prev = fileOps.get(key) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn);
|
||||
const settled = run.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
fileOps.set(key, settled);
|
||||
void settled.then(() => {
|
||||
// Drop the tail once it settles — otherwise the map grows with every
|
||||
// distinct cache file touched over the process lifetime. Only delete
|
||||
// when OUR promise is still the tail: a later op may have chained on.
|
||||
if (fileOps.get(key) === settled) fileOps.delete(key);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Keep at most this many `.corrupt-*` backups (newest wins): a crash
|
||||
* loop over a corrupt file must not litter the directory without bound. */
|
||||
const MAX_CORRUPT_BACKUPS = 2;
|
||||
|
||||
interface CacheFileShape<TEntry> {
|
||||
version: 1;
|
||||
entries: Record<string, TEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared mechanics for omni's persistent JSON entry caches
|
||||
* (`upload-cache.json`, `policy-cache.json`): one flat
|
||||
* `{ version: 1, entries: {} }` file with
|
||||
*
|
||||
* - per-file serialized load-modify-save (in-process),
|
||||
* - atomic writes via `atomicWriteFile` (tmp + rename, `noFollow`; 0600
|
||||
* forced on every save, 0700 dir),
|
||||
* - corrupt files backed up as `.corrupt-<ts>` (newest
|
||||
* {@link MAX_CORRUPT_BACKUPS} kept) and rebuilt empty — never fatal,
|
||||
* - unreadable-but-existing files (EACCES, EMFILE, …) making the current
|
||||
* operation a no-op instead of an empty rebuild: a transient read
|
||||
* failure must never lead to a save that wipes every persisted entry.
|
||||
*
|
||||
* Entry semantics (keys, TTLs, invalidation) stay in the owning cache.
|
||||
*/
|
||||
export class OmniJsonCacheFile<TEntry> {
|
||||
private readonly debugLogger: DebugLogger;
|
||||
|
||||
constructor(
|
||||
readonly filePath: string,
|
||||
debugChannel: string,
|
||||
) {
|
||||
this.debugLogger = createDebugLogger(debugChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one serialized operation against the entry map. `fn` returns the
|
||||
* operation result plus whether it changed the map (triggering an
|
||||
* atomic save). When the file exists but cannot be read,
|
||||
* `unreadableResult` is returned and nothing is saved.
|
||||
*/
|
||||
async access<R>(
|
||||
unreadableResult: R,
|
||||
fn: (
|
||||
entries: Record<string, TEntry>,
|
||||
) =>
|
||||
| { result: R; changed?: boolean }
|
||||
| Promise<{ result: R; changed?: boolean }>,
|
||||
): Promise<R> {
|
||||
return serializeFileOperation(this.filePath, async () => {
|
||||
const data = await this.load();
|
||||
if (!data) return unreadableResult;
|
||||
const { result, changed } = await fn(data.entries);
|
||||
if (changed) await this.save(data);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache file. Returns null when the file exists but could not
|
||||
* be read (EACCES, EMFILE, …): the caller must skip its operation for
|
||||
* this call — proceeding with an empty snapshot and later saving it
|
||||
* would overwrite N valid entries with one (self-inflicted cache wipe).
|
||||
* Only a genuinely missing file means empty-and-writable.
|
||||
*/
|
||||
private async load(): Promise<CacheFileShape<TEntry> | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(this.filePath, 'utf8');
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
// ENOENT: no cache file yet (POSIX and Windows). ENOTDIR: a parent
|
||||
// path component is a plain file — POSIX raises ENOTDIR where
|
||||
// Windows reports ENOENT for the same condition.
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
this.debugLogger.debug(
|
||||
`cache read failed, operation skipped: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CacheFileShape<TEntry>;
|
||||
// `entries` must be a plain non-null object: `typeof null` and
|
||||
// `typeof []` are both 'object', and either shape would throw raw
|
||||
// TypeErrors from every accessor (escaping the never-fatal contract
|
||||
// and skipping backup+rebuild).
|
||||
if (
|
||||
parsed?.version === 1 &&
|
||||
typeof parsed.entries === 'object' &&
|
||||
parsed.entries !== null &&
|
||||
!Array.isArray(parsed.entries)
|
||||
) {
|
||||
// Entry VALUES must be plain non-null non-array objects too: the
|
||||
// file is edited/shipped by hand (workspace caches), and a value
|
||||
// like `null` or `"x"` would surface as raw TypeErrors from the
|
||||
// owning cache's field accessors (`v.expiresAt`,
|
||||
// `v.degradedSha256`, …) — escaping the never-fatal contract.
|
||||
// Malformed values are pruned individually (cheap re-work for
|
||||
// just those keys) instead of condemning the whole file.
|
||||
let pruned = 0;
|
||||
for (const [k, v] of Object.entries(parsed.entries)) {
|
||||
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
||||
delete parsed.entries[k];
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
this.debugLogger.debug(
|
||||
`dropped ${pruned} malformed cache entr${pruned === 1 ? 'y' : 'ies'} from ${this.filePath}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
throw new Error('unexpected shape');
|
||||
} catch {
|
||||
// Corrupt cache: preserve for inspection, start fresh. Losing a
|
||||
// cache only costs re-work — never fail the pipeline over it.
|
||||
const backup = `${this.filePath}.corrupt-${Date.now()}`;
|
||||
await fs.rename(this.filePath, backup).catch(() => {});
|
||||
await this.pruneCorruptBackups();
|
||||
this.debugLogger.debug(`corrupt cache backed up to ${backup}`);
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort: keep only the newest {@link MAX_CORRUPT_BACKUPS}. */
|
||||
private async pruneCorruptBackups(): Promise<void> {
|
||||
const dir = path.dirname(this.filePath);
|
||||
const prefix = `${path.basename(this.filePath)}.corrupt-`;
|
||||
try {
|
||||
const backups = (await fs.readdir(dir))
|
||||
.filter((n) => n.startsWith(prefix))
|
||||
// Millisecond timestamps are fixed-width for centuries, so the
|
||||
// lexicographic sort is chronological; newest first.
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const name of backups.slice(MAX_CORRUPT_BACKUPS)) {
|
||||
await fs.rm(path.join(dir, name), { force: true }).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Pruning is hygiene; never let it affect the read path.
|
||||
}
|
||||
}
|
||||
|
||||
private async save(data: CacheFileShape<TEntry>): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.filePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
await atomicWriteFile(this.filePath, JSON.stringify(data, null, 1), {
|
||||
mode: 0o600,
|
||||
forceMode: true,
|
||||
// Rename-replacement semantics: a symlink planted at the cache
|
||||
// path is REPLACED by the rename, never written through — without
|
||||
// this the default symlink resolution would redirect the write
|
||||
// (and the 0600 chmod) onto the link's target.
|
||||
noFollow: true,
|
||||
});
|
||||
} catch (err) {
|
||||
// Cache persistence is best-effort by design.
|
||||
this.debugLogger.debug(
|
||||
`cache write failed: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import { buildOmniMediaGuidanceSection } from './media-guidance.js';
|
||||
import type { OmniUploadConfig } from './upload-config.js';
|
||||
import {
|
||||
OMNI_DISCLOSURE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_HANDLE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_PATH_TEXT_PREFIX,
|
||||
OMNI_OMISSION_TEXT_PREFIX,
|
||||
OMNI_TRANSCRIPT_TEXT_PREFIX,
|
||||
} from './disclosure.js';
|
||||
|
||||
const DASHSCOPE_CGC = {
|
||||
authType: AuthType.USE_OPENAI,
|
||||
apiKey: 'sk-real-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
};
|
||||
|
||||
function stubConfig(overrides?: {
|
||||
omniEnabled?: boolean;
|
||||
cgc?: Record<string, unknown>;
|
||||
policyTools?: Record<string, unknown>;
|
||||
recallMode?: 'active' | 'sideQuery';
|
||||
upload?: OmniUploadConfig;
|
||||
fixedPolicies?: unknown[];
|
||||
}): Config {
|
||||
return {
|
||||
isOmniEnabled: vi.fn().mockReturnValue(overrides?.omniEnabled ?? true),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue(overrides?.cgc ?? DASHSCOPE_CGC),
|
||||
getModel: vi.fn().mockReturnValue('qwen3.5-omni-plus'),
|
||||
getOmniUploadConfig: vi.fn().mockReturnValue(overrides?.upload),
|
||||
getOmniPolicyToolsSettings: vi.fn().mockReturnValue(overrides?.policyTools),
|
||||
getOmniProcessingConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
overrides?.fixedPolicies
|
||||
? { fixedPolicies: overrides.fixedPolicies }
|
||||
: undefined,
|
||||
),
|
||||
getOmniMemoryConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
overrides?.recallMode
|
||||
? { recall: { mode: overrides.recallMode } }
|
||||
: undefined,
|
||||
),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
/** A normalized-policy stub carrying the fields the guidance collector
|
||||
* reads: id + priority for ordering, description for the prompt bullet. */
|
||||
function policy(
|
||||
id: string,
|
||||
priority: number,
|
||||
description?: string,
|
||||
): Record<string, unknown> {
|
||||
return { id, priority, mediaTypes: ['video'], output: {}, description };
|
||||
}
|
||||
|
||||
describe('buildOmniMediaGuidanceSection', () => {
|
||||
it('returns null when omni is disabled', () => {
|
||||
expect(
|
||||
buildOmniMediaGuidanceSection(stubConfig({ omniEnabled: false })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the delivery gate rejects the provider', () => {
|
||||
expect(
|
||||
buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
cgc: { ...DASHSCOPE_CGC, baseUrl: 'https://api.openai.com/v1' },
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('includes guidance for custom inference with a dedicated upload channel', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
cgc: {
|
||||
authType: AuthType.USE_OPENAI,
|
||||
apiKey: 'inference-key',
|
||||
baseUrl: 'http://127.0.0.1:22002/v1',
|
||||
},
|
||||
upload: {
|
||||
apiKey: 'upload-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
model: 'qwen3.5-omni-plus',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section).toContain('Media Delivery');
|
||||
});
|
||||
|
||||
it('explains all three disclosure markers and the progressive contract', () => {
|
||||
const section = buildOmniMediaGuidanceSection(stubConfig());
|
||||
expect(section).toContain(OMNI_DISCLOSURE_TEXT_PREFIX);
|
||||
expect(section).toContain(OMNI_OMISSION_TEXT_PREFIX);
|
||||
expect(section).toContain(OMNI_TRANSCRIPT_TEXT_PREFIX);
|
||||
expect(section).toContain('progressive-understanding');
|
||||
// The two behavioral pillars: overview-not-complete + no extrapolation.
|
||||
expect(section).toContain('not the complete content');
|
||||
expect(section).toContain('Never conclude');
|
||||
});
|
||||
|
||||
it('with no tools enabled, instructs stating missing evidence instead of listing tools', () => {
|
||||
const section = buildOmniMediaGuidanceSection(stubConfig())!;
|
||||
expect(section).toContain('No media tools are enabled');
|
||||
expect(section).not.toContain('Available media tools');
|
||||
expect(section).not.toContain(ToolNames.OMNI_CLIP_VIDEO);
|
||||
});
|
||||
|
||||
it('lists exactly the modelAccess-enabled tools', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
policyTools: {
|
||||
[ToolNames.OMNI_CLIP_VIDEO]: { modelAccess: { enabled: true } },
|
||||
[ToolNames.OMNI_EXTRACT_KEYFRAMES]: {
|
||||
modelAccess: { enabled: true },
|
||||
},
|
||||
// Present but not enabled — must not be listed.
|
||||
[ToolNames.OMNI_TRANSCRIBE_AUDIO]: {
|
||||
modelAccess: { enabled: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)!;
|
||||
expect(section).toContain('Available media tools');
|
||||
expect(section).toContain(ToolNames.OMNI_CLIP_VIDEO);
|
||||
expect(section).toContain(ToolNames.OMNI_EXTRACT_KEYFRAMES);
|
||||
expect(section).not.toContain(ToolNames.OMNI_TRANSCRIBE_AUDIO);
|
||||
expect(section).not.toContain('No media tools are enabled');
|
||||
// Tool-usage direction present when tools are available.
|
||||
expect(section).toContain('fetch the evidence yourself');
|
||||
});
|
||||
|
||||
it('tolerates malformed policyTools settings (fail-closed per tool)', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
policyTools: {
|
||||
[ToolNames.OMNI_CLIP_VIDEO]: 'not-an-object',
|
||||
[ToolNames.OMNI_EXTRACT_KEYFRAMES]: { modelAccess: 'nope' },
|
||||
},
|
||||
}),
|
||||
)!;
|
||||
expect(section).toContain('No media tools are enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOmniMediaGuidanceSection — policy descriptions', () => {
|
||||
it('injects a described policy verbatim into the prompt', () => {
|
||||
const desc =
|
||||
'Keyframe degradation triggers only when estimated tokens > 10000; ' +
|
||||
'at or below that a clip is delivered at native resolution.';
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({ fixedPolicies: [policy('movie-keyframes', 60, desc)] }),
|
||||
)!;
|
||||
expect(section).toContain('automatic preprocessing policies');
|
||||
expect(section).toContain(desc);
|
||||
});
|
||||
|
||||
it('omits the block entirely when no policy carries a description', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
fixedPolicies: [
|
||||
policy('movie-keyframes', 60),
|
||||
policy('movie-extract-audio', 100),
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
expect(section).not.toContain('automatic preprocessing policies');
|
||||
});
|
||||
|
||||
it('orders descriptions by priority desc then id, and skips undescribed policies', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({
|
||||
fixedPolicies: [
|
||||
policy('movie-keyframes', 60, 'LOW-PRIORITY-DESC'),
|
||||
policy('undescribed', 200), // no description → contributes nothing
|
||||
policy('movie-extract-audio', 100, 'HIGH-PRIORITY-DESC'),
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
expect(section).toContain('HIGH-PRIORITY-DESC');
|
||||
expect(section).toContain('LOW-PRIORITY-DESC');
|
||||
// Higher priority (100) must precede lower (60).
|
||||
expect(section.indexOf('HIGH-PRIORITY-DESC')).toBeLessThan(
|
||||
section.indexOf('LOW-PRIORITY-DESC'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOmniMediaGuidanceSection — recall guidance', () => {
|
||||
it('explains the resource marker and the recall-before-reprocessing contract in active mode', () => {
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({ recallMode: 'active' }),
|
||||
)!;
|
||||
// The annotation ships with every memory-known delivery, but the tool
|
||||
// that consumes it is deferred — so without this the model sees the
|
||||
// marker with no explanation and reprocesses what memory already holds.
|
||||
expect(section).toContain(OMNI_RESOURCE_HANDLE_TEXT_PREFIX);
|
||||
expect(section).toContain(OMNI_RESOURCE_PATH_TEXT_PREFIX);
|
||||
expect(section).toContain('omni_recall_media_memory');
|
||||
expect(section).toMatch(/BEFORE reprocessing/);
|
||||
// It must describe BOTH annotation forms: the absolute path for a local
|
||||
// file the model read, and the opaque handle for path-less media.
|
||||
expect(section).toMatch(/absolute path/);
|
||||
expect(section).toMatch(/opaque session handle/);
|
||||
});
|
||||
|
||||
it('says nothing about the recall tool in sideQuery mode', () => {
|
||||
// D10: in sideQuery mode the harness injects recalled memory itself and
|
||||
// the tool is not even registered — telling the model to call it would
|
||||
// invite a guaranteed unknown-tool error. But the REFERENCE guidance still
|
||||
// ships in BOTH annotation forms (R3-6): the media tools below take a
|
||||
// path/handle regardless of recall mode, so the model needs this to use
|
||||
// them even though the recall tool is absent.
|
||||
const section = buildOmniMediaGuidanceSection(
|
||||
stubConfig({ recallMode: 'sideQuery' }),
|
||||
)!;
|
||||
expect(section).not.toContain('omni_recall_media_memory');
|
||||
// Both reference forms are explained without naming the recall tool.
|
||||
expect(section).toContain(OMNI_RESOURCE_HANDLE_TEXT_PREFIX);
|
||||
expect(section).toContain(OMNI_RESOURCE_PATH_TEXT_PREFIX);
|
||||
expect(section).toMatch(/absolute path/);
|
||||
expect(section).toMatch(/opaque session handle/);
|
||||
});
|
||||
|
||||
it('says nothing about recall when memory is not configured', () => {
|
||||
const section = buildOmniMediaGuidanceSection(stubConfig())!;
|
||||
expect(section).not.toContain('omni_recall_media_memory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Progressive media understanding guidance (system-prompt layer).
|
||||
*
|
||||
* The delivery pipeline degrades media to fit transport/context limits and
|
||||
* discloses each transformation next to the media Part (decision D8). The
|
||||
* disclosures state WHAT was done; this module supplies the missing WHY
|
||||
* and the follow-up contract: degradation is a context-budget-driven
|
||||
* progressive-understanding strategy, the degraded delivery is an
|
||||
* overview/entry point rather than the complete content, and the model
|
||||
* should proactively fetch higher-fidelity / more complete evidence with
|
||||
* the media policy tools when the task needs it.
|
||||
*
|
||||
* Injected once per session as a STABLE system-prompt layer (see
|
||||
* client.ts getMainSessionSystemInstruction): the disclosure texts arrive
|
||||
* mid-conversation at unpredictable points, so per-delivery preambles
|
||||
* would repeat across three assembly sites (atCommandProcessor,
|
||||
* fileUtils, tool-result-media) and would still miss the reactive
|
||||
* server-reject swaps; one durable contract in the prompt covers all of
|
||||
* them. Kept as a leaf module (no pipeline imports) so prompt assembly
|
||||
* stays lightweight.
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import { isOmniDeliveryActive } from './delivery-gate.js';
|
||||
import { resolveMediaPolicyModelAccess } from './policy/model-access.js';
|
||||
import {
|
||||
OMNI_DISCLOSURE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_HANDLE_TEXT_PREFIX,
|
||||
OMNI_RESOURCE_PATH_TEXT_PREFIX,
|
||||
OMNI_OMISSION_TEXT_PREFIX,
|
||||
OMNI_TRANSCRIPT_TEXT_PREFIX,
|
||||
} from './disclosure.js';
|
||||
|
||||
/** One-line capability summaries for the model-callable media tools.
|
||||
* Deliberately terse: the full parameter schema ships with the tool
|
||||
* declaration; this list only tells the model WHEN to reach for each. */
|
||||
const MEDIA_TOOL_CAPABILITIES: ReadonlyArray<[string, string]> = [
|
||||
[
|
||||
ToolNames.OMNI_CLIP_VIDEO,
|
||||
'cut a specific time range out of a video (startSec/durationSec) — the primary way to inspect parts of a long video that were not delivered',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_UNDERSTAND_VIDEO_SEGMENTS,
|
||||
'understand a long video end to end: fixed-length segments described in parallel by an omni model, assembled into a time-labeled text summary',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_EXTRACT_KEYFRAMES,
|
||||
'extract still frames from a video (clip a range first to sample frames from a specific segment)',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_DOWNSCALE_VIDEO,
|
||||
're-encode a video at lower resolution / frame rate to fit transport limits',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_EXTRACT_AUDIO,
|
||||
'extract the audio track from a video for listening or transcription',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_TRANSCRIBE_AUDIO,
|
||||
'transcribe speech in an audio file to text',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CAPTION_AUDIO,
|
||||
'describe an audio file semantically (speech gist, timbre, sound events, mood) — understanding, not verbatim transcription',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CLIP_AUDIO,
|
||||
'cut a specific time range out of an audio file (startMs/durationMs)',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_DOWNSAMPLE_AUDIO,
|
||||
're-encode audio at a lower bitrate/sample rate',
|
||||
],
|
||||
[ToolNames.OMNI_DOWNSAMPLE_IMAGE, 'shrink an image to a smaller resolution'],
|
||||
[ToolNames.OMNI_CONVERT_IMAGE, 'convert an image to another format'],
|
||||
[
|
||||
ToolNames.OMNI_CLIP_IMAGE,
|
||||
'crop a pixel rectangle (x/y/width/height) out of an image',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_CAPTION_IMAGE,
|
||||
'describe an image with a VL model under a custom prompt, turning visual content into text',
|
||||
],
|
||||
[
|
||||
ToolNames.OMNI_OCR_IMAGE,
|
||||
'extract the text visible in an image (OCR), with automatic language detection',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Model-facing descriptions of the configured fixed policies, in the order
|
||||
* they run (priority desc, id asc — the orchestrator's own ordering), each
|
||||
* a single bullet. A policy contributes only when its config carries a
|
||||
* `description`; policies without one stay silent. Empty when no policy is
|
||||
* described, so the guidance section omits the block entirely.
|
||||
*
|
||||
* This lets the active preprocessing contract (e.g. the token threshold
|
||||
* below which a video is delivered un-degraded, and how to stay under it)
|
||||
* be authored right next to the policy in settings — like a tool
|
||||
* description — and flow into the prompt automatically, rather than being
|
||||
* restated in this module.
|
||||
*/
|
||||
function collectPolicyDescriptions(config: Config): string[] {
|
||||
const policies = config.getOmniProcessingConfig?.()?.fixedPolicies;
|
||||
if (!policies) return [];
|
||||
return [...policies]
|
||||
.sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id))
|
||||
.map((p) => p.description)
|
||||
.filter((d): d is string => typeof d === 'string' && d.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the progressive media understanding section for the system
|
||||
* prompt, or `null` when omni delivery is inactive (no disclosures will
|
||||
* ever reach the model, so the contract would be noise).
|
||||
*
|
||||
* The tool list only names tools whose
|
||||
* `omni.processing.policyTools.<name>.modelAccess.enabled` is true —
|
||||
* directing the model at tools the scheduler would reject teaches it a
|
||||
* dead end. With no tools enabled, the section instead instructs the
|
||||
* model to state what evidence is missing rather than extrapolate.
|
||||
*/
|
||||
export function buildOmniMediaGuidanceSection(config: Config): string | null {
|
||||
if (!isOmniDeliveryActive(config)) return null;
|
||||
|
||||
const enabledTools = MEDIA_TOOL_CAPABILITIES.filter(
|
||||
([name]) => resolveMediaPolicyModelAccess(config, name).enabled,
|
||||
);
|
||||
|
||||
// How the model references delivered media. Emitted in BOTH recall modes:
|
||||
// a 【媒体路径】 / 【媒体资源】 marker rides every memory-known delivery,
|
||||
// and the media tools below take a path/handle regardless of recall mode,
|
||||
// so the model needs this to use them. (Its absence in sideQuery mode left
|
||||
// the stale per-tool schema text as the model's only instruction.)
|
||||
const referenceGuidance = `
|
||||
- ${OMNI_RESOURCE_PATH_TEXT_PREFIX}<absolute path>: how a local file you read is referenced. The path is shown VERBATIM — byte-for-byte the real on-disk path — so you can re-read it or point the media tools at it directly.
|
||||
- ${OMNI_RESOURCE_HANDLE_TEXT_PREFIX}<name>:<handle>: how media with no path you can see (tool results, URLs, recalled memory) is referenced — <handle> is an opaque session handle.
|
||||
- Pass whichever reference you were shown to the media tools below: an absolute path as \`inputPath\`, an opaque session handle as \`resourceId\`.`;
|
||||
|
||||
// The active recall tool is the ONE consumer that is mode-specific: it is
|
||||
// deferred (surfaces via ToolSearch), so without this the model can receive
|
||||
// 【媒体路径】 / 【媒体资源】 markers and — told by the rest of this section
|
||||
// to gather evidence with the policy tools — reprocess from scratch what
|
||||
// memory already holds. Only stated when recall.mode is 'active': in
|
||||
// sideQuery mode the harness injects recalled memory itself and the model
|
||||
// must not call it.
|
||||
const recallGuidance =
|
||||
config.getOmniMemoryConfig?.()?.recall.mode === 'active'
|
||||
? `
|
||||
- BEFORE reprocessing media (extracting frames, transcribing, clipping), call \`${ToolNames.OMNI_RECALL_MEDIA_MEMORY}\` with that <ref>: earlier sessions may already have produced the transcript, keyframes or excerpt you need, and it returns instantly. It also reports honest gaps — which channels were never processed — and can suggest which tool closes them.`
|
||||
: '';
|
||||
|
||||
// Active preprocessing contract, authored per policy in settings and
|
||||
// collected here (like tool descriptions): each configured policy that
|
||||
// carries a `description` explains what it does / when it triggers, so
|
||||
// the model can work with the pipeline (e.g. shrink a clip under the
|
||||
// native-delivery token threshold) instead of guessing at it.
|
||||
const policyDescriptions = collectPolicyDescriptions(config);
|
||||
const policyGuidance =
|
||||
policyDescriptions.length > 0
|
||||
? `
|
||||
|
||||
The media reaching you is shaped by these automatic preprocessing policies (they run in this order):
|
||||
${policyDescriptions.map((d) => `- ${d}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const toolGuidance =
|
||||
enabledTools.length > 0
|
||||
? `- When the task needs evidence beyond what was delivered — later time ranges, finer visual detail, more frames, a fuller transcript — do not stop at the delivered subset: fetch the evidence yourself with the media tools below, then read the produced file(s) to bring them into context. Work in targeted excerpts (a specific time range or region at a time) so each request stays within limits, and iterate until you have seen enough to complete the task.
|
||||
- Available media tools:
|
||||
${enabledTools.map(([name, capability]) => ` - ${name}: ${capability}`).join('\n')}`
|
||||
: `- No media tools are enabled in this session. When the delivered evidence does not cover what the task needs, say explicitly which part of the media you could not observe instead of extrapolating from the delivered subset.`;
|
||||
|
||||
return `# Media Delivery (Progressive Understanding)
|
||||
|
||||
Media files in this session reach you through a preprocessing pipeline that must fit them into transport and context-window limits. Large or long media is therefore delivered in reduced form on purpose: this is a progressive-understanding strategy — you first get an affordable overview, then fetch the specific higher-fidelity evidence the task needs. Every transformation is disclosed in a text part placed immediately BEFORE the media it describes:
|
||||
|
||||
- ${OMNI_DISCLOSURE_TEXT_PREFIX}<file>: the adjacent media is a degraded derivative (clipped, downscaled, resampled, or keyframes); the marker states exactly what was reduced.
|
||||
- ${OMNI_OMISSION_TEXT_PREFIX}<file>: the media could not be delivered at all; the notice stands in its place.
|
||||
- ${OMNI_TRANSCRIPT_TEXT_PREFIX}<file>: text derived from the media (e.g. a speech transcript), possibly delivered instead of the media itself.
|
||||
${referenceGuidance}${recallGuidance}
|
||||
|
||||
Interpret delivered media under this contract:
|
||||
|
||||
- A degraded delivery is an OVERVIEW or entry point, not the complete content. The original file on disk is untouched and remains fully available for further processing.
|
||||
- Never conclude that content outside the delivered portion does not exist, and never present conclusions drawn from a partial delivery as covering the whole file. Read each disclosure quantitatively: a clip marker covering [0s–600s] of a 4882s video means 4282s exist that you have NOT seen; a keyframe marker tells you which timestamps you actually saw.${policyGuidance}
|
||||
${toolGuidance}`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import {
|
||||
MediaMemoryService,
|
||||
MediaMemoryRecallService,
|
||||
type MediaMemoryNextPolicyAction,
|
||||
type MediaMemoryRecallAdvisor,
|
||||
} from '../services/media-memory/index.js';
|
||||
import { formatResourceHandleText } from './disclosure.js';
|
||||
import { resolveMediaPolicyModelAccess } from './policy/model-access.js';
|
||||
import { OmniObjectStore } from './storage.js';
|
||||
import type { MediaChannel } from '../services/media-memory/index.js';
|
||||
import type { OmniModality } from './recognition.js';
|
||||
|
||||
/**
|
||||
* Session wiring for media-memory recall (M §9): both recall surfaces —
|
||||
* the active `omni_recall_media_memory` tool and the passive sideQuery
|
||||
* selector — build their service through this one module, so the advisor
|
||||
* and root-dir derivation cannot drift between them.
|
||||
*/
|
||||
|
||||
/** First evidence-gathering step for one uncovered channel of a media
|
||||
* type. Only DIRECT steps are suggested (a video's speech_text gap
|
||||
* suggests extracting the audio track, not the audio-input transcribe
|
||||
* tool — the model chains from there). */
|
||||
const GAP_STEP: ReadonlyArray<{
|
||||
mediaType: OmniModality;
|
||||
channels: readonly MediaChannel[];
|
||||
toolName: string;
|
||||
reason: string;
|
||||
}> = [
|
||||
{
|
||||
mediaType: 'video',
|
||||
channels: ['visual'],
|
||||
toolName: ToolNames.OMNI_EXTRACT_KEYFRAMES,
|
||||
reason: 'no visual evidence collected yet: extract keyframes',
|
||||
},
|
||||
{
|
||||
mediaType: 'video',
|
||||
// 'acoustic' ONLY. Extracting the track covers `acoustic`, which
|
||||
// leaves `speech_text` open — so matching on speech_text too made the
|
||||
// advisor re-suggest extraction in the very payload that returns the
|
||||
// extracted audio. A wholly unprocessed video still matches (its gap
|
||||
// contains acoustic) and the model chains to transcription from there.
|
||||
channels: ['acoustic'],
|
||||
toolName: ToolNames.OMNI_EXTRACT_AUDIO,
|
||||
reason: 'no audio-track evidence collected yet: extract the audio track',
|
||||
},
|
||||
{
|
||||
mediaType: 'audio',
|
||||
channels: ['speech_text'],
|
||||
toolName: ToolNames.OMNI_TRANSCRIBE_AUDIO,
|
||||
reason: 'no transcript collected yet: transcribe the audio',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Advisor mapping recall gaps to `nextPolicyActions` (M §9.4): only tools
|
||||
* that are BOTH registered in this session and opened to the model via
|
||||
* `modelAccess.enabled` are ever suggested — recall must not steer the
|
||||
* model into calls the media-policy gate would reject.
|
||||
*/
|
||||
export function buildMediaMemoryRecallAdvisor(
|
||||
config: Config,
|
||||
): MediaMemoryRecallAdvisor {
|
||||
const callable = (toolName: string): boolean =>
|
||||
config.getToolRegistry().getTool(toolName) !== undefined &&
|
||||
resolveMediaPolicyModelAccess(config, toolName).enabled;
|
||||
|
||||
return ({ resourceId, mediaType, gap }) => {
|
||||
// Nothing can be gathered from a file that is gone.
|
||||
if (gap.reason === 'artifact_unavailable') return [];
|
||||
// `partial_coverage` means sampled evidence already exists, and by
|
||||
// design it stays sampled: keyframes deliberately never report
|
||||
// complete visual coverage. Suggesting the step that produced it would
|
||||
// advise work that CANNOT close the gap, forever.
|
||||
if (gap.reason === 'partial_coverage') return [];
|
||||
const actions: MediaMemoryNextPolicyAction[] = [];
|
||||
for (const step of GAP_STEP) {
|
||||
if (step.mediaType !== mediaType) continue;
|
||||
if (!gap.channels.some((c) => step.channels.includes(c))) continue;
|
||||
if (!callable(step.toolName)) continue;
|
||||
actions.push({
|
||||
toolName: step.toolName,
|
||||
resourceId,
|
||||
arguments: {},
|
||||
reason: step.reason,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session recall service, or undefined when media memory is not
|
||||
* configured on this Config (omni off, initialize skipped, stub configs).
|
||||
*/
|
||||
export function createMediaMemoryRecallService(
|
||||
config: Config,
|
||||
): MediaMemoryRecallService | undefined {
|
||||
const memoryConfig = config.getOmniMemoryConfig?.();
|
||||
if (!memoryConfig) return undefined;
|
||||
const omniRootDir = new OmniObjectStore(
|
||||
config.storage.getQwenDir(),
|
||||
).getOmniRootDir();
|
||||
return new MediaMemoryRecallService(
|
||||
omniRootDir,
|
||||
memoryConfig.recall,
|
||||
config.getOmniMediaResourceRegistry(),
|
||||
{ advise: buildMediaMemoryRecallAdvisor(config) },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor a remembered file into this session (design M §9.2.1).
|
||||
*
|
||||
* A handle is only minted at delivery, and delivery needs the bytes — so a
|
||||
* file memory knows about but that is gone from disk had no way back into
|
||||
* a session, and its transcripts and keyframes were stranded. The same
|
||||
* friction hits an audit that does not want to re-deliver a 2.4 GB film
|
||||
* just to ask what work was recorded: without a handle, recall rejects
|
||||
* everything, and the natural next move (passing the filename as a
|
||||
* resourceId) is correctly refused.
|
||||
*
|
||||
* The user's own `@`-reference is the authorization here — the same
|
||||
* authorization a normal delivery carries — so this mints a handle from
|
||||
* the RECORDED identity without needing the bytes. Returns undefined when
|
||||
* memory is off or has never seen this locator.
|
||||
*/
|
||||
export function reanchorRememberedMedia(
|
||||
config: Config,
|
||||
absolutePath: string,
|
||||
): Promise<{ resourceId: string; annotation: string } | undefined> {
|
||||
const memoryConfig = config.getOmniMemoryConfig?.();
|
||||
if (!memoryConfig) return Promise.resolve(undefined);
|
||||
const registry = config.getOmniMediaResourceRegistry?.();
|
||||
if (!registry) return Promise.resolve(undefined);
|
||||
const store = new OmniObjectStore(config.storage.getQwenDir());
|
||||
return new MediaMemoryService(store.getOmniRootDir())
|
||||
.findBindingByFileRef(absolutePath)
|
||||
.then((found) => {
|
||||
if (!found) return undefined;
|
||||
const { resourceId } = registry.bind({
|
||||
...found.binding,
|
||||
fileRef: absolutePath,
|
||||
mediaType: found.mediaType,
|
||||
});
|
||||
return {
|
||||
resourceId,
|
||||
annotation: formatResourceHandleText(
|
||||
path.basename(absolutePath),
|
||||
resourceId,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_OMNI_MEMORY_CONFIG,
|
||||
MediaMemoryService,
|
||||
MediaResourceRegistry,
|
||||
} from '../services/media-memory/index.js';
|
||||
import {
|
||||
formatResourceHandleText,
|
||||
formatResourcePathText,
|
||||
} from './disclosure.js';
|
||||
import {
|
||||
extractRequestResourceIds,
|
||||
formatOmniMemorySideQueryReminder,
|
||||
runOmniMemorySideQuery,
|
||||
} from './memory-side-query.js';
|
||||
|
||||
vi.mock('../utils/sideQuery.js', () => ({ runSideQuery: vi.fn() }));
|
||||
import { runSideQuery } from '../utils/sideQuery.js';
|
||||
|
||||
const runSideQueryMock = vi.mocked(runSideQuery);
|
||||
|
||||
/** The slice of the selector's `runSideQuery` options these tests inspect —
|
||||
* everything the production caller is responsible for composing. */
|
||||
interface SelectorOptions {
|
||||
contents: Content[];
|
||||
abortSignal: AbortSignal;
|
||||
promptId?: string;
|
||||
validate?: (response: { entryIds: unknown }) => string | null;
|
||||
}
|
||||
|
||||
/** What the harness puts in front of the selector model. */
|
||||
interface SelectorPayload {
|
||||
request: string;
|
||||
candidates: Array<{ entryId: string }>;
|
||||
}
|
||||
|
||||
function parseSelectorPayload(options: SelectorOptions): SelectorPayload {
|
||||
return JSON.parse(
|
||||
(options.contents[0]!.parts![0] as { text: string }).text,
|
||||
) as SelectorPayload;
|
||||
}
|
||||
|
||||
/** Stand in for `runSideQuery` the way production behaves: the parsed model
|
||||
* response is run through the caller's `validate` closure and its message
|
||||
* is THROWN (sideQuery.ts). Mocks that only read `options.contents` and
|
||||
* return a selection never touch `validate`, so a broken membership/budget
|
||||
* check would go unnoticed even though it rejects every real selection. */
|
||||
function mockSelector(
|
||||
respond: (payload: SelectorPayload) => { entryIds: unknown[] },
|
||||
): void {
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
const response = respond(parseSelectorPayload(options));
|
||||
const rejection = options.validate?.(response);
|
||||
if (rejection) throw new Error(rejection);
|
||||
return response;
|
||||
}) as never);
|
||||
}
|
||||
|
||||
describe('omni memory sideQuery selector', () => {
|
||||
let tmpDir: string;
|
||||
let registry: MediaResourceRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-sidequery-'));
|
||||
registry = new MediaResourceRegistry();
|
||||
runSideQueryMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
type SideQuerySettings =
|
||||
(typeof DEFAULT_OMNI_MEMORY_CONFIG)['recall']['sideQuery'];
|
||||
|
||||
function memoryConfig(
|
||||
mode: 'active' | 'sideQuery',
|
||||
sideQuery?: Partial<SideQuerySettings>,
|
||||
) {
|
||||
return {
|
||||
...DEFAULT_OMNI_MEMORY_CONFIG,
|
||||
recall: {
|
||||
...DEFAULT_OMNI_MEMORY_CONFIG.recall,
|
||||
mode,
|
||||
sideQuery: {
|
||||
...DEFAULT_OMNI_MEMORY_CONFIG.recall.sideQuery,
|
||||
...sideQuery,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sideQueryConfig(
|
||||
mode: 'active' | 'sideQuery' = 'sideQuery',
|
||||
sideQuery?: Partial<SideQuerySettings>,
|
||||
): Config {
|
||||
return {
|
||||
getOmniMemoryConfig: () => memoryConfig(mode, sideQuery),
|
||||
getOmniMediaResourceRegistry: () => registry,
|
||||
storage: { getQwenDir: () => tmpDir },
|
||||
getToolRegistry: () => ({ getTool: () => undefined }),
|
||||
getOmniPolicyToolsSettings: () => undefined,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
/** Record one image into the store under `fileRef` and bind its session
|
||||
* handle. `sha256` is parameterized so callers can mint a distinct file
|
||||
* VERSION at the same path. */
|
||||
async function recordAndBindFileRef(
|
||||
fileRef: string,
|
||||
sha256: string = 'a'.repeat(64),
|
||||
): Promise<string> {
|
||||
const memory = new MediaMemoryService(path.join(tmpDir, 'omni'));
|
||||
const binding = await memory.recordFileRecognized({
|
||||
fileRef,
|
||||
sha256,
|
||||
mediaType: 'image',
|
||||
metadata: { width: 32, height: 32 },
|
||||
sizeBytes: 1234,
|
||||
mimeType: 'image/png',
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: path.basename(fileRef) },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: 'omni-sniff-ffprobe/1',
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
return registry.bind({
|
||||
...binding!,
|
||||
fileRef,
|
||||
mediaType: 'image',
|
||||
}).resourceId;
|
||||
}
|
||||
|
||||
/** Record one image into the store and bind its session handle. */
|
||||
function recordAndBind(): Promise<string> {
|
||||
return recordAndBindFileRef(path.join(tmpDir, 'pic.png'));
|
||||
}
|
||||
|
||||
describe('extractRequestResourceIds', () => {
|
||||
it('collects issued handles from annotation lines, deduplicated', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
const parts = [
|
||||
'plain user text',
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
{
|
||||
text:
|
||||
'context\n' +
|
||||
formatResourceHandleText('pic.png', resourceId) +
|
||||
'\nmore',
|
||||
},
|
||||
];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores handles this session never issued', () => {
|
||||
const parts = [
|
||||
{ text: formatResourceHandleText('ghost.png', 'media-7-abcdef01') },
|
||||
];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([]);
|
||||
});
|
||||
|
||||
it('recovers the handle from a path-form annotation via resolveByFileRef', async () => {
|
||||
// Model-visible local media is annotated with its absolute path, not
|
||||
// the opaque handle; passive recall must still map that path back to
|
||||
// the session handle so recall keys on both annotation forms.
|
||||
const resourceId = await recordAndBind();
|
||||
const parts = [
|
||||
'plain user text',
|
||||
{ text: formatResourcePathText(path.join(tmpDir, 'pic.png')) },
|
||||
];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a path-form annotation for a file this session never bound', () => {
|
||||
const parts = [
|
||||
{ text: formatResourcePathText(path.join(tmpDir, 'never-seen.png')) },
|
||||
];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves a path-form annotation whose filename ends in whitespace', async () => {
|
||||
// A trailing space is a legal POSIX filename char; it rides in the
|
||||
// fileRef and the annotation. Parsing must NOT trim it away, or the
|
||||
// exact-equality resolveByFileRef misses and the file's memory is
|
||||
// silently dropped from passive recall.
|
||||
const fileRef = path.join(tmpDir, 'pic.png ');
|
||||
const resourceId = await recordAndBindFileRef(fileRef);
|
||||
const parts = [{ text: formatResourcePathText(fileRef) }];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a path-form annotation whose filename ends in a handle-shaped suffix', async () => {
|
||||
// The path form's own 【媒体路径】 marker means the `:` in the filename
|
||||
// is never a handle boundary; the verbatim path resolves to its binding.
|
||||
const fileRef = path.join(tmpDir, 'clip:media-3-9f2cabcd');
|
||||
const resourceId = await recordAndBindFileRef(fileRef);
|
||||
const parts = [{ text: formatResourcePathText(fileRef) }];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a path form embedded in a line with leading whitespace', async () => {
|
||||
// Annotations can be flattened into a larger part with indentation;
|
||||
// leading whitespace is line formatting, not part of the path.
|
||||
const resourceId = await recordAndBind();
|
||||
const parts = [
|
||||
{
|
||||
text: `context\n ${formatResourcePathText(
|
||||
path.join(tmpDir, 'pic.png'),
|
||||
)}\nmore`,
|
||||
},
|
||||
];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a path form when trailing whitespace is only line formatting', async () => {
|
||||
// Here the FILENAME does not end in whitespace, but the embedded line
|
||||
// carries trailing spaces (formatting). The left-trimmed candidate keeps
|
||||
// them and misses the exact-equality resolveByFileRef; the fully-trimmed
|
||||
// fallback is what resolves it — so this pins that second candidate.
|
||||
const resourceId = await recordAndBind(); // fileRef = <tmp>/pic.png
|
||||
const annotation = formatResourcePathText(path.join(tmpDir, 'pic.png'));
|
||||
const parts = [{ text: `context\n ${annotation} \nmore` }];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
resourceId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a path to the LATEST version bound at that locator', async () => {
|
||||
// Two versions of one file at the same path (re-read after an edit) mint
|
||||
// distinct handles — exercised via the `sha256` parameter. A path names
|
||||
// the file, not a version, so passive recall must key on the version the
|
||||
// model currently sees (the latest), not the first ever bound.
|
||||
const fileRef = path.join(tmpDir, 'pic.png');
|
||||
const first = await recordAndBindFileRef(fileRef, 'a'.repeat(64));
|
||||
const second = await recordAndBindFileRef(fileRef, 'b'.repeat(64));
|
||||
expect(second).not.toBe(first);
|
||||
const parts = [{ text: formatResourcePathText(fileRef) }];
|
||||
expect(extractRequestResourceIds(sideQueryConfig(), parts)).toEqual([
|
||||
second,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOmniMemorySideQuery', () => {
|
||||
it('is a no-op in active mode (D10 mutual exclusion)', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig('active'),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
expect(outcome).toBeNull();
|
||||
expect(runSideQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the request carries no handles', async () => {
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: ['just text'],
|
||||
});
|
||||
expect(outcome).toBeNull();
|
||||
expect(runSideQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('materializes the selector picks and formats the reminder', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
// Capture, assert AFTER: production wraps the selector call in
|
||||
// try/catch and turns any throw into `selector_failed` degradation, so
|
||||
// a failed expect() INSIDE the mock would surface as a null result
|
||||
// instead of naming what leaked.
|
||||
let seenPayload: SelectorPayload | undefined;
|
||||
mockSelector((payload) => {
|
||||
seenPayload = payload;
|
||||
return { entryIds: [payload.candidates[0]!.entryId] };
|
||||
});
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
'what size is this image?',
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(seenPayload?.request).toContain('what size is this image');
|
||||
// Selector-visible manifest: summaries only, no paths.
|
||||
expect(JSON.stringify(seenPayload?.candidates)).not.toContain(tmpDir);
|
||||
expect(outcome?.result).not.toBeNull();
|
||||
expect(outcome!.result!.entries).toHaveLength(1);
|
||||
expect(outcome!.result!.entries[0]!.kind).toBe('metadata');
|
||||
const reminder = formatOmniMemorySideQueryReminder(outcome!.result!);
|
||||
expect(reminder).toContain('【媒体记忆】');
|
||||
expect(reminder).toContain(outcome!.result!.entries[0]!.entryId);
|
||||
expect(reminder).not.toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('shows the selector the question even when IDE context is merged in', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
let seenRequest = '';
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: { contents: Content[] },
|
||||
) => {
|
||||
const payload = JSON.parse(
|
||||
(options.contents[0]!.parts![0] as { text: string }).text,
|
||||
);
|
||||
seenRequest = payload.request;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
// Exactly how client.ts builds the parts in IDE mode: wrapIdeContext
|
||||
// output is PREPENDED INTO the user's own text part, before the
|
||||
// passive-recall pass runs — so the question lives in a part that
|
||||
// STARTS with <system-reminder>. Dropping such parts wholesale would
|
||||
// make the selector pick relevance-blind.
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{
|
||||
text:
|
||||
'<system-reminder>\nActive file: /x/y.ts\n</system-reminder>' +
|
||||
'what size is this image?',
|
||||
},
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(seenRequest).toContain('what size is this image?');
|
||||
// The reminder itself is stripped, not forwarded.
|
||||
expect(seenRequest).not.toContain('system-reminder');
|
||||
expect(seenRequest).not.toContain('/x/y.ts');
|
||||
});
|
||||
|
||||
it('never forwards a local media path to the selector (path form)', async () => {
|
||||
// A model-visible local file is annotated with its ABSOLUTE PATH. The
|
||||
// selector judges relevance from the question alone and must not receive
|
||||
// the path (home dir, OS username, project layout) — §17.5 "selector 不
|
||||
// 接收原始媒体、大文本或本地路径". The path form put it into the selector call.
|
||||
const fileRef = path.join(tmpDir, 'pic.png');
|
||||
await recordAndBindFileRef(fileRef);
|
||||
let seenRequest = '';
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: { contents: Content[] },
|
||||
) => {
|
||||
const payload = JSON.parse(
|
||||
(options.contents[0]!.parts![0] as { text: string }).text,
|
||||
);
|
||||
seenRequest = payload.request;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: 'what is in this picture?' },
|
||||
{ text: formatResourcePathText(fileRef) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(seenRequest).toContain('what is in this picture?');
|
||||
expect(seenRequest).not.toContain(fileRef);
|
||||
expect(seenRequest).not.toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('keeps marker-prefixed PROSE in the selector request (parse-gated strip)', async () => {
|
||||
// R3-8: stripResourceAnnotationLines must drop only lines that PARSE as
|
||||
// a real annotation, not every line opening with a marker. A user's note
|
||||
// `【媒体资源】清单…` (marker prefix, but no `:<handle>` payload) is
|
||||
// their question — the old prefix-only strip deleted it, so the selector
|
||||
// judged relevance from a truncated question. The parse gate keeps it.
|
||||
const resourceId = await recordAndBind();
|
||||
let seenRequest = '';
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: { contents: Content[] },
|
||||
) => {
|
||||
seenRequest = JSON.parse(
|
||||
(options.contents[0]!.parts![0] as { text: string }).text,
|
||||
).request;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: '【媒体资源】清单 和 【媒体路径】清单 是我的笔记' },
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
// The prose survives; the genuine handle annotation is stripped.
|
||||
expect(seenRequest).toContain('【媒体资源】清单');
|
||||
expect(seenRequest).toContain('【媒体路径】清单');
|
||||
expect(seenRequest).not.toContain(resourceId);
|
||||
});
|
||||
|
||||
it('degrades to an empty recall with a reason when the selector fails', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
runSideQueryMock.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
result: null,
|
||||
reason: expect.stringContaining('selector_failed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a selection the candidate manifest does not authorize', async () => {
|
||||
// The `validate` closure is the only thing standing between the
|
||||
// selector and an arbitrary entryId: a wrong verdict either lets a
|
||||
// forged/cross-root id through to materialization, or (inverted)
|
||||
// rejects every legitimate selection — and with the default
|
||||
// `maxAttempts: 1` that turns every passive recall into
|
||||
// `selector_failed`, silently killing the whole feature.
|
||||
const resourceId = await recordAndBind();
|
||||
let validate: SelectorOptions['validate'];
|
||||
let manifestEntryId = '';
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
validate = options.validate;
|
||||
manifestEntryId = parseSelectorPayload(options).candidates[0]!.entryId;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig('sideQuery', { maxSelectedEntries: 1 }),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(validate).toBeDefined();
|
||||
expect(validate!({ entryIds: [manifestEntryId] })).toBeNull();
|
||||
expect(validate!({ entryIds: ['media-entry-forged'] })).toContain(
|
||||
'not in the candidate manifest',
|
||||
);
|
||||
// Two copies of a legal id are still two picks: the budget cap is
|
||||
// judged before membership, so the manifest needs only one entry.
|
||||
expect(
|
||||
validate!({ entryIds: [manifestEntryId, manifestEntryId] }),
|
||||
).toContain('at most 1 entryIds');
|
||||
expect(validate!({ entryIds: 'everything' })).toContain(
|
||||
'must be an array',
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades when the selector names an entryId outside the manifest', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
mockSelector(() => ({ entryIds: ['media-entry-forged'] }));
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
// Whole-selection rejection, surfaced through the same degradation
|
||||
// path as a generation failure — never a partial materialization.
|
||||
expect(outcome).toMatchObject({
|
||||
result: null,
|
||||
reason: expect.stringContaining('selector_failed'),
|
||||
});
|
||||
expect(outcome!.reason).toContain('not in the candidate manifest');
|
||||
});
|
||||
|
||||
it('caps the request text handed to the selector', async () => {
|
||||
// The selector is a bounded pre-flight call on the critical path of
|
||||
// every request carrying media: an unbounded request text (a pasted
|
||||
// log, a huge diff) would put the main request's latency and cost at
|
||||
// the mercy of whatever the user happened to paste.
|
||||
const resourceId = await recordAndBind();
|
||||
const longQuestion = 'q'.repeat(5000);
|
||||
let seenRequest = '';
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
seenRequest = parseSelectorPayload(options).request;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
longQuestion,
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(seenRequest).toHaveLength(4000);
|
||||
expect(seenRequest).toBe(longQuestion.slice(0, 4000));
|
||||
});
|
||||
|
||||
it('cancels the selector when the caller aborts', async () => {
|
||||
// A Ctrl-C landing inside the selector window must reach the selector
|
||||
// call: composed out of the request signal, the interrupted main
|
||||
// request would sit through the whole sideQuery.timeoutMs waiting for
|
||||
// a selection nobody will use.
|
||||
const resourceId = await recordAndBind();
|
||||
const controller = new AbortController();
|
||||
let seenSignal: AbortSignal | undefined;
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
seenSignal = options.abortSignal;
|
||||
controller.abort();
|
||||
throw new Error('aborted');
|
||||
}) as never);
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(seenSignal?.aborted).toBe(true);
|
||||
// A user abort is not the bounded window elapsing.
|
||||
expect(outcome?.reason).toContain('selector_failed');
|
||||
});
|
||||
|
||||
it('reports selector_timeout when the bounded window elapses', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
await new Promise((_resolve, reject) => {
|
||||
options.abortSignal.addEventListener('abort', () =>
|
||||
reject(new Error('The operation was aborted')),
|
||||
);
|
||||
});
|
||||
}) as never);
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig('sideQuery', { timeoutMs: 5 }),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
result: null,
|
||||
reason: 'selector_timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('attributes the selector call to the originating prompt', async () => {
|
||||
// Without the promptId the selector's model traffic is logged under a
|
||||
// synthetic id, detaching this pre-flight call's cost and failures
|
||||
// from the request that caused them.
|
||||
const resourceId = await recordAndBind();
|
||||
let seenPromptId: string | undefined;
|
||||
runSideQueryMock.mockImplementation((async (
|
||||
_config: unknown,
|
||||
options: SelectorOptions,
|
||||
) => {
|
||||
seenPromptId = options.promptId;
|
||||
return { entryIds: [] };
|
||||
}) as never);
|
||||
|
||||
await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
promptId: 'session-abc########3',
|
||||
});
|
||||
|
||||
expect(seenPromptId).toBe('session-abc########3');
|
||||
});
|
||||
|
||||
it('treats an empty selection as nothing to inject', async () => {
|
||||
const resourceId = await recordAndBind();
|
||||
runSideQueryMock.mockResolvedValue({ entryIds: [] } as never);
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('pic.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
result: null,
|
||||
reason: 'selector_selected_nothing',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the selector entirely when memory has no candidates', async () => {
|
||||
// Bind a handle whose version was never persisted (empty store).
|
||||
const resourceId = registry.bind({
|
||||
fileId: 'f1',
|
||||
fileVersionId: 'v1',
|
||||
rootFileId: 'f1',
|
||||
fileRef: path.join(tmpDir, 'ghost.png'),
|
||||
mediaType: 'image',
|
||||
}).resourceId;
|
||||
|
||||
const outcome = await runOmniMemorySideQuery({
|
||||
config: sideQueryConfig(),
|
||||
requestParts: [
|
||||
{ text: formatResourceHandleText('ghost.png', resourceId) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ result: null, reason: 'no_candidates' });
|
||||
expect(runSideQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, Part, PartUnion } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { runSideQuery } from '../utils/sideQuery.js';
|
||||
import {
|
||||
MediaMemoryRecallRejection,
|
||||
type MediaMemoryCandidateSummary,
|
||||
type MediaMemoryRecallResult,
|
||||
} from '../services/media-memory/index.js';
|
||||
import {
|
||||
parseResourceHandleText,
|
||||
parseResourcePathText,
|
||||
} from './disclosure.js';
|
||||
import { createMediaMemoryRecallService } from './memory-recall.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:memory');
|
||||
|
||||
/**
|
||||
* Passive sideQuery recall (memory design M §9.3, D10 sideQuery mode):
|
||||
* before the main model request is sent, a bounded selector model reads a
|
||||
* candidate manifest of what memory knows about the media the CURRENT
|
||||
* request explicitly references, picks the relevant entryIds, and the
|
||||
* harness materializes exactly those through the unified recall protocol.
|
||||
*
|
||||
* Constitutional bounds, enforced here and in the recall service:
|
||||
* - only resources the request explicitly carries (parsed from the
|
||||
* resource annotations — the 【媒体资源】 handle form or the 【媒体路径】
|
||||
* path form) are consulted — never a project-wide scan;
|
||||
* - the selector sees summaries (no raw media, full text, paths, or
|
||||
* secrets — resource-annotation lines, which for a local file carry the
|
||||
* absolute path, are stripped from the selector's request text) and may
|
||||
* ONLY return manifest entryIds — an unknown id,
|
||||
* cross-root id, or over-budget selection rejects the WHOLE selection;
|
||||
* - any failure (timeout, selector error, rejection) degrades to an empty
|
||||
* recall with a recorded reason; the main request always proceeds.
|
||||
*/
|
||||
|
||||
/** Outcome of one passive recall attempt (the selector ran or was
|
||||
* legitimately skipped after handles were found). */
|
||||
export interface OmniMemorySideQueryOutcome {
|
||||
/** Materialized recall to inject, or null when there is nothing. */
|
||||
result: MediaMemoryRecallResult | null;
|
||||
/** Recorded reason when `result` is null (M §17: 超时/非法 → 空召回 +
|
||||
* 记录原因). */
|
||||
reason?: string;
|
||||
/** Session handles the selector consulted. */
|
||||
resourceIds: string[];
|
||||
}
|
||||
|
||||
/** JSON face the selector must answer with: entryIds only (M §9.3 — no
|
||||
* free text, no new conclusions). */
|
||||
const SELECTION_SCHEMA: Record<string, unknown> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entryIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'entryIds chosen from the candidate manifest.',
|
||||
},
|
||||
},
|
||||
required: ['entryIds'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Bound on the request text handed to the selector for relevance. */
|
||||
const MAX_SELECTOR_REQUEST_CHARS = 4000;
|
||||
|
||||
/** Extract the session resource handles a request explicitly carries: every
|
||||
* resource annotation part — the 【媒体资源】 handle form directly, or the
|
||||
* 【媒体路径】 path form reversed to its handle via `resolveByFileRef` — that
|
||||
* resolves to a binding this session's registry actually issued (M §9.3 —
|
||||
* passive recall never guesses beyond what the request references).
|
||||
* Deduplicated, in first-appearance order. */
|
||||
export function extractRequestResourceIds(
|
||||
config: Config,
|
||||
parts: readonly PartUnion[],
|
||||
): string[] {
|
||||
const registry = config.getOmniMediaResourceRegistry?.();
|
||||
if (!registry) return [];
|
||||
const found: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const part of parts) {
|
||||
const text =
|
||||
typeof part === 'string'
|
||||
? part
|
||||
: typeof part === 'object' && part !== null && 'text' in part
|
||||
? (part as { text?: string }).text
|
||||
: undefined;
|
||||
if (!text) continue;
|
||||
// Annotations may be embedded per-line inside a larger flattened part.
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
// The handle form (path-less media) names the resourceId directly;
|
||||
// surrounding whitespace on an embedded line is not part of the
|
||||
// grammar, so match against the trimmed line.
|
||||
let resourceId = parseResourceHandleText(trimmed);
|
||||
if (!resourceId) {
|
||||
// The path form (model-visible local media) names the absolute
|
||||
// path, which the registry maps back to the SAME session handle it
|
||||
// issued for that file (resolveByFileRef) — so passive recall keys
|
||||
// on both forms. A filename may legally END in whitespace (POSIX),
|
||||
// so prefer the left-trimmed line (which preserves those trailing
|
||||
// characters) and fall back to the fully-trimmed line for the case
|
||||
// where the trailing whitespace was only line formatting. A path
|
||||
// containing a newline cannot survive this per-line split and is
|
||||
// inherently unaddressable here.
|
||||
const leftTrimmed = line.replace(/^\s+/, '');
|
||||
for (const candidate of [
|
||||
parseResourcePathText(leftTrimmed),
|
||||
parseResourcePathText(trimmed),
|
||||
]) {
|
||||
if (!candidate) continue;
|
||||
const binding = registry.resolveByFileRef(candidate);
|
||||
if (binding) {
|
||||
resourceId = binding.resourceId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resourceId || seen.has(resourceId)) continue;
|
||||
if (!registry.resolve(resourceId)) continue;
|
||||
seen.add(resourceId);
|
||||
found.push(resourceId);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Strip every `<system-reminder>…</system-reminder>` block from a text
|
||||
* part. Harness reminders are not the user's question, but they can be
|
||||
* PREPENDED INTO the user's own text part (IDE context does exactly that,
|
||||
* before the passive-recall pass runs) — dropping the whole part on a
|
||||
* leading tag would hide the question from the selector entirely. */
|
||||
function stripSystemReminders(text: string): string {
|
||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
||||
}
|
||||
|
||||
/** Drop every line that actually PARSES as a resource annotation — the
|
||||
* handle form (`【媒体资源】<name>:<handle>`) or the path form
|
||||
* (`【媒体路径】<absolute path>`). Harness-injected, not the user's question —
|
||||
* the selector already has the candidate manifest and judges relevance from
|
||||
* the question alone, so these lines add no signal. Critically, the PATH form
|
||||
* carries the file's absolute local path (home directory, OS username, project
|
||||
* layout); leaving it in would send that to the selector's model call and its
|
||||
* API-traffic logs, violating the §17.5 acceptance item "selector 不接收
|
||||
* 原始媒体、大文本或本地路径".
|
||||
*
|
||||
* Gated on a successful parse, NOT on the bare marker prefix: ordinary prose
|
||||
* that merely opens with a marker (`【媒体资源】清单`) is the user's question and
|
||||
* must survive — the parse side (parseResourceHandleText's id grammar,
|
||||
* parseResourcePathText's isAbsolutePathLike) already rejects it and the
|
||||
* export keeps it in request.text, so the selector must agree. Mirrors
|
||||
* extractRequestResourceIds' candidates (left-trim preserves a filename's
|
||||
* legal trailing whitespace; full-trim covers line-formatting whitespace). */
|
||||
function stripResourceAnnotationLines(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
const leftTrimmed = line.replace(/^\s+/, '');
|
||||
return !(
|
||||
parseResourceHandleText(trimmed) ??
|
||||
parseResourcePathText(leftTrimmed) ??
|
||||
parseResourcePathText(trimmed)
|
||||
);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Plain request text (system reminders and resource annotations removed,
|
||||
* bounded) — the selector's only view of what the user is asking. */
|
||||
function selectorRequestText(parts: readonly PartUnion[]): string {
|
||||
const texts: string[] = [];
|
||||
for (const part of parts) {
|
||||
const text =
|
||||
typeof part === 'string'
|
||||
? part
|
||||
: typeof part === 'object' && part !== null && 'text' in part
|
||||
? (part as { text?: string }).text
|
||||
: undefined;
|
||||
if (!text) continue;
|
||||
const stripped = stripResourceAnnotationLines(
|
||||
stripSystemReminders(text),
|
||||
).trim();
|
||||
if (stripped) texts.push(stripped);
|
||||
}
|
||||
let joined = texts.join('\n');
|
||||
if (joined.length > MAX_SELECTOR_REQUEST_CHARS) {
|
||||
joined = joined.slice(0, MAX_SELECTOR_REQUEST_CHARS);
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the passive selector for one outgoing request. Returns null when
|
||||
* the passive path does not apply at all (memory off, mode !== sideQuery,
|
||||
* or the request references no session media); otherwise an outcome whose
|
||||
* `result` is the recall to inject (or null with a recorded reason).
|
||||
*
|
||||
* Latency is bounded by `sideQuery.timeoutMs` — the caller awaits this
|
||||
* BEFORE sending the main request (M §9.3: 选中结果必须在主请求发送前注入).
|
||||
*/
|
||||
export async function runOmniMemorySideQuery(params: {
|
||||
config: Config;
|
||||
requestParts: readonly PartUnion[];
|
||||
promptId?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OmniMemorySideQueryOutcome | null> {
|
||||
const { config } = params;
|
||||
const memoryConfig = config.getOmniMemoryConfig?.();
|
||||
if (!memoryConfig || memoryConfig.recall.mode !== 'sideQuery') return null;
|
||||
const resourceIds = extractRequestResourceIds(config, params.requestParts);
|
||||
if (resourceIds.length === 0) return null;
|
||||
const service = createMediaMemoryRecallService(config);
|
||||
if (!service) return null;
|
||||
const sideQuery = memoryConfig.recall.sideQuery;
|
||||
|
||||
let manifest: MediaMemoryCandidateSummary[];
|
||||
try {
|
||||
manifest = await service.candidateSummaries(resourceIds);
|
||||
} catch (err) {
|
||||
const reason = `manifest_failed: ${err instanceof Error ? err.message : err}`;
|
||||
debugLogger.debug(`omni sideQuery recall degraded: ${reason}`);
|
||||
return { result: null, reason, resourceIds };
|
||||
}
|
||||
if (manifest.length === 0) {
|
||||
return { result: null, reason: 'no_candidates', resourceIds };
|
||||
}
|
||||
|
||||
const manifestIds = new Set(manifest.map((c) => c.entryId));
|
||||
const timeoutSignal = AbortSignal.timeout(sideQuery.timeoutMs);
|
||||
const abortSignal = params.signal
|
||||
? AbortSignal.any([params.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify(
|
||||
{
|
||||
request: selectorRequestText(params.requestParts),
|
||||
candidates: manifest,
|
||||
},
|
||||
null,
|
||||
1,
|
||||
),
|
||||
} as Part,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let selection: { entryIds: string[] };
|
||||
try {
|
||||
selection = await runSideQuery<{ entryIds: string[] }>(config, {
|
||||
contents,
|
||||
schema: SELECTION_SCHEMA,
|
||||
abortSignal,
|
||||
...(sideQuery.model !== null ? { model: sideQuery.model } : {}),
|
||||
systemInstruction:
|
||||
'You select which persisted media-memory entries are relevant to ' +
|
||||
'the current request. Input: {request, candidates}. Return ONLY ' +
|
||||
`{"entryIds": [...]}: at most ${sideQuery.maxSelectedEntries} ids, ` +
|
||||
'each copied verbatim from the candidates. Return an empty array ' +
|
||||
'when nothing is relevant. Never invent ids, never add other keys.',
|
||||
...(params.promptId !== undefined ? { promptId: params.promptId } : {}),
|
||||
purpose: 'omni-memory-sidequery-selector',
|
||||
maxAttempts: sideQuery.maxAttempts,
|
||||
skipOutputLanguagePreference: true,
|
||||
validate: (response) => {
|
||||
if (!Array.isArray(response.entryIds))
|
||||
return 'entryIds must be an array';
|
||||
if (response.entryIds.length > sideQuery.maxSelectedEntries) {
|
||||
return `at most ${sideQuery.maxSelectedEntries} entryIds`;
|
||||
}
|
||||
const unknown = response.entryIds.find(
|
||||
(id) => typeof id !== 'string' || !manifestIds.has(id),
|
||||
);
|
||||
return unknown !== undefined
|
||||
? `entryId ${String(unknown)} is not in the candidate manifest`
|
||||
: null;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Timeout, generation failure, or a selection our `validate` refused.
|
||||
// Note the asymmetry: `maxAttempts` governs the client's own retry loop
|
||||
// (unparseable output, schema violations), while `validate` runs once
|
||||
// afterwards — so a selection that parses but names an id outside the
|
||||
// manifest throws on the first offense and is not retried. Either way:
|
||||
// empty recall, main request proceeds (M §17).
|
||||
const reason = timeoutSignal.aborted
|
||||
? 'selector_timeout'
|
||||
: `selector_failed: ${err instanceof Error ? err.message : err}`;
|
||||
debugLogger.debug(`omni sideQuery recall degraded: ${reason}`);
|
||||
return { result: null, reason, resourceIds };
|
||||
}
|
||||
|
||||
if (selection.entryIds.length === 0) {
|
||||
return { result: null, reason: 'selector_selected_nothing', resourceIds };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await service.recallSelection(
|
||||
resourceIds,
|
||||
selection.entryIds,
|
||||
);
|
||||
// Every pick can drop out in the availability pass (the artifacts were
|
||||
// deleted between the walk and now). Injecting the empty shell would
|
||||
// spend the reminder on a payload that says nothing.
|
||||
if (result.entries.length === 0) {
|
||||
return { result: null, reason: 'materialized_nothing', resourceIds };
|
||||
}
|
||||
return { result, resourceIds };
|
||||
} catch (err) {
|
||||
// Defense in depth: validate() should have caught any bad selection,
|
||||
// but a whole-rejection here still degrades to an empty recall.
|
||||
const reason =
|
||||
err instanceof MediaMemoryRecallRejection
|
||||
? `selection_rejected: ${err.reason}`
|
||||
: `materialize_failed: ${err instanceof Error ? err.message : err}`;
|
||||
debugLogger.debug(`omni sideQuery recall degraded: ${reason}`);
|
||||
return { result: null, reason, resourceIds };
|
||||
}
|
||||
}
|
||||
|
||||
/** Model-facing injection block for a materialized passive recall. Framed
|
||||
* as a system reminder so provider converters treat it as harness text;
|
||||
* the JSON body is the same unified protocol shape the active tool
|
||||
* returns (M §9.4). */
|
||||
export function formatOmniMemorySideQueryReminder(
|
||||
result: MediaMemoryRecallResult,
|
||||
): string {
|
||||
return (
|
||||
'<system-reminder>\n' +
|
||||
'【媒体记忆】Recalled media memory for the resources referenced in ' +
|
||||
'this request (passive mode). Entries below were persisted by ' +
|
||||
'earlier processing; resourceIds in them are session handles you may ' +
|
||||
'pass to omni media tools.\n' +
|
||||
`${JSON.stringify(result, null, 1)}\n` +
|
||||
'</system-reminder>'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
FixedPolicyCondition,
|
||||
FixedPolicyConditionContext,
|
||||
} from './conditions.js';
|
||||
import {
|
||||
conditionUsesNamespace,
|
||||
evaluateFixedPolicyCondition,
|
||||
validateFixedPolicyCondition,
|
||||
} from './conditions.js';
|
||||
|
||||
const CONTEXT: FixedPolicyConditionContext = {
|
||||
resource: {
|
||||
sizeBytes: 8_200_000,
|
||||
width: 4096,
|
||||
height: 3072,
|
||||
estimatedTokenCount: 150_000,
|
||||
},
|
||||
request: { totalEstimatedMediaTokens: 180_000 },
|
||||
session: {
|
||||
contextWindowTokens: 131_072,
|
||||
promptTokenCount: 20_000,
|
||||
reservedOutputTokens: 8_192,
|
||||
availableContextTokens: 102_880,
|
||||
},
|
||||
};
|
||||
|
||||
const expr = (...parts: unknown[]): FixedPolicyCondition =>
|
||||
parts as unknown as FixedPolicyCondition;
|
||||
|
||||
describe('evaluateFixedPolicyCondition — comparisons', () => {
|
||||
it.each([
|
||||
// [operator, right literal, expected outcome] against width=4096
|
||||
['>', 4095, 'match'],
|
||||
['>', 4096, 'no_match'],
|
||||
['>=', 4096, 'match'],
|
||||
['>=', 4097, 'no_match'],
|
||||
['<', 4097, 'match'],
|
||||
['<', 4096, 'no_match'],
|
||||
['<=', 4096, 'match'],
|
||||
['<=', 4095, 'no_match'],
|
||||
['==', 4096, 'match'],
|
||||
['==', 4095, 'no_match'],
|
||||
['!=', 4095, 'match'],
|
||||
['!=', 4096, 'no_match'],
|
||||
] as const)('width %s %d → %s', (operator, right, outcome) => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr(operator, ['field', 'resource.width'], right),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result.outcome).toBe(outcome);
|
||||
});
|
||||
|
||||
it('compares field to field (the §8.3 keyframe-extraction example)', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr(
|
||||
'>',
|
||||
['field', 'resource.estimatedTokenCount'],
|
||||
['field', 'session.availableContextTokens'],
|
||||
),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toEqual({ outcome: 'match' });
|
||||
});
|
||||
|
||||
it('compares literal to literal', () => {
|
||||
expect(evaluateFixedPolicyCondition(expr('<', 2, 3), CONTEXT).outcome).toBe(
|
||||
'match',
|
||||
);
|
||||
});
|
||||
|
||||
it('== supports strict string/boolean equality; type mismatch is a determinate no_match', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('==', 'aac', 'aac'), CONTEXT).outcome,
|
||||
).toBe('match');
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('==', '3', 3), CONTEXT).outcome,
|
||||
).toBe('no_match');
|
||||
// ...and != is its exact complement, including across types.
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('!=', '3', 3), CONTEXT).outcome,
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('an absent field is unavailable, never false', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr('>', ['field', 'resource.durationMs'], 0),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.durationMs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown field name is unavailable and named', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr('>', ['field', 'resource.doesNotExist'], 0),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.doesNotExist'],
|
||||
});
|
||||
});
|
||||
|
||||
it('both operands missing → both fields recorded', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr(
|
||||
'>',
|
||||
['field', 'resource.bitRate'],
|
||||
['field', 'resource.sampleRateHz'],
|
||||
),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.bitRate', 'resource.sampleRateHz'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ordering over a non-numeric literal is unavailable, not false', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr('>', ['field', 'resource.width'], 'wide'),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toMatchObject({ outcome: 'unavailable' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateFixedPolicyCondition — combinators (strong Kleene)', () => {
|
||||
const TRUE = expr('==', 1, 1);
|
||||
const FALSE = expr('==', 1, 2);
|
||||
const UNAVAILABLE = expr('>', ['field', 'resource.durationMs'], 0);
|
||||
|
||||
it('all: every branch true → match', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('all', TRUE, TRUE), CONTEXT).outcome,
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('all: a false branch dominates an unavailable sibling', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('all', UNAVAILABLE, FALSE), CONTEXT)
|
||||
.outcome,
|
||||
).toBe('no_match');
|
||||
});
|
||||
|
||||
it('all: true + unavailable → unavailable with the missing field', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('all', TRUE, UNAVAILABLE), CONTEXT),
|
||||
).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.durationMs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('any: a true branch dominates an unavailable sibling', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('any', UNAVAILABLE, TRUE), CONTEXT)
|
||||
.outcome,
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('any: every branch false → no_match', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('any', FALSE, FALSE), CONTEXT).outcome,
|
||||
).toBe('no_match');
|
||||
});
|
||||
|
||||
it('any: false + unavailable → unavailable', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('any', FALSE, UNAVAILABLE), CONTEXT),
|
||||
).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.durationMs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('!: flips determinate outcomes', () => {
|
||||
expect(evaluateFixedPolicyCondition(expr('!', TRUE), CONTEXT).outcome).toBe(
|
||||
'no_match',
|
||||
);
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('!', FALSE), CONTEXT).outcome,
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('!: unavailable passes through — negation must not launder unknowns', () => {
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(expr('!', UNAVAILABLE), CONTEXT),
|
||||
).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.durationMs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('nests recursively and dedups missing fields', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr(
|
||||
'any',
|
||||
expr('all', UNAVAILABLE, TRUE),
|
||||
expr('<', ['field', 'resource.durationMs'], 100),
|
||||
),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
missingFields: ['resource.durationMs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('vacuous combinators: ["all"] → match, ["any"] → no_match', () => {
|
||||
expect(evaluateFixedPolicyCondition(expr('all'), CONTEXT).outcome).toBe(
|
||||
'match',
|
||||
);
|
||||
expect(evaluateFixedPolicyCondition(expr('any'), CONTEXT).outcome).toBe(
|
||||
'no_match',
|
||||
);
|
||||
});
|
||||
|
||||
it('never throws on malformed nodes — degrades to unavailable', () => {
|
||||
for (const bad of [
|
||||
null,
|
||||
42,
|
||||
'gt',
|
||||
{},
|
||||
[],
|
||||
['between', 1, 2],
|
||||
['>', 1], // wrong arity
|
||||
['>', 1, 2, 3], // wrong arity
|
||||
['!'], // missing operand
|
||||
['!', TRUE, FALSE], // extra operand
|
||||
['>', ['field'], 1], // malformed field reference
|
||||
// The retired object form must degrade, not silently match.
|
||||
{
|
||||
left: { field: 'resource.width' },
|
||||
operator: 'gt',
|
||||
right: { value: 1 },
|
||||
},
|
||||
]) {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
bad as unknown as FixedPolicyCondition,
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFixedPolicyCondition', () => {
|
||||
it('accepts the §8.3 documentation example', () => {
|
||||
expect(
|
||||
validateFixedPolicyCondition([
|
||||
'all',
|
||||
[
|
||||
'>',
|
||||
['field', 'resource.estimatedTokenCount'],
|
||||
['field', 'session.availableContextTokens'],
|
||||
],
|
||||
['>=', ['field', 'session.contextWindowTokens'], 131072],
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts negation and != comparisons', () => {
|
||||
expect(
|
||||
validateFixedPolicyCondition([
|
||||
'!',
|
||||
['!=', ['field', 'resource.channels'], 2],
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['non-array root', 7, /must be an expression array/],
|
||||
['empty array', [], /must be an expression array/],
|
||||
['non-string head', [42, 1, 2], /must be an expression array/],
|
||||
['bare all', ['all'], /"all" requires at least one operand/],
|
||||
['bare any', ['any'], /"any" requires at least one operand/],
|
||||
['! with two operands', ['!', ['==', 1, 1], ['==', 2, 2]], /exactly one/],
|
||||
['unknown operator', ['between', 1, 2], /unknown operator "between"/],
|
||||
['comparison arity', ['>', 1], /takes exactly two operands/],
|
||||
['unknown field', ['>', ['field', 'resource.nope'], 1], /unknown field/],
|
||||
[
|
||||
'malformed field reference',
|
||||
['>', ['field'], 1],
|
||||
/field reference must be/,
|
||||
],
|
||||
[
|
||||
'array that is not a field reference',
|
||||
['>', ['resource.width'], 1],
|
||||
/field reference must be/,
|
||||
],
|
||||
[
|
||||
'ordering operator with a string literal',
|
||||
['>', ['field', 'resource.width'], 'wide'],
|
||||
/requires a finite numeric literal/,
|
||||
],
|
||||
[
|
||||
'non-primitive literal',
|
||||
['==', { nested: true }, 1],
|
||||
/number, string, or boolean/,
|
||||
],
|
||||
[
|
||||
'legacy object form gets a migration hint',
|
||||
{
|
||||
left: { field: 'resource.width' },
|
||||
operator: 'gt',
|
||||
right: { value: 3000 },
|
||||
},
|
||||
/no longer supported/,
|
||||
],
|
||||
])('rejects %s', (_label, raw, pattern) => {
|
||||
const errors = validateFixedPolicyCondition(raw);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.join('\n')).toMatch(pattern);
|
||||
});
|
||||
|
||||
it('== and != allow string and boolean literals', () => {
|
||||
expect(validateFixedPolicyCondition(['==', true, 'x'])).toEqual([]);
|
||||
expect(validateFixedPolicyCondition(['!=', 'aac', 'opus'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports nested positional paths for errors inside combinators', () => {
|
||||
const errors = validateFixedPolicyCondition([
|
||||
'any',
|
||||
['all', ['nope', 1, 2]],
|
||||
]);
|
||||
expect(errors.join('\n')).toContain('when[1][1][0]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory.* namespace (policy design §4.1/4.4)', () => {
|
||||
it('resolves presence flags from the memory context', () => {
|
||||
const withMemory: FixedPolicyConditionContext = {
|
||||
...CONTEXT,
|
||||
memory: { hasTranscript: 1, hasOcr: 0 },
|
||||
};
|
||||
// The §4.1 trigger: "memory 中无完整 ASR 结果" — hasTranscript == 0.
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(
|
||||
expr('==', ['field', 'memory.hasTranscript'], 0),
|
||||
withMemory,
|
||||
).outcome,
|
||||
).toBe('no_match');
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(
|
||||
expr('==', ['field', 'memory.hasOcr'], 0),
|
||||
withMemory,
|
||||
).outcome,
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('evaluates unavailable when the memory namespace is absent', () => {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
expr('==', ['field', 'memory.hasTranscript'], 0),
|
||||
CONTEXT,
|
||||
);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
expect(result).toMatchObject({ missingFields: ['memory.hasTranscript'] });
|
||||
});
|
||||
|
||||
it('propagates memory unavailability through combinators (strong Kleene)', () => {
|
||||
// all(false, unavailable) is a determinate false — the missing memory
|
||||
// field is not decisive.
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(
|
||||
expr(
|
||||
'all',
|
||||
['<', ['field', 'resource.width'], 100],
|
||||
['==', ['field', 'memory.hasTranscript'], 0],
|
||||
),
|
||||
CONTEXT,
|
||||
).outcome,
|
||||
).toBe('no_match');
|
||||
// all(true, unavailable) stays unavailable.
|
||||
expect(
|
||||
evaluateFixedPolicyCondition(
|
||||
expr(
|
||||
'all',
|
||||
['>', ['field', 'resource.durationMs'] as unknown[], 0],
|
||||
['==', ['field', 'memory.hasTranscript'], 0],
|
||||
),
|
||||
{ ...CONTEXT, resource: { ...CONTEXT.resource, durationMs: 100 } },
|
||||
).outcome,
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('accepts memory fields in structural validation', () => {
|
||||
expect(
|
||||
validateFixedPolicyCondition(['==', ['field', 'memory.hasOcr'], 0]),
|
||||
).toEqual([]);
|
||||
const errors = validateFixedPolicyCondition([
|
||||
'==',
|
||||
['field', 'memory.hasBogus'],
|
||||
0,
|
||||
]);
|
||||
expect(errors.join('\n')).toMatch(/unknown field/);
|
||||
});
|
||||
|
||||
it('conditionUsesNamespace detects memory references through nesting', () => {
|
||||
expect(
|
||||
conditionUsesNamespace(
|
||||
expr(
|
||||
'all',
|
||||
['>', ['field', 'resource.durationMs'], 1_800_000],
|
||||
['any', ['==', ['field', 'memory.hasTranscript'], 0]],
|
||||
),
|
||||
'memory',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
conditionUsesNamespace(
|
||||
expr('>', ['field', 'resource.width'], 2000),
|
||||
'memory',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
conditionUsesNamespace(
|
||||
expr('>', ['field', 'resource.width'], 2000),
|
||||
'resource',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
conditionUsesNamespace(
|
||||
expr('!', ['==', ['field', 'session.promptTokenCount'], 0]),
|
||||
'session',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Restricted `when` condition DSL for fixed policies (policy design §8.3).
|
||||
*
|
||||
* Conditions are Mapbox-style expression arrays: `[operator, ...operands]`.
|
||||
* A comparison takes exactly two operands, each either a
|
||||
* `["field", "<namespace.name>"]` reference or a bare literal —
|
||||
* `[">", ["field", "resource.width"], 3000]`. Combinators nest recursively:
|
||||
* `["all", <expr>, ...]`, `["any", <expr>, ...]`, `["!", <expr>]`. No
|
||||
* arbitrary code, no JSONPath, no value-producing sub-expressions inside
|
||||
* operands. Evaluation is three-valued: a comparison over a field that
|
||||
* cannot be resolved yields `unavailable`, which must NEVER be silently
|
||||
* treated as false — the caller applies the policy's
|
||||
* `onConditionUnavailable` behavior (default: skip) and the run record
|
||||
* names the missing fields.
|
||||
*/
|
||||
|
||||
/** Readable fields, grouped in their three natural namespaces. */
|
||||
export const RESOURCE_CONDITION_FIELDS = [
|
||||
'sizeBytes',
|
||||
'durationMs',
|
||||
'width',
|
||||
'height',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'frameRate',
|
||||
'frameCount',
|
||||
'bitRate',
|
||||
'sampleRateHz',
|
||||
'channels',
|
||||
'estimatedTokenCount',
|
||||
] as const;
|
||||
export const REQUEST_CONDITION_FIELDS = ['totalEstimatedMediaTokens'] as const;
|
||||
export const SESSION_CONDITION_FIELDS = [
|
||||
'contextWindowTokens',
|
||||
'promptTokenCount',
|
||||
'reservedOutputTokens',
|
||||
'availableContextTokens',
|
||||
] as const;
|
||||
/** Memory-state fields (policy design §4.1/4.4): 1 when the media
|
||||
* memory subgraph of the item being matched already carries an output
|
||||
* with the corresponding role, 0 when it does not. Numeric so the
|
||||
* comparison operators work unchanged — `["==", ["field",
|
||||
* "memory.hasTranscript"], 0]` reads as "no transcript yet". */
|
||||
export const MEMORY_CONDITION_FIELDS = [
|
||||
'hasTranscript',
|
||||
'hasOcr',
|
||||
'hasCaption',
|
||||
'hasSummary',
|
||||
'hasKeyframes',
|
||||
'hasClip',
|
||||
] as const;
|
||||
|
||||
export type ResourceConditionField = (typeof RESOURCE_CONDITION_FIELDS)[number];
|
||||
export type RequestConditionField = (typeof REQUEST_CONDITION_FIELDS)[number];
|
||||
export type SessionConditionField = (typeof SESSION_CONDITION_FIELDS)[number];
|
||||
export type MemoryConditionField = (typeof MEMORY_CONDITION_FIELDS)[number];
|
||||
|
||||
/** Fully-qualified field name, e.g. `resource.sizeBytes`. */
|
||||
export type FixedPolicyField =
|
||||
| `resource.${ResourceConditionField}`
|
||||
| `request.${RequestConditionField}`
|
||||
| `session.${SessionConditionField}`
|
||||
| `memory.${MemoryConditionField}`;
|
||||
|
||||
/** Operand of a comparison: a field reference or a bare literal. */
|
||||
export type ConditionOperand =
|
||||
| ['field', FixedPolicyField]
|
||||
| number
|
||||
| string
|
||||
| boolean;
|
||||
|
||||
export type ComparisonOperator = '>' | '>=' | '<' | '<=' | '==' | '!=';
|
||||
|
||||
export type ComparisonCondition = [
|
||||
ComparisonOperator,
|
||||
ConditionOperand,
|
||||
ConditionOperand,
|
||||
];
|
||||
|
||||
export type FixedPolicyCondition =
|
||||
| ComparisonCondition
|
||||
| ['all' | 'any', ...FixedPolicyCondition[]]
|
||||
| ['!', FixedPolicyCondition];
|
||||
|
||||
/** Values feeding field resolution. Resource/request/session entries are
|
||||
* numeric; an absent entry means the field could not be obtained for this
|
||||
* resource (e.g. `durationMs` for an image, or a probe that returned
|
||||
* nothing). The memory namespace is numeric too (0/1 presence flags); it
|
||||
* is ABSENT ENTIRELY when memory state could not be consulted (memory
|
||||
* disabled, no binding, store unreadable) — a memory condition then
|
||||
* evaluates `unavailable`, never silently false. */
|
||||
export interface FixedPolicyConditionContext {
|
||||
resource?: Partial<Record<ResourceConditionField, number>>;
|
||||
request?: Partial<Record<RequestConditionField, number>>;
|
||||
session?: Partial<Record<SessionConditionField, number>>;
|
||||
memory?: Partial<Record<MemoryConditionField, number>>;
|
||||
}
|
||||
|
||||
export type ConditionEvaluation =
|
||||
| { outcome: 'match' }
|
||||
| { outcome: 'no_match' }
|
||||
| {
|
||||
outcome: 'unavailable';
|
||||
/** Field names (or operand descriptions) that made the result
|
||||
* undecidable — surfaced in the policy run record. */
|
||||
missingFields: string[];
|
||||
};
|
||||
|
||||
const MATCH: ConditionEvaluation = { outcome: 'match' };
|
||||
const NO_MATCH: ConditionEvaluation = { outcome: 'no_match' };
|
||||
|
||||
function unavailable(missingFields: string[]): ConditionEvaluation {
|
||||
return { outcome: 'unavailable', missingFields: [...new Set(missingFields)] };
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const COMPARISON_OPERATORS: readonly ComparisonOperator[] = [
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'==',
|
||||
'!=',
|
||||
];
|
||||
|
||||
function isComparisonOperator(v: unknown): v is ComparisonOperator {
|
||||
return (COMPARISON_OPERATORS as readonly unknown[]).includes(v);
|
||||
}
|
||||
|
||||
const KNOWN_FIELDS: ReadonlySet<string> = new Set([
|
||||
...RESOURCE_CONDITION_FIELDS.map((f) => `resource.${f}`),
|
||||
...REQUEST_CONDITION_FIELDS.map((f) => `request.${f}`),
|
||||
...SESSION_CONDITION_FIELDS.map((f) => `session.${f}`),
|
||||
...MEMORY_CONDITION_FIELDS.map((f) => `memory.${f}`),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a `when` expression references any field of the given
|
||||
* namespace (`resource.` / `request.` / `session.` / `memory.`) — the
|
||||
* orchestrator uses this to skip computing an expensive namespace (the
|
||||
* memory subgraph query) when no policy in play reads it.
|
||||
*/
|
||||
export function conditionUsesNamespace(
|
||||
condition: FixedPolicyCondition,
|
||||
namespace: string,
|
||||
): boolean {
|
||||
const node: unknown = condition;
|
||||
if (!Array.isArray(node)) return false;
|
||||
const prefix = `${namespace}.`;
|
||||
for (const operand of node) {
|
||||
if (!Array.isArray(operand)) continue;
|
||||
if (
|
||||
operand.length === 2 &&
|
||||
operand[0] === 'field' &&
|
||||
typeof operand[1] === 'string'
|
||||
) {
|
||||
if (operand[1].startsWith(prefix)) return true;
|
||||
continue;
|
||||
}
|
||||
// Nested combinator operands recurse; field refs were handled above.
|
||||
if (conditionUsesNamespace(operand as FixedPolicyCondition, namespace)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type ResolvedOperand =
|
||||
| { ok: true; value: number | string | boolean; describe: string }
|
||||
| { ok: false; missing: string };
|
||||
|
||||
function resolveOperand(
|
||||
operand: unknown,
|
||||
context: FixedPolicyConditionContext,
|
||||
): ResolvedOperand {
|
||||
if (Array.isArray(operand)) {
|
||||
if (
|
||||
operand.length !== 2 ||
|
||||
operand[0] !== 'field' ||
|
||||
typeof operand[1] !== 'string'
|
||||
) {
|
||||
return { ok: false, missing: '<malformed operand>' };
|
||||
}
|
||||
const field = operand[1];
|
||||
if (!KNOWN_FIELDS.has(field)) {
|
||||
return { ok: false, missing: field };
|
||||
}
|
||||
const [namespace, name] = field.split('.') as [
|
||||
'resource' | 'request' | 'session' | 'memory',
|
||||
string,
|
||||
];
|
||||
const value = (
|
||||
context[namespace] as Record<string, number | undefined> | undefined
|
||||
)?.[name];
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return { ok: false, missing: field };
|
||||
}
|
||||
return { ok: true, value, describe: field };
|
||||
}
|
||||
if (
|
||||
typeof operand === 'number' ||
|
||||
typeof operand === 'string' ||
|
||||
typeof operand === 'boolean'
|
||||
) {
|
||||
return { ok: true, value: operand, describe: 'value' };
|
||||
}
|
||||
return { ok: false, missing: '<malformed operand>' };
|
||||
}
|
||||
|
||||
function evaluateComparison(
|
||||
operator: ComparisonOperator,
|
||||
leftRaw: unknown,
|
||||
rightRaw: unknown,
|
||||
context: FixedPolicyConditionContext,
|
||||
): ConditionEvaluation {
|
||||
const left = resolveOperand(leftRaw, context);
|
||||
const right = resolveOperand(rightRaw, context);
|
||||
if (!left.ok || !right.ok) {
|
||||
const missing: string[] = [];
|
||||
if (!left.ok) missing.push(left.missing);
|
||||
if (!right.ok) missing.push(right.missing);
|
||||
return unavailable(missing);
|
||||
}
|
||||
if (operator === '==' || operator === '!=') {
|
||||
// Strict (in)equality: a type mismatch between two AVAILABLE values is
|
||||
// a determinate not-equal, not an unavailability.
|
||||
const equal = left.value === right.value;
|
||||
return equal === (operator === '==') ? MATCH : NO_MATCH;
|
||||
}
|
||||
// Ordering requires two finite numbers; anything else cannot be ordered
|
||||
// and must not silently collapse to false.
|
||||
if (
|
||||
typeof left.value !== 'number' ||
|
||||
typeof right.value !== 'number' ||
|
||||
!Number.isFinite(left.value) ||
|
||||
!Number.isFinite(right.value)
|
||||
) {
|
||||
const missing: string[] = [];
|
||||
if (typeof left.value !== 'number' || !Number.isFinite(left.value)) {
|
||||
missing.push(`${left.describe} (not orderable)`);
|
||||
}
|
||||
if (typeof right.value !== 'number' || !Number.isFinite(right.value)) {
|
||||
missing.push(`${right.describe} (not orderable)`);
|
||||
}
|
||||
return unavailable(missing);
|
||||
}
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return left.value > right.value ? MATCH : NO_MATCH;
|
||||
case '>=':
|
||||
return left.value >= right.value ? MATCH : NO_MATCH;
|
||||
case '<':
|
||||
return left.value < right.value ? MATCH : NO_MATCH;
|
||||
case '<=':
|
||||
return left.value <= right.value ? MATCH : NO_MATCH;
|
||||
default: {
|
||||
const exhaustive: never = operator;
|
||||
return unavailable([`unknown operator ${String(exhaustive)}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a `when` condition against a context snapshot. Total function —
|
||||
* never throws, even on structurally malformed input (which startup
|
||||
* validation rejects; anything that slips through degrades to
|
||||
* `unavailable`, the fail-safe outcome).
|
||||
*
|
||||
* Combinators use strong Kleene logic so `unavailable` propagates only
|
||||
* when it is actually decisive: `all` with a false branch is false
|
||||
* regardless of an unavailable sibling; `any` with a true branch is true;
|
||||
* `!` flips determinate outcomes and passes `unavailable` through.
|
||||
*/
|
||||
export function evaluateFixedPolicyCondition(
|
||||
condition: FixedPolicyCondition,
|
||||
context: FixedPolicyConditionContext,
|
||||
): ConditionEvaluation {
|
||||
// Deliberately treated as unknown: this total function must handle
|
||||
// malformed nodes anyway, and narrowing the tuple union member-by-member
|
||||
// buys nothing here.
|
||||
const node: unknown = condition;
|
||||
if (!Array.isArray(node) || node.length === 0) {
|
||||
return unavailable(['<malformed condition>']);
|
||||
}
|
||||
const head = node[0];
|
||||
if (head === 'all' || head === 'any') {
|
||||
const isAll = head === 'all';
|
||||
const missing: string[] = [];
|
||||
let sawUnavailable = false;
|
||||
for (let i = 1; i < node.length; i++) {
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
node[i] as FixedPolicyCondition,
|
||||
context,
|
||||
);
|
||||
if (result.outcome === 'unavailable') {
|
||||
sawUnavailable = true;
|
||||
missing.push(...result.missingFields);
|
||||
continue;
|
||||
}
|
||||
// Dominant outcomes short-circuit: false for `all`, true for `any`.
|
||||
if (isAll && result.outcome === 'no_match') return NO_MATCH;
|
||||
if (!isAll && result.outcome === 'match') return MATCH;
|
||||
}
|
||||
if (sawUnavailable) return unavailable(missing);
|
||||
return isAll ? MATCH : NO_MATCH;
|
||||
}
|
||||
if (head === '!') {
|
||||
if (node.length !== 2) {
|
||||
return unavailable(['<malformed condition>']);
|
||||
}
|
||||
const result = evaluateFixedPolicyCondition(
|
||||
node[1] as FixedPolicyCondition,
|
||||
context,
|
||||
);
|
||||
if (result.outcome === 'unavailable') return result;
|
||||
return result.outcome === 'match' ? NO_MATCH : MATCH;
|
||||
}
|
||||
if (isComparisonOperator(head) && node.length === 3) {
|
||||
return evaluateComparison(head, node[1], node[2], context);
|
||||
}
|
||||
return unavailable(['<malformed condition>']);
|
||||
}
|
||||
|
||||
function validateOperand(
|
||||
raw: unknown,
|
||||
operator: ComparisonOperator,
|
||||
where: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length !== 2 || raw[0] !== 'field') {
|
||||
errors.push(
|
||||
`${where}: field reference must be ["field", "<namespace.field>"]`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof raw[1] !== 'string' || !KNOWN_FIELDS.has(raw[1])) {
|
||||
errors.push(`${where}: unknown field ${JSON.stringify(raw[1])}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof raw !== 'number' &&
|
||||
typeof raw !== 'string' &&
|
||||
typeof raw !== 'boolean'
|
||||
) {
|
||||
errors.push(`${where}: literal must be a number, string, or boolean`);
|
||||
return;
|
||||
}
|
||||
if (operator !== '==' && operator !== '!=') {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||
errors.push(
|
||||
`${where}: operator "${operator}" requires a finite numeric literal`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation for a raw `when` condition (policy design §13 #5),
|
||||
* run at config-normalization time. Returns a list of human-readable
|
||||
* errors; empty means valid.
|
||||
*/
|
||||
export function validateFixedPolicyCondition(
|
||||
raw: unknown,
|
||||
where = 'when',
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
if (isPlainObject(raw)) {
|
||||
// Catch the pre-expression object form with a pointed migration hint
|
||||
// instead of a generic type error.
|
||||
errors.push(
|
||||
`${where}: condition must be an expression array like ` +
|
||||
`[">", ["field", "resource.width"], 3000] or ["all", <expr>, ...] ` +
|
||||
`(the {left, operator, right} object form is no longer supported)`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
if (!Array.isArray(raw) || raw.length === 0 || typeof raw[0] !== 'string') {
|
||||
errors.push(
|
||||
`${where}: condition must be an expression array [operator, ...operands]`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
const head = raw[0];
|
||||
if (head === 'all' || head === 'any') {
|
||||
if (raw.length < 2) {
|
||||
errors.push(
|
||||
`${where}: "${head}" requires at least one operand condition`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
for (let i = 1; i < raw.length; i++) {
|
||||
errors.push(...validateFixedPolicyCondition(raw[i], `${where}[${i}]`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
if (head === '!') {
|
||||
if (raw.length !== 2) {
|
||||
errors.push(`${where}: "!" takes exactly one operand condition`);
|
||||
return errors;
|
||||
}
|
||||
errors.push(...validateFixedPolicyCondition(raw[1], `${where}[1]`));
|
||||
return errors;
|
||||
}
|
||||
if (!isComparisonOperator(head)) {
|
||||
errors.push(
|
||||
`${where}[0]: unknown operator ${JSON.stringify(head)} (expected one ` +
|
||||
`of ${COMPARISON_OPERATORS.join(', ')}, all, any, !)`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
if (raw.length !== 3) {
|
||||
errors.push(`${where}: comparison "${head}" takes exactly two operands`);
|
||||
return errors;
|
||||
}
|
||||
validateOperand(raw[1], head, `${where}[1]`, errors);
|
||||
validateOperand(raw[2], head, `${where}[2]`, errors);
|
||||
return errors;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computePolicyFingerprint,
|
||||
OmniDegradationCache,
|
||||
} from './degradation-cache.js';
|
||||
|
||||
const ORIGINAL = 'a'.repeat(64);
|
||||
const DEGRADED = 'b'.repeat(64);
|
||||
|
||||
const ENTRY = {
|
||||
degradedSha256: DEGRADED,
|
||||
extension: '.jpg',
|
||||
disclosure:
|
||||
'原 4096×3072/8.2MB → 1568×1176/0.9MB,质量 75,细节与文字锐度受损',
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
describe('computePolicyFingerprint', () => {
|
||||
it('is stable across key order and identical inputs', () => {
|
||||
const a = computePolicyFingerprint('omni_downsample_image', {
|
||||
maxDimension: 1568,
|
||||
quality: 75,
|
||||
});
|
||||
const b = computePolicyFingerprint('omni_downsample_image', {
|
||||
quality: 75,
|
||||
maxDimension: 1568,
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('ignores the per-invocation io params (inputPath/outputDir)', () => {
|
||||
const bare = computePolicyFingerprint('omni_downsample_image', {
|
||||
quality: 75,
|
||||
});
|
||||
const withIo = computePolicyFingerprint('omni_downsample_image', {
|
||||
quality: 75,
|
||||
inputPath: '/tmp/a/in.png',
|
||||
outputDir: '/tmp/staging/deadbeef',
|
||||
});
|
||||
expect(withIo).toBe(bare);
|
||||
});
|
||||
|
||||
it('ignores undefined values (absent tunable == undefined tunable)', () => {
|
||||
expect(
|
||||
computePolicyFingerprint('t', { quality: 75, maxDimension: undefined }),
|
||||
).toBe(computePolicyFingerprint('t', { quality: 75 }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tool name', ['other_tool', { quality: 75 }, undefined]],
|
||||
['argument value', ['t', { quality: 80 }, undefined]],
|
||||
['argument set', ['t', { quality: 75, maxDimension: 800 }, undefined]],
|
||||
['tool version', ['t', { quality: 75 }, '2']],
|
||||
] as Array<[string, [string, Record<string, unknown>, string | undefined]]>)(
|
||||
'changes when the %s changes',
|
||||
(_label, [tool, args, version]) => {
|
||||
const base = computePolicyFingerprint('t', { quality: 75 });
|
||||
expect(computePolicyFingerprint(tool, args, version)).not.toBe(base);
|
||||
},
|
||||
);
|
||||
|
||||
it('sorts keys recursively in nested arguments', () => {
|
||||
expect(
|
||||
computePolicyFingerprint('t', { opts: { b: 2, a: [1, { d: 4, c: 3 }] } }),
|
||||
).toBe(
|
||||
computePolicyFingerprint('t', { opts: { a: [1, { c: 3, d: 4 }], b: 2 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OmniDegradationCache', () => {
|
||||
let root: string;
|
||||
let cache: OmniDegradationCache;
|
||||
const fp = computePolicyFingerprint('omni_downsample_image', {
|
||||
maxDimension: 1568,
|
||||
quality: 75,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-degcache-'));
|
||||
cache = new OmniDegradationCache(root);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips an entry and persists across instances', async () => {
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
const hit = await cache.get(ORIGINAL, fp);
|
||||
expect(hit).toMatchObject(ENTRY);
|
||||
expect(Date.parse(hit!.createdAt)).not.toBeNaN();
|
||||
|
||||
const second = new OmniDegradationCache(root);
|
||||
await expect(second.get(ORIGINAL, fp)).resolves.toMatchObject(ENTRY);
|
||||
});
|
||||
|
||||
it('writes to policy-cache.json under the omni root', async () => {
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
const raw = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'policy-cache.json'), 'utf8'),
|
||||
);
|
||||
expect(raw.version).toBe(1);
|
||||
expect(Object.keys(raw.entries)).toEqual([`${ORIGINAL}|${fp}`]);
|
||||
});
|
||||
|
||||
it('misses on a different fingerprint or original hash', async () => {
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
const otherFp = computePolicyFingerprint('omni_downsample_image', {
|
||||
maxDimension: 800,
|
||||
});
|
||||
await expect(cache.get(ORIGINAL, otherFp)).resolves.toBeNull();
|
||||
await expect(cache.get('c'.repeat(64), fp)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('re-put for the same key replaces the entry', async () => {
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
await cache.put(ORIGINAL, fp, {
|
||||
...ENTRY,
|
||||
degradedSha256: 'd'.repeat(64),
|
||||
});
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toMatchObject({
|
||||
degradedSha256: 'd'.repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the optional artifact role (a hit must not strip it)', async () => {
|
||||
await cache.put(ORIGINAL, fp, { ...ENTRY, role: 'thumbnail' });
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toMatchObject({
|
||||
...ENTRY,
|
||||
role: 'thumbnail',
|
||||
});
|
||||
// And an entry without a role stays role-less.
|
||||
const fp2 = computePolicyFingerprint('omni_downsample_image', {
|
||||
quality: 51,
|
||||
});
|
||||
await cache.put(ORIGINAL, fp2, ENTRY);
|
||||
const hit = await cache.get(ORIGINAL, fp2);
|
||||
expect(hit!.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removeByOriginalSha256 drops every policy result for the source', async () => {
|
||||
const fp2 = computePolicyFingerprint('omni_downsample_image', {
|
||||
quality: 50,
|
||||
});
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
await cache.put(ORIGINAL, fp2, ENTRY);
|
||||
await cache.put('c'.repeat(64), fp, ENTRY);
|
||||
|
||||
await cache.removeByOriginalSha256(ORIGINAL);
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
await expect(cache.get(ORIGINAL, fp2)).resolves.toBeNull();
|
||||
await expect(cache.get('c'.repeat(64), fp)).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('removeByDegradedSha256 drops every entry pointing at the derivative', async () => {
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
await cache.put('c'.repeat(64), fp, ENTRY);
|
||||
await cache.put('e'.repeat(64), fp, {
|
||||
...ENTRY,
|
||||
degradedSha256: 'f'.repeat(64),
|
||||
});
|
||||
|
||||
await cache.removeByDegradedSha256(DEGRADED);
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
await expect(cache.get('c'.repeat(64), fp)).resolves.toBeNull();
|
||||
await expect(cache.get('e'.repeat(64), fp)).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('backs up a corrupt cache file and starts fresh (never fatal)', async () => {
|
||||
const filePath = path.join(root, 'policy-cache.json');
|
||||
await fs.writeFile(filePath, '{corrupt');
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
const names = await fs.readdir(root);
|
||||
expect(names.some((n) => n.startsWith('policy-cache.json.corrupt-'))).toBe(
|
||||
true,
|
||||
);
|
||||
// And the cache is usable again.
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toMatchObject(ENTRY);
|
||||
});
|
||||
|
||||
it('writes atomically: no .tmp litter, 0600 file mode', async () => {
|
||||
await cache.put(ORIGINAL, fp, ENTRY);
|
||||
const names = await fs.readdir(root);
|
||||
expect(names.filter((n) => n.endsWith('.tmp'))).toEqual([]);
|
||||
if (process.platform !== 'win32') {
|
||||
const stat = await fs.stat(path.join(root, 'policy-cache.json'));
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes concurrent puts without losing entries', async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
cache.put(ORIGINAL, computePolicyFingerprint('t', { i }), ENTRY),
|
||||
),
|
||||
);
|
||||
const raw = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'policy-cache.json'), 'utf8'),
|
||||
);
|
||||
expect(Object.keys(raw.entries)).toHaveLength(8);
|
||||
});
|
||||
|
||||
describe('poisoned cache file (workspace-controlled input is shape-validated)', () => {
|
||||
/** Plant one raw entry as a hostile repo could ship it. */
|
||||
async function plantEntry(entry: Record<string, unknown>): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(root, 'policy-cache.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: { [`${ORIGINAL}|${fp}`]: entry },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'traversal in degradedSha256',
|
||||
{ ...ENTRY, degradedSha256: '../../../../etc/passwd' },
|
||||
],
|
||||
[
|
||||
'uppercase hex degradedSha256',
|
||||
{ ...ENTRY, degradedSha256: 'A'.repeat(64) },
|
||||
],
|
||||
['short degradedSha256', { ...ENTRY, degradedSha256: 'ab12' }],
|
||||
[
|
||||
'traversal in extension',
|
||||
{ ...ENTRY, extension: '/../../../../tmp/evil' },
|
||||
],
|
||||
['multi-dot extension', { ...ENTRY, extension: '.jpg/../x' }],
|
||||
['dotless extension', { ...ENTRY, extension: 'jpg' }],
|
||||
['non-string extension', { ...ENTRY, extension: 42 }],
|
||||
['empty disclosure (D8 invariant)', { ...ENTRY, disclosure: '' }],
|
||||
['missing disclosure', { ...ENTRY, disclosure: undefined }],
|
||||
[
|
||||
'oversized disclosure (prompt-stuffing channel)',
|
||||
{ ...ENTRY, disclosure: 'x'.repeat(4096) },
|
||||
],
|
||||
['empty mimeType', { ...ENTRY, mimeType: '' }],
|
||||
['missing mimeType', { ...ENTRY, mimeType: undefined }],
|
||||
['empty role', { ...ENTRY, role: '' }],
|
||||
['non-string role', { ...ENTRY, role: 42 }],
|
||||
])(
|
||||
'drops a malformed entry instead of serving it: %s',
|
||||
async (_label, entry) => {
|
||||
await plantEntry(entry as Record<string, unknown>);
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
// Self-heal: the malformed entry is deleted, so the next transcode's
|
||||
// put() rebuilds it from verified data.
|
||||
const raw = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'policy-cache.json'), 'utf8'),
|
||||
);
|
||||
expect(raw.entries).toEqual({});
|
||||
},
|
||||
);
|
||||
|
||||
it('still serves a planted entry when every field is well-formed', async () => {
|
||||
await plantEntry({ ...ENTRY, createdAt: new Date().toISOString() });
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toMatchObject(ENTRY);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['string', 'x'],
|
||||
['number', 42],
|
||||
['array', [1, 2]],
|
||||
])(
|
||||
'drops a non-object entry VALUE at load instead of throwing: %s',
|
||||
async (_label, value) => {
|
||||
// Value-level shape is validated at load (shared cache-file layer):
|
||||
// a crafted value like `null` must not surface as TypeErrors from
|
||||
// field accessors — including scans like removeByDegradedSha256
|
||||
// that touch EVERY entry, not just the requested key.
|
||||
await fs.writeFile(
|
||||
path.join(root, 'policy-cache.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: { [`${ORIGINAL}|${fp}`]: value, other: ENTRY },
|
||||
}),
|
||||
);
|
||||
await expect(cache.get(ORIGINAL, fp)).resolves.toBeNull();
|
||||
await expect(
|
||||
cache.removeByDegradedSha256(ENTRY.degradedSha256),
|
||||
).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { OmniJsonCacheFile } from '../json-cache-file.js';
|
||||
import { OBJECT_EXTENSION_RE } from '../storage.js';
|
||||
|
||||
/**
|
||||
* Identity of one degradation result (decision D2): everything the
|
||||
* orchestrator needs to reuse a previously transcoded derivative without
|
||||
* re-running the tool — the derived object's content hash (locating it in
|
||||
* `objects/`), the extension it was stored under (storage.ts convention:
|
||||
* leading dot), and the disclosure text that must accompany the lossy
|
||||
* derivative on every delivery.
|
||||
*/
|
||||
export interface DegradationCacheEntry {
|
||||
degradedSha256: string;
|
||||
/** Object-store extension INCLUDING the leading dot (".jpg"). */
|
||||
extension: string;
|
||||
/** Disclosure the tool emitted (D8) — redelivered verbatim on reuse. */
|
||||
disclosure: string;
|
||||
mimeType: string;
|
||||
/** `metadata.omniRole` the tool stamped on the artifact, when any —
|
||||
* without it a cache hit would strip the role a fresh derivation
|
||||
* carries, changing downstream artifact matching between the first run
|
||||
* and every cached rerun. */
|
||||
role?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Ceiling on a cached disclosure's length. Disclosures are one-line
|
||||
* summaries (tens of characters in practice); the cache file is
|
||||
* workspace-shippable, and an unbounded field served verbatim into
|
||||
* model-visible content would hand a hostile repo an arbitrarily large
|
||||
* prompt-stuffing channel on every cache hit. */
|
||||
export const MAX_CACHED_DISCLOSURE_LENGTH = 2048;
|
||||
|
||||
/** Io params are per-invocation plumbing, never policy identity: the same
|
||||
* policy applied to the same object must hit regardless of where the
|
||||
* source file sat or which staging dir the run used. `resourceId` joins
|
||||
* them for gated model calls — the session handle names the same bytes the
|
||||
* hash already keys, and it is freshly minted every session, so leaving it
|
||||
* in would make every session re-derive identical work. */
|
||||
const FINGERPRINT_EXCLUDED_KEYS = new Set([
|
||||
'inputPath',
|
||||
'outputDir',
|
||||
'resourceId',
|
||||
]);
|
||||
|
||||
/** Deterministic JSON: objects serialized with sorted keys at every
|
||||
* depth, so `{a,b}` and `{b,a}` fingerprint identically. */
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
}
|
||||
|
||||
/**
|
||||
* `policyFingerprint = sha256(toolName + normalized arguments + tool
|
||||
* version)` (decision D2). Arguments are normalized by dropping the
|
||||
* per-invocation io params and key-sorting the rest, so semantically
|
||||
* identical calls fingerprint identically. `toolVersion` exists to
|
||||
* invalidate cached results when a tool's transcode behavior changes
|
||||
* without any argument changing.
|
||||
*/
|
||||
export function computePolicyFingerprint(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
toolVersion = '1',
|
||||
): string {
|
||||
const tunables: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (!FINGERPRINT_EXCLUDED_KEYS.has(k) && v !== undefined) {
|
||||
tunables[k] = v;
|
||||
}
|
||||
}
|
||||
return createHash('sha256')
|
||||
.update(`${toolName}\n${stableStringify(tunables)}\n${toolVersion}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent map from `(originalSha256, policyFingerprint)` to the
|
||||
* degraded derivative's identity (decision D2). Lives at
|
||||
* `.qwen/omni/policy-cache.json`; a hit whose object still exists in
|
||||
* `objects/` lets the orchestrator skip a minutes-long transcode. The
|
||||
* existence check is the orchestrator's job — this cache only answers
|
||||
* "what did this policy produce last time".
|
||||
*
|
||||
* File mechanics (serialized ops, atomic writes, corrupt backup+rebuild,
|
||||
* unreadable-file no-op) are shared with the upload cache via
|
||||
* {@link OmniJsonCacheFile}. Entries carry no TTL: identities are
|
||||
* content-addressed and never go stale — they are invalidated
|
||||
* explicitly when the underlying object disappears (GC/corruption).
|
||||
*/
|
||||
export class OmniDegradationCache {
|
||||
private readonly file: OmniJsonCacheFile<DegradationCacheEntry>;
|
||||
|
||||
constructor(omniRootDir: string) {
|
||||
this.file = new OmniJsonCacheFile(
|
||||
path.join(omniRootDir, 'policy-cache.json'),
|
||||
'omni:policy-cache',
|
||||
);
|
||||
}
|
||||
|
||||
private key(originalSha256: string, policyFingerprint: string): string {
|
||||
return `${originalSha256}|${policyFingerprint}`;
|
||||
}
|
||||
|
||||
async get(
|
||||
originalSha256: string,
|
||||
policyFingerprint: string,
|
||||
): Promise<DegradationCacheEntry | null> {
|
||||
return this.file.access<DegradationCacheEntry | null>(null, (entries) => {
|
||||
const key = this.key(originalSha256, policyFingerprint);
|
||||
const entry = entries[key];
|
||||
if (!entry) return { result: null };
|
||||
// The cache file sits inside the workspace (`.qwen/omni/`), so a
|
||||
// hostile repository can ship a crafted one. Entries are only
|
||||
// trusted when every field that later becomes a filesystem path or
|
||||
// a model-visible text is well-formed: the hash must be exactly
|
||||
// 64-hex (it addresses the object store), the extension a single
|
||||
// dotted component (no traversal segments), and the disclosure
|
||||
// non-empty (lossy reuse without disclosure would silently break
|
||||
// the D8 invariant) and bounded (an unbounded field served
|
||||
// verbatim into model-visible content is a prompt-stuffing
|
||||
// channel). Malformed entries are dropped, not served —
|
||||
// the orchestrator then re-derives and overwrites them.
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/.test(entry.degradedSha256) ||
|
||||
typeof entry.extension !== 'string' ||
|
||||
!OBJECT_EXTENSION_RE.test(entry.extension) ||
|
||||
typeof entry.disclosure !== 'string' ||
|
||||
entry.disclosure.length === 0 ||
|
||||
entry.disclosure.length > MAX_CACHED_DISCLOSURE_LENGTH ||
|
||||
typeof entry.mimeType !== 'string' ||
|
||||
entry.mimeType.length === 0 ||
|
||||
(entry.role !== undefined &&
|
||||
(typeof entry.role !== 'string' || entry.role.length === 0))
|
||||
) {
|
||||
delete entries[key];
|
||||
return { result: null, changed: true };
|
||||
}
|
||||
return { result: entry };
|
||||
});
|
||||
}
|
||||
|
||||
async put(
|
||||
originalSha256: string,
|
||||
policyFingerprint: string,
|
||||
entry: Omit<DegradationCacheEntry, 'createdAt'>,
|
||||
): Promise<void> {
|
||||
return this.file.access(undefined, (entries) => {
|
||||
entries[this.key(originalSha256, policyFingerprint)] = {
|
||||
...entry,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return { result: undefined, changed: true };
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop every policy result derived FROM the object (source object
|
||||
* gone/corrupt — GC cascade). */
|
||||
async removeByOriginalSha256(originalSha256: string): Promise<void> {
|
||||
return this.file.access(undefined, (entries) => {
|
||||
const prefix = `${originalSha256}|`;
|
||||
let changed = false;
|
||||
for (const k of Object.keys(entries)) {
|
||||
if (k.startsWith(prefix)) {
|
||||
delete entries[k];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return { result: undefined, changed };
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop every entry POINTING AT the derived object (derivative
|
||||
* gone/corrupt — the next run must re-transcode, not chase a missing
|
||||
* object). */
|
||||
async removeByDegradedSha256(degradedSha256: string): Promise<void> {
|
||||
return this.file.access(undefined, (entries) => {
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
if (v.degradedSha256 === degradedSha256) {
|
||||
delete entries[k];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return { result: undefined, changed };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MediaPolicyToolDescriptor } from '../../tools/tools.js';
|
||||
import type { OmniPolicyToolsSettings } from './types.js';
|
||||
import {
|
||||
evaluateMediaPolicyToolCall,
|
||||
isMediaPolicyToolHiddenFromModel,
|
||||
projectMediaPolicyToolDeclaration,
|
||||
resolveMediaPolicyModelAccess,
|
||||
type MediaPolicyConfigView,
|
||||
} from './model-access.js';
|
||||
|
||||
const DESCRIPTOR: MediaPolicyToolDescriptor = {
|
||||
kind: 'media_policy',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [{ kind: 'media', required: true }],
|
||||
};
|
||||
|
||||
const configWith = (
|
||||
settings: OmniPolicyToolsSettings | undefined,
|
||||
): MediaPolicyConfigView => ({
|
||||
getOmniPolicyToolsSettings: () => settings,
|
||||
});
|
||||
|
||||
const policyTool = (name = 'omni_compress_image') => ({
|
||||
name,
|
||||
mediaPolicyDescriptor: DESCRIPTOR,
|
||||
});
|
||||
|
||||
const ordinaryTool = (name = 'run_shell_command') => ({ name });
|
||||
|
||||
describe('resolveMediaPolicyModelAccess', () => {
|
||||
it('defaults to disabled with empty projections when settings are absent', () => {
|
||||
expect(resolveMediaPolicyModelAccess({}, 'omni_compress_image')).toEqual({
|
||||
enabled: false,
|
||||
defaultArguments: {},
|
||||
lockedArguments: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to disabled when the tool has no settings entry', () => {
|
||||
const config = configWith({
|
||||
other_tool: { modelAccess: { enabled: true } },
|
||||
});
|
||||
expect(
|
||||
resolveMediaPolicyModelAccess(config, 'omni_compress_image').enabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reads enabled + argument projections when well-formed', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
defaultArguments: { quality: 80 },
|
||||
lockedArguments: { output_dir: '/tmp/objects' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
resolveMediaPolicyModelAccess(config, 'omni_compress_image'),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultArguments: { quality: 80 },
|
||||
lockedArguments: { output_dir: '/tmp/objects' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null tombstone entry', { omni_compress_image: null }],
|
||||
['non-object modelAccess', { omni_compress_image: { modelAccess: 'yes' } }],
|
||||
[
|
||||
'array modelAccess',
|
||||
{ omni_compress_image: { modelAccess: [{ enabled: true }] } },
|
||||
],
|
||||
[
|
||||
'truthy non-boolean enabled',
|
||||
{ omni_compress_image: { modelAccess: { enabled: 'true' } } },
|
||||
],
|
||||
])('fails closed on malformed settings: %s', (_label, raw) => {
|
||||
const config = configWith(raw as unknown as OmniPolicyToolsSettings);
|
||||
expect(
|
||||
resolveMediaPolicyModelAccess(config, 'omni_compress_image').enabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores malformed argument projections but keeps enabled', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
defaultArguments: 'quality=80',
|
||||
lockedArguments: ['output_dir'],
|
||||
},
|
||||
},
|
||||
} as unknown as OmniPolicyToolsSettings);
|
||||
expect(
|
||||
resolveMediaPolicyModelAccess(config, 'omni_compress_image'),
|
||||
).toEqual({ enabled: true, defaultArguments: {}, lockedArguments: {} });
|
||||
});
|
||||
|
||||
it('reads description and parameterSchema when well-formed', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
description: 'Compress an image.',
|
||||
parameterSchema: { properties: { quality: { maximum: 90 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const access = resolveMediaPolicyModelAccess(config, 'omni_compress_image');
|
||||
expect(access.description).toBe('Compress an image.');
|
||||
expect(access.parameterSchema).toEqual({
|
||||
properties: { quality: { maximum: 90 } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty description', { description: '' }],
|
||||
['non-string description', { description: 42 }],
|
||||
['array parameterSchema', { parameterSchema: [] }],
|
||||
['string parameterSchema', { parameterSchema: '{}' }],
|
||||
])('drops a malformed declaration projection: %s', (_label, modelAccess) => {
|
||||
const config = configWith({
|
||||
omni_compress_image: { modelAccess },
|
||||
} as unknown as OmniPolicyToolsSettings);
|
||||
const access = resolveMediaPolicyModelAccess(config, 'omni_compress_image');
|
||||
expect(access.description).toBeUndefined();
|
||||
expect(access.parameterSchema).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectMediaPolicyToolDeclaration', () => {
|
||||
const NATIVE = {
|
||||
name: 'omni_compress_image',
|
||||
description: 'Native description.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inputPath: { type: 'string', description: 'Source path.' },
|
||||
outputDir: { type: 'string' },
|
||||
maxDimension: { type: 'number', minimum: 1 },
|
||||
quality: { type: 'number', minimum: 1, maximum: 100 },
|
||||
},
|
||||
required: ['inputPath', 'outputDir'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
it('returns the native declaration unchanged without modelAccess settings', () => {
|
||||
expect(projectMediaPolicyToolDeclaration({}, NATIVE)).toEqual(NATIVE);
|
||||
});
|
||||
|
||||
it('removes lockedArguments keys from properties AND required', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { outputDir: '/staging' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(projectMediaPolicyToolDeclaration(config, NATIVE)).toEqual({
|
||||
name: 'omni_compress_image',
|
||||
description: 'Native description.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inputPath: { type: 'string', description: 'Source path.' },
|
||||
maxDimension: { type: 'number', minimum: 1 },
|
||||
quality: { type: 'number', minimum: 1, maximum: 100 },
|
||||
},
|
||||
required: ['inputPath'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows to parameterSchema properties, merging overrides over native constraints', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { inputPath: '/x', outputDir: '/y' },
|
||||
parameterSchema: {
|
||||
properties: {
|
||||
maxDimension: { maximum: 4096, description: 'Longest edge.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(projectMediaPolicyToolDeclaration(config, NATIVE)).toEqual({
|
||||
name: 'omni_compress_image',
|
||||
description: 'Native description.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
maxDimension: {
|
||||
type: 'number', // native constraint preserved…
|
||||
minimum: 1,
|
||||
maximum: 4096, // …override merged on top
|
||||
description: 'Longest edge.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('is narrowing-only: a projection property with no native counterpart is ignored', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
parameterSchema: {
|
||||
properties: {
|
||||
quality: {},
|
||||
madeUp: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const declaration = projectMediaPolicyToolDeclaration(config, NATIVE);
|
||||
const schema = declaration.parametersJsonSchema as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(schema.properties)).toEqual(['quality']);
|
||||
});
|
||||
|
||||
it('never re-adds a locked key even when parameterSchema names it', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { outputDir: '/staging' },
|
||||
parameterSchema: {
|
||||
properties: { outputDir: {}, quality: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const declaration = projectMediaPolicyToolDeclaration(config, NATIVE);
|
||||
const schema = declaration.parametersJsonSchema as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(schema.properties)).toEqual(['quality']);
|
||||
});
|
||||
|
||||
it('overrides the description when configured', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: { enabled: true, description: 'Model-facing text.' },
|
||||
},
|
||||
});
|
||||
expect(projectMediaPolicyToolDeclaration(config, NATIVE).description).toBe(
|
||||
'Model-facing text.',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a non-record native schema through, still applying the description override', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
description: 'Overridden.',
|
||||
lockedArguments: { outputDir: '/staging' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const native = {
|
||||
name: 'omni_compress_image',
|
||||
description: 'Native description.',
|
||||
parametersJsonSchema: undefined,
|
||||
};
|
||||
expect(projectMediaPolicyToolDeclaration(config, native)).toEqual({
|
||||
name: 'omni_compress_image',
|
||||
description: 'Overridden.',
|
||||
parametersJsonSchema: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides descriptor operatorOnlyParams like locked keys, even with no modelAccess settings', () => {
|
||||
// Endpoint/credential parameters must never be model-visible: with an
|
||||
// enabled-but-unprojected modelAccess config (and even with NO config
|
||||
// at all) the operator-only keys are removed from properties and
|
||||
// required exactly like lockedArguments keys.
|
||||
const native = {
|
||||
name: 'omni_transcribe_audio',
|
||||
description: 'Transcribe.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inputPath: { type: 'string' },
|
||||
baseUrl: { type: 'string' },
|
||||
apiKeyEnv: { type: 'string' },
|
||||
},
|
||||
required: ['inputPath', 'baseUrl'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
operatorOnlyParams: ['baseUrl', 'apiKeyEnv'] as const,
|
||||
};
|
||||
for (const config of [
|
||||
{},
|
||||
configWith({ omni_transcribe_audio: { modelAccess: { enabled: true } } }),
|
||||
]) {
|
||||
const declaration = projectMediaPolicyToolDeclaration(config, native);
|
||||
const schema = declaration.parametersJsonSchema as {
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
};
|
||||
expect(Object.keys(schema.properties)).toEqual(['inputPath']);
|
||||
expect(schema.required).toEqual(['inputPath']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMediaPolicyToolHiddenFromModel', () => {
|
||||
it('never hides ordinary tools', () => {
|
||||
expect(isMediaPolicyToolHiddenFromModel({}, ordinaryTool())).toBe(false);
|
||||
});
|
||||
|
||||
it('hides media-policy tools by default', () => {
|
||||
expect(isMediaPolicyToolHiddenFromModel({}, policyTool())).toBe(true);
|
||||
});
|
||||
|
||||
it('reveals media-policy tools when modelAccess.enabled is true', () => {
|
||||
const config = configWith({
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
});
|
||||
expect(isMediaPolicyToolHiddenFromModel(config, policyTool())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateMediaPolicyToolCall', () => {
|
||||
it('passes ordinary tools untouched regardless of settings', () => {
|
||||
const args = { command: 'ls' };
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
run_shell_command: { modelAccess: { enabled: false } },
|
||||
}),
|
||||
tool: ordinaryTool(),
|
||||
args,
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toEqual({ outcome: 'pass', args });
|
||||
});
|
||||
|
||||
it('treats a missing origin as a model call (fail closed)', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {},
|
||||
tool: policyTool(),
|
||||
args: {},
|
||||
executionOrigin: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects model calls of media-policy tools by default, citing the setting', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {},
|
||||
tool: policyTool(),
|
||||
args: {},
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
});
|
||||
expect((result as { message: string }).message).toContain(
|
||||
'"omni.processing.policyTools.omni_compress_image.modelAccess.enabled": true',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects client-origin calls the same as model calls when disabled', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {},
|
||||
tool: policyTool(),
|
||||
args: {},
|
||||
executionOrigin: { kind: 'client' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a forged fixed_policy origin on a non-media-policy tool', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {},
|
||||
tool: ordinaryTool(),
|
||||
args: { command: 'rm -rf /' },
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'forged',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
});
|
||||
expect((result as { message: string }).message).toContain(
|
||||
'not a media policy tool',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes fixed_policy calls of media-policy tools untouched, ignoring modelAccess', () => {
|
||||
const args = { quality: 55, output_dir: '/staging' };
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
// Disabled + locked keys present in args: neither applies to
|
||||
// fixed-policy calls.
|
||||
config: configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: false,
|
||||
lockedArguments: { output_dir: '/elsewhere' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args,
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'image-compress-v1',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ outcome: 'pass', args });
|
||||
expect((result as { args: Record<string, unknown> }).args).toBe(args);
|
||||
});
|
||||
|
||||
it('rejects explicit lockedArguments keys as invalid_params, naming the keys', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { output_dir: '/tmp', format: 'webp' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args: { output_dir: '/evil', format: 'exe', quality: 50 },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
const message = (result as { message: string }).message;
|
||||
expect(message).toContain('"output_dir"');
|
||||
expect(message).toContain('"format"');
|
||||
});
|
||||
|
||||
it('rejects a locked key even when passed as undefined', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { output_dir: '/tmp' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args: { output_dir: undefined },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges defaults < model args < lockedArguments on pass', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
defaultArguments: { quality: 80, format: 'jpeg' },
|
||||
lockedArguments: { output_dir: '/objects' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args: { quality: 55, source: 'a.png' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: 'pass',
|
||||
args: {
|
||||
quality: 55, // model overrides default
|
||||
format: 'jpeg', // default fills omitted
|
||||
source: 'a.png', // model-only key preserved
|
||||
output_dir: '/objects', // locked always injected
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes enabled tools with no projections through unchanged', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args: { source: 'a.png' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toEqual({ outcome: 'pass', args: { source: 'a.png' } });
|
||||
});
|
||||
|
||||
const exfilTool = (name = 'omni_transcribe_audio') => ({
|
||||
name,
|
||||
mediaPolicyDescriptor: {
|
||||
...DESCRIPTOR,
|
||||
operatorOnlyParams: ['baseUrl', 'apiKeyEnv'],
|
||||
} satisfies MediaPolicyToolDescriptor,
|
||||
});
|
||||
|
||||
it('rejects gated calls naming a descriptor operator-only key (credential exfiltration)', () => {
|
||||
// The attack this gate exists for: injected content telling the model
|
||||
// to point another provider's key at an attacker host.
|
||||
for (const originKind of ['model', 'client'] as const) {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_transcribe_audio: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
tool: exfilTool(),
|
||||
args: {
|
||||
inputPath: '/a.wav',
|
||||
baseUrl: 'https://evil.example/v1',
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
},
|
||||
executionOrigin: { kind: originKind },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
const message = (result as { message: string }).message;
|
||||
expect(message).toContain('"baseUrl"');
|
||||
expect(message).toContain('"apiKeyEnv"');
|
||||
expect(message).toContain('operator-only');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an operator-only key even when passed as undefined', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_transcribe_audio: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
tool: exfilTool(),
|
||||
args: { inputPath: '/a.wav', apiKeyEnv: undefined },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows operator-only values via defaultArguments and fixed_policy args', () => {
|
||||
// Operator surfaces stay functional: modelAccess.defaultArguments may
|
||||
// inject the endpoint config the caller is forbidden to name…
|
||||
const gated = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_transcribe_audio: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
defaultArguments: { baseUrl: 'https://asr.corp/v1' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tool: exfilTool(),
|
||||
args: { inputPath: '/a.wav' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(gated).toEqual({
|
||||
outcome: 'pass',
|
||||
args: { inputPath: '/a.wav', baseUrl: 'https://asr.corp/v1' },
|
||||
});
|
||||
|
||||
// …and fixed-policy calls (operator-authored settings.json arguments)
|
||||
// bypass the gate entirely, operator-only keys included.
|
||||
const args = { inputPath: '/a.wav', apiKeyEnv: 'CORP_ASR_KEY' };
|
||||
const fixed = evaluateMediaPolicyToolCall({
|
||||
config: configWith(undefined),
|
||||
tool: exfilTool(),
|
||||
args,
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'audio-transcribe-v1',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
});
|
||||
expect(fixed).toEqual({ outcome: 'pass', args });
|
||||
});
|
||||
|
||||
describe('resourceId input resolution (M §5.2)', () => {
|
||||
const bindingFor = (id: string, fileRef: string) => ({
|
||||
resourceId: id,
|
||||
fileId: 'f1',
|
||||
fileVersionId: 'v1',
|
||||
rootFileId: 'f1',
|
||||
fileRef,
|
||||
mediaType: 'image' as const,
|
||||
});
|
||||
const registryWith = (bindings: Record<string, string>) => ({
|
||||
resolve: (id: string) =>
|
||||
bindings[id] !== undefined ? bindingFor(id, bindings[id]) : undefined,
|
||||
// Reverse lookup by locator — lets the gate accept the absolute PATH the
|
||||
// annotation shows for a model-visible local file (same as active recall).
|
||||
resolveByFileRef: (ref: string) => {
|
||||
const id = Object.keys(bindings).find((k) => bindings[k] === ref);
|
||||
return id ? bindingFor(id, ref) : undefined;
|
||||
},
|
||||
});
|
||||
const enabledConfig = (bindings: Record<string, string>) => ({
|
||||
...configWith({
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
getOmniMediaResourceRegistry: () => registryWith(bindings) as never,
|
||||
});
|
||||
|
||||
it('resolves a session handle to inputPath and drops resourceId', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: enabledConfig({ 'media-1-ab': '/media/movie.mkv' }),
|
||||
tool: policyTool(),
|
||||
args: { resourceId: 'media-1-ab', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: 'pass',
|
||||
args: { inputPath: '/media/movie.mkv', outputDir: '/out' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the absolute PATH shown for a local file (path form) to inputPath', () => {
|
||||
// A model-visible local file is annotated with its path, not a handle.
|
||||
// A model that puts that path in resourceId (following the stale schema
|
||||
// text) must still resolve — the gate reverses it via resolveByFileRef,
|
||||
// the same as active recall — rather than burning a turn on rejection.
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: enabledConfig({ 'media-1-ab': '/media/movie.mkv' }),
|
||||
tool: policyTool(),
|
||||
args: { resourceId: '/media/movie.mkv', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: 'pass',
|
||||
args: { inputPath: '/media/movie.mkv', outputDir: '/out' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a handle this session never issued', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: enabledConfig({}),
|
||||
tool: policyTool(),
|
||||
args: { resourceId: 'media-9-zz', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
expect((result as { message: string }).message).toContain(
|
||||
'matches no media delivered this session',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a call on a config with no session registry', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: configWith({
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
tool: policyTool(),
|
||||
args: { resourceId: 'media-1-ab', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects naming both inputPath and resourceId', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: enabledConfig({ 'media-1-ab': '/media/movie.mkv' }),
|
||||
tool: policyTool(),
|
||||
args: {
|
||||
resourceId: 'media-1-ab',
|
||||
inputPath: '/elsewhere.png',
|
||||
outputDir: '/out',
|
||||
},
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
expect((result as { message: string }).message).toContain('exactly one');
|
||||
});
|
||||
|
||||
it('cannot sidestep an operator-pinned inputPath via a handle', () => {
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {
|
||||
...configWith({
|
||||
omni_compress_image: {
|
||||
modelAccess: {
|
||||
enabled: true,
|
||||
lockedArguments: { inputPath: '/pinned.png' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
getOmniMediaResourceRegistry: () =>
|
||||
registryWith({ 'media-1-ab': '/media/movie.mkv' }) as never,
|
||||
},
|
||||
tool: policyTool(),
|
||||
args: { resourceId: 'media-1-ab', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
// The resolved inputPath collides with the locked key — rejected
|
||||
// exactly like naming the locked key directly.
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a handle whose modality the tool does not accept', () => {
|
||||
// The fixed-policy path validates modality at startup; a gated caller
|
||||
// holds only opaque handles, so mixing two up must fail as a
|
||||
// correctable parameter error rather than as a spawned ffmpeg that
|
||||
// burns the tool timeout and returns an opaque stderr tail.
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: {
|
||||
...configWith({
|
||||
omni_compress_image: { modelAccess: { enabled: true } },
|
||||
}),
|
||||
getOmniMediaResourceRegistry: () =>
|
||||
({
|
||||
resolve: () => ({
|
||||
resourceId: 'media-1-ab',
|
||||
fileId: 'f1',
|
||||
fileVersionId: 'v1',
|
||||
rootFileId: 'f1',
|
||||
fileRef: '/media/movie.mkv',
|
||||
mediaType: 'video' as const,
|
||||
}),
|
||||
}) as never,
|
||||
},
|
||||
tool: policyTool(),
|
||||
args: { resourceId: 'media-1-ab', outputDir: '/out' },
|
||||
executionOrigin: { kind: 'model' },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
});
|
||||
expect((result as { message: string }).message).toContain(
|
||||
'names video media',
|
||||
);
|
||||
});
|
||||
|
||||
it('never resolves handles for fixed_policy calls (args pass untouched)', () => {
|
||||
const args = { resourceId: 'media-1-ab', outputDir: '/out' };
|
||||
const result = evaluateMediaPolicyToolCall({
|
||||
config: enabledConfig({ 'media-1-ab': '/media/movie.mkv' }),
|
||||
tool: policyTool(),
|
||||
args,
|
||||
executionOrigin: {
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'p1',
|
||||
stage: 'preprocessing',
|
||||
},
|
||||
});
|
||||
// RESERVED_ARGUMENT_KEYS already bans resourceId in fixed-policy
|
||||
// arguments; the gate's job is only to leave fixed calls alone.
|
||||
expect(result).toEqual({ outcome: 'pass', args });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FunctionDeclaration } from '@google/genai';
|
||||
import type { ToolExecutionOrigin } from '../../core/turn.js';
|
||||
import type { MediaPolicyToolDescriptor } from '../../tools/tools.js';
|
||||
import type { OmniMediaRegistryView } from '../../services/media-memory/registry.js';
|
||||
import { resolveMediaReference } from '../../services/media-memory/registry.js';
|
||||
import { isPlainRecord } from './types.js';
|
||||
import type {
|
||||
MediaPolicyToolConfigView,
|
||||
OmniPolicyToolModelAccessSettings,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Shared modelAccess resolver + call gate for omni media-policy tools.
|
||||
*
|
||||
* Media-policy tools are always registered (the fixed-policy orchestrator
|
||||
* must be able to find them), but they are fixed-policy-only by default:
|
||||
* only `omni.processing.policyTools.<name>.modelAccess.enabled: true`
|
||||
* makes them callable by the model or by direct client calls. The gate
|
||||
* must hold on every surface at once — declaration lists, ToolSearch
|
||||
* keyword + select, the CoreToolScheduler, and ACP's Session.runTool() —
|
||||
* so all of them call into this module rather than re-deriving the rule.
|
||||
*/
|
||||
|
||||
/** Historical name this module exported for its config view; kept as an
|
||||
* alias of the shared type so existing importers stay valid. */
|
||||
export type MediaPolicyConfigView = MediaPolicyToolConfigView;
|
||||
|
||||
/** Resolved modelAccess for one tool: always concrete (defaults applied). */
|
||||
export interface ResolvedMediaPolicyModelAccess {
|
||||
/** Whether model/client-origin calls are allowed. Default false. */
|
||||
enabled: boolean;
|
||||
defaultArguments: Record<string, unknown>;
|
||||
lockedArguments: Record<string, unknown>;
|
||||
/** Model-facing description override for the declaration projection. */
|
||||
description?: string;
|
||||
/** Narrowing-only projection over the native parameter schema. */
|
||||
parameterSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `omni.processing.policyTools.<toolName>.modelAccess` leniently:
|
||||
* anything absent or malformed resolves to the fail-closed default
|
||||
* (`enabled: false`, no argument projection).
|
||||
*/
|
||||
export function resolveMediaPolicyModelAccess(
|
||||
config: MediaPolicyConfigView,
|
||||
toolName: string,
|
||||
): ResolvedMediaPolicyModelAccess {
|
||||
const entry = config.getOmniPolicyToolsSettings?.()?.[toolName];
|
||||
const modelAccess: OmniPolicyToolModelAccessSettings | undefined =
|
||||
isPlainRecord(entry) && isPlainRecord(entry['modelAccess'])
|
||||
? (entry['modelAccess'] as OmniPolicyToolModelAccessSettings)
|
||||
: undefined;
|
||||
return {
|
||||
enabled: modelAccess?.enabled === true,
|
||||
defaultArguments: isPlainRecord(modelAccess?.defaultArguments)
|
||||
? modelAccess.defaultArguments
|
||||
: {},
|
||||
lockedArguments: isPlainRecord(modelAccess?.lockedArguments)
|
||||
? modelAccess.lockedArguments
|
||||
: {},
|
||||
description:
|
||||
typeof modelAccess?.description === 'string' &&
|
||||
modelAccess.description !== ''
|
||||
? modelAccess.description
|
||||
: undefined,
|
||||
parameterSchema: isPlainRecord(modelAccess?.parameterSchema)
|
||||
? modelAccess.parameterSchema
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-visible declaration for a media-policy tool (decision D6, policy
|
||||
* design §9.4): the projection is applied at the SINGLE `schema` getter
|
||||
* the declaration surfaces read, while validation keeps using the native
|
||||
* schema (the harness-injected arguments the projection hides must stay
|
||||
* valid).
|
||||
*
|
||||
* Shape: the native parametersJsonSchema minus every
|
||||
* `modelAccess.lockedArguments` key (removed from `properties` and
|
||||
* `required` — the model must not see arguments it is forbidden to pass),
|
||||
* then — when `modelAccess.parameterSchema` is configured — narrowed to
|
||||
* the properties it names, with each named property's constraints merged
|
||||
* over the native ones. A projection property with no native counterpart
|
||||
* is ignored (narrowing-only: the projection can never ADD surface).
|
||||
* `modelAccess.description` overrides the tool description when set.
|
||||
*/
|
||||
export function projectMediaPolicyToolDeclaration(
|
||||
config: MediaPolicyConfigView,
|
||||
native: {
|
||||
name: string;
|
||||
description: string;
|
||||
parametersJsonSchema: unknown;
|
||||
/** Descriptor-declared operator-only keys — hidden from the model
|
||||
* exactly like lockedArguments (the model must not see arguments the
|
||||
* gate forbids it to pass). */
|
||||
operatorOnlyParams?: readonly string[];
|
||||
},
|
||||
): FunctionDeclaration {
|
||||
const access = resolveMediaPolicyModelAccess(config, native.name);
|
||||
const description = access.description ?? native.description;
|
||||
const schema = isPlainRecord(native.parametersJsonSchema)
|
||||
? native.parametersJsonSchema
|
||||
: undefined;
|
||||
const nativeProps =
|
||||
schema && isPlainRecord(schema['properties'])
|
||||
? schema['properties']
|
||||
: undefined;
|
||||
if (!schema || !nativeProps) {
|
||||
return {
|
||||
name: native.name,
|
||||
description,
|
||||
parametersJsonSchema: native.parametersJsonSchema,
|
||||
};
|
||||
}
|
||||
const lockedKeys = new Set([
|
||||
...Object.keys(access.lockedArguments),
|
||||
...(native.operatorOnlyParams ?? []),
|
||||
]);
|
||||
const narrowProps =
|
||||
access.parameterSchema &&
|
||||
isPlainRecord(access.parameterSchema['properties'])
|
||||
? (access.parameterSchema['properties'] as Record<string, unknown>)
|
||||
: undefined;
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(nativeProps)) {
|
||||
if (lockedKeys.has(key)) continue;
|
||||
if (narrowProps && !(key in narrowProps)) continue;
|
||||
const override = narrowProps?.[key];
|
||||
properties[key] =
|
||||
isPlainRecord(override) && isPlainRecord(value)
|
||||
? { ...value, ...override }
|
||||
: value;
|
||||
}
|
||||
const required = Array.isArray(schema['required'])
|
||||
? (schema['required'] as unknown[]).filter(
|
||||
(key): key is string => typeof key === 'string' && key in properties,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
name: native.name,
|
||||
description,
|
||||
parametersJsonSchema: {
|
||||
...schema,
|
||||
properties,
|
||||
...(required !== undefined ? { required } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool must be hidden from model-facing declaration surfaces
|
||||
* (initial declarations, subagent filtered declarations, ToolSearch
|
||||
* keyword candidates and exact-select). True iff the tool is a
|
||||
* media-policy tool whose modelAccess is not enabled.
|
||||
*/
|
||||
export function isMediaPolicyToolHiddenFromModel(
|
||||
config: MediaPolicyConfigView,
|
||||
tool: { name: string; mediaPolicyDescriptor?: MediaPolicyToolDescriptor },
|
||||
): boolean {
|
||||
if (!tool.mediaPolicyDescriptor) return false;
|
||||
return !resolveMediaPolicyModelAccess(config, tool.name).enabled;
|
||||
}
|
||||
|
||||
/** Outcome of {@link evaluateMediaPolicyToolCall}. */
|
||||
export type MediaPolicyCallGateResult =
|
||||
| {
|
||||
outcome: 'pass';
|
||||
/** Arguments to build the invocation with. For gated model/client
|
||||
* calls this is defaults + caller args + lockedArguments; for
|
||||
* everything else it is the caller args unchanged. */
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
outcome: 'reject';
|
||||
/** 'execution_denied' → the call may not run at all;
|
||||
* 'invalid_params' → a parameter-level error the model can fix. */
|
||||
reason: 'execution_denied' | 'invalid_params';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execution-time gate applied by CoreToolScheduler and ACP Session.runTool
|
||||
* before an invocation is built:
|
||||
*
|
||||
* - a `fixed_policy` origin on a NON-media-policy tool is rejected
|
||||
* (defense in depth — origins are never deserialized, but a forged
|
||||
* origin must not become a permission bypass for Shell/Edit/MCP);
|
||||
* - a `fixed_policy` origin on a media-policy tool passes untouched (the
|
||||
* orchestrator already resolved its own `arguments`; modelAccess does
|
||||
* not apply to fixed calls);
|
||||
* - a model/client-origin call of a media-policy tool requires
|
||||
* `modelAccess.enabled`, may reference its input by opaque session
|
||||
* `resourceId` INSTEAD of `inputPath` (resolved here to the real
|
||||
* locator — memory design M §5.2; for path-less media the model never
|
||||
* learns the path, while a model-visible local file was shown its own
|
||||
* path and may name it here or in `inputPath`),
|
||||
* must not name any lockedArguments key explicitly, and gets
|
||||
* defaults + lockedArguments merged in;
|
||||
* - everything else passes untouched.
|
||||
*
|
||||
* A missing origin fails closed as `{ kind: 'model' }`.
|
||||
*/
|
||||
export function evaluateMediaPolicyToolCall(params: {
|
||||
config: MediaPolicyConfigView & OmniMediaRegistryView;
|
||||
tool: { name: string; mediaPolicyDescriptor?: MediaPolicyToolDescriptor };
|
||||
args: Record<string, unknown>;
|
||||
executionOrigin: ToolExecutionOrigin | undefined;
|
||||
}): MediaPolicyCallGateResult {
|
||||
const { config, tool } = params;
|
||||
let args = params.args;
|
||||
const origin = params.executionOrigin ?? { kind: 'model' };
|
||||
|
||||
if (origin.kind === 'fixed_policy') {
|
||||
if (!tool.mediaPolicyDescriptor) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
message:
|
||||
`Tool "${tool.name}" cannot run with a fixed-policy execution ` +
|
||||
`origin: it is not a media policy tool.`,
|
||||
};
|
||||
}
|
||||
return { outcome: 'pass', args };
|
||||
}
|
||||
|
||||
if (!tool.mediaPolicyDescriptor) {
|
||||
return { outcome: 'pass', args };
|
||||
}
|
||||
|
||||
const access = resolveMediaPolicyModelAccess(config, tool.name);
|
||||
if (!access.enabled) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'execution_denied',
|
||||
message:
|
||||
`Tool "${tool.name}" is an omni media policy tool reserved for ` +
|
||||
`fixed-policy orchestration. Direct calls require ` +
|
||||
`"omni.processing.policyTools.${tool.name}.modelAccess.enabled": true.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Session-reference input (M §5.2): a gated caller may name its source by
|
||||
// the reference it was shown in a 【媒体路径】 / 【媒体资源】 annotation
|
||||
// instead of a real path — the opaque handle minted at delivery/recall, or,
|
||||
// for a model-visible local file, the absolute path itself.
|
||||
// `resolveMediaReference` accepts either form (reversing a displayed path
|
||||
// back to its binding, same as active recall). Resolution happens BEFORE the
|
||||
// lockedArguments check so a resolved inputPath cannot sidestep an
|
||||
// operator-pinned input.
|
||||
if (typeof args['resourceId'] === 'string') {
|
||||
if (args['inputPath'] !== undefined) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
message:
|
||||
`Invalid parameters for tool "${tool.name}": provide exactly ` +
|
||||
`one of "inputPath" or "resourceId", not both.`,
|
||||
};
|
||||
}
|
||||
const registry = config.getOmniMediaResourceRegistry?.();
|
||||
const binding = registry
|
||||
? resolveMediaReference(registry, args['resourceId'])
|
||||
: undefined;
|
||||
if (!binding) {
|
||||
// Neither an issued handle nor the path of a file delivered this
|
||||
// session (fabricated or stale cross-session reference) — reject,
|
||||
// never guess (M §9.2 stance).
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
message:
|
||||
`Invalid parameters for tool "${tool.name}": resourceId ` +
|
||||
`"${args['resourceId']}" matches no media delivered this session. ` +
|
||||
`Use a handle or the absolute path from a 【媒体资源】 annotation ` +
|
||||
`or a recall result.`,
|
||||
};
|
||||
}
|
||||
// The handle's modality must be one this tool declares consuming. The
|
||||
// fixed-policy path validates this at startup; a gated caller holds
|
||||
// only opaque handles, so mixing two up is easy — and without this the
|
||||
// mistake becomes a spawned ffmpeg that burns the tool timeout and
|
||||
// returns an opaque stderr tail, instead of an instant parameter error
|
||||
// the caller can correct.
|
||||
const accepted = tool.mediaPolicyDescriptor.inputMediaTypes;
|
||||
if (accepted !== undefined && !accepted.includes(binding.mediaType)) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
message:
|
||||
`Invalid parameters for tool "${tool.name}": resourceId ` +
|
||||
`"${args['resourceId']}" names ${binding.mediaType} media, but ` +
|
||||
`this tool accepts ${accepted.join(', ')}. Pass a handle whose ` +
|
||||
`media type matches.`,
|
||||
};
|
||||
}
|
||||
const { resourceId: _resourceId, ...rest } = args;
|
||||
args = { ...rest, inputPath: binding.fileRef };
|
||||
}
|
||||
|
||||
const lockedKeys = Object.keys(access.lockedArguments);
|
||||
const violations = lockedKeys.filter((key) =>
|
||||
Object.prototype.hasOwnProperty.call(args, key),
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
message:
|
||||
`Invalid parameters for tool "${tool.name}": ` +
|
||||
`${violations.map((k) => `"${k}"`).join(', ')} ` +
|
||||
`${violations.length === 1 ? 'is' : 'are'} locked by configuration ` +
|
||||
`and must not be provided. Remove ${
|
||||
violations.length === 1 ? 'it' : 'them'
|
||||
} and retry.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Operator-only parameters (descriptor-declared, e.g. endpoint base URL
|
||||
// + credential env-var name): a gated caller must never set them — a
|
||||
// model-controlled endpoint/credential pair would let injected content
|
||||
// exfiltrate arbitrary environment secrets to an attacker host. They
|
||||
// remain settable through settings / defaultArguments / lockedArguments
|
||||
// (operator-controlled surfaces only).
|
||||
const operatorViolations = (
|
||||
tool.mediaPolicyDescriptor.operatorOnlyParams ?? []
|
||||
).filter((key) => Object.prototype.hasOwnProperty.call(args, key));
|
||||
if (operatorViolations.length > 0) {
|
||||
return {
|
||||
outcome: 'reject',
|
||||
reason: 'invalid_params',
|
||||
message:
|
||||
`Invalid parameters for tool "${tool.name}": ` +
|
||||
`${operatorViolations.map((k) => `"${k}"`).join(', ')} ` +
|
||||
`${operatorViolations.length === 1 ? 'is' : 'are'} operator-only ` +
|
||||
`(set via omni.processing.policyTools.${tool.name} configuration) ` +
|
||||
`and must not be provided by the caller. Remove ${
|
||||
operatorViolations.length === 1 ? 'it' : 'them'
|
||||
} and retry.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: 'pass',
|
||||
args: { ...access.defaultArguments, ...args, ...access.lockedArguments },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { PolicyArtifactBatch } from '../../core/turn.js';
|
||||
import type { MediaPolicyToolDescriptor } from '../../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_OMNI_MEMORY_CONFIG,
|
||||
MediaResourceRegistry,
|
||||
type MediaMemorySnapshot,
|
||||
} from '../../services/media-memory/index.js';
|
||||
import { OmniObjectStore } from '../storage.js';
|
||||
import { collectModelPolicyCall } from './model-call-collection.js';
|
||||
|
||||
// The content sniff stays REAL (magic bytes, in-process) — only the
|
||||
// ffprobe SUBPROCESS is stubbed. Under CI load the real ffprobe hit its
|
||||
// 15s kill timer and the collection silently skipped per D12, reading
|
||||
// as "0 executions" (observed twice on the hk runners). Probing adds
|
||||
// nothing this suite asserts; the subprocess only adds a load
|
||||
// dependency.
|
||||
vi.mock('../ffmpeg.js', () => ({
|
||||
probeMediaMetadata: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ width: 1, height: 1 } as never),
|
||||
}));
|
||||
|
||||
/** A 1x1 JPEG — recognizeMediaFile sniffs real bytes, so the artifact has
|
||||
* to be a genuine image. */
|
||||
const JPEG_1X1 = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
||||
'HBwcJC4nHywjLBwcKDcpLDA1NTU1HyU5PTgyNTL/wAALCAABAAEBAREA/8QAFAABAQAAAAAAAAAA' +
|
||||
'AAAAAAAAAAP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQR' +
|
||||
'AQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AKAA/9k=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const DESCRIPTOR: MediaPolicyToolDescriptor = {
|
||||
kind: 'media_policy',
|
||||
version: '3',
|
||||
inputMediaTypes: ['image'],
|
||||
outputs: [
|
||||
{ kind: 'media', required: true, mimeTypes: ['image/jpeg'], lossy: true },
|
||||
],
|
||||
};
|
||||
|
||||
describe('collectModelPolicyCall', () => {
|
||||
let tmpDir: string;
|
||||
let outputDir: string;
|
||||
let inputPath: string;
|
||||
let registry: MediaResourceRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omni-model-commit-'));
|
||||
outputDir = path.join(tmpDir, 'out');
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
inputPath = path.join(tmpDir, 'source.jpg');
|
||||
await fs.writeFile(inputPath, JPEG_1X1);
|
||||
await fs.writeFile(path.join(outputDir, 'derived.jpg'), JPEG_1X1);
|
||||
registry = new MediaResourceRegistry();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function bridgeConfig(overrides?: Record<string, unknown>): Config {
|
||||
return {
|
||||
storage: { getQwenDir: () => tmpDir },
|
||||
getOmniMemoryConfig: () => DEFAULT_OMNI_MEMORY_CONFIG,
|
||||
getOmniMediaResourceRegistry: () => registry,
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function batchOf(
|
||||
origin: PolicyArtifactBatch['executionOrigin'] = { kind: 'model' },
|
||||
): PolicyArtifactBatch {
|
||||
return {
|
||||
toolName: 'omni_downsample_image',
|
||||
invocationId: 'call-abc',
|
||||
executionOrigin: origin,
|
||||
artifacts: [
|
||||
{
|
||||
kind: 'image',
|
||||
storage: 'workspace',
|
||||
title: 'derived',
|
||||
workspacePath: 'derived.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
sizeBytes: JPEG_1X1.length,
|
||||
metadata: {
|
||||
omniDisclosure: 'downsampled to 1x1',
|
||||
omniRole: 'degraded',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function readSnapshot(): Promise<MediaMemorySnapshot> {
|
||||
const file = path.join(
|
||||
new OmniObjectStore(tmpDir).getOmniRootDir(),
|
||||
'memory.json',
|
||||
);
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(file, 'utf8')) as MediaMemorySnapshot;
|
||||
} catch {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
files: {},
|
||||
versions: {},
|
||||
executions: {},
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
it('commits a model-origin success through the same boundary', async () => {
|
||||
await collectModelPolicyCall({
|
||||
config: bridgeConfig(),
|
||||
batch: batchOf(),
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir, quality: 70 },
|
||||
});
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
const executions = Object.values(snapshot.executions);
|
||||
expect(executions).toHaveLength(1);
|
||||
expect(executions[0]).toMatchObject({
|
||||
toolName: 'omni_downsample_image',
|
||||
invocationId: 'call-abc',
|
||||
// The REAL origin is preserved — not rewritten as fixed_policy.
|
||||
executionOrigin: { kind: 'model' },
|
||||
toolVersion: '3',
|
||||
// Per-invocation plumbing is excluded from reproducible config.
|
||||
finalArguments: { quality: 70 },
|
||||
});
|
||||
|
||||
// One entry per declared output, and the derivative was promoted into
|
||||
// the object store BEFORE the record referencing it was written.
|
||||
const entries = Object.values(snapshot.entries);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
kind: 'derived_media',
|
||||
role: 'degraded',
|
||||
disclosure: 'downsampled to 1x1',
|
||||
});
|
||||
const derivedVersion = snapshot.versions[entries[0]!.derivedVersionId!]!;
|
||||
const objectRef = snapshot.files[derivedVersion.fileId]!.fileRef;
|
||||
expect(objectRef.startsWith(path.join(tmpDir, 'omni'))).toBe(true);
|
||||
await expect(fs.stat(objectRef)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('reuses the session binding for a handle-driven call', async () => {
|
||||
// The gate turned the model's resourceId into inputPath; the registry
|
||||
// still holds the identity, so the source is not re-recognized.
|
||||
const bound = registry.bind({
|
||||
fileId: 'f-src',
|
||||
fileVersionId: 'v-src',
|
||||
rootFileId: 'f-root',
|
||||
fileRef: inputPath,
|
||||
mediaType: 'image',
|
||||
});
|
||||
|
||||
await collectModelPolicyCall({
|
||||
config: bridgeConfig(),
|
||||
batch: batchOf(),
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir, resourceId: bound.resourceId },
|
||||
});
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
const execution = Object.values(snapshot.executions)[0]!;
|
||||
expect(execution.sourceVersionId).toBe('v-src');
|
||||
expect(execution.rootFileId).toBe('f-root');
|
||||
// resourceId is per-invocation plumbing, never reproducible config.
|
||||
expect(execution.finalArguments).not.toHaveProperty('resourceId');
|
||||
});
|
||||
|
||||
it('attributes the execution to the RE-BOUND version at a reverted path (R3-5)', async () => {
|
||||
// Read bytes A (v-1), edit to B and re-read (v-2), then revert to A and
|
||||
// re-read: the third bind re-delivers v-1 and refreshes its recency. A
|
||||
// model call resolves its source via resolveByFileRef, which must return
|
||||
// the version the model is CURRENTLY looking at (v-1), so the committed
|
||||
// sourceVersionId is v-1 — not the superseded v-2. Without the recency
|
||||
// refresh the execution mis-attributes to stale bytes.
|
||||
const first = registry.bind({
|
||||
fileId: 'f-src',
|
||||
fileVersionId: 'v-1',
|
||||
rootFileId: 'f-root',
|
||||
fileRef: inputPath,
|
||||
mediaType: 'image',
|
||||
});
|
||||
registry.bind({
|
||||
fileId: 'f-src',
|
||||
fileVersionId: 'v-2',
|
||||
rootFileId: 'f-root',
|
||||
fileRef: inputPath,
|
||||
mediaType: 'image',
|
||||
});
|
||||
const reverted = registry.bind({
|
||||
fileId: 'f-src',
|
||||
fileVersionId: 'v-1',
|
||||
rootFileId: 'f-root',
|
||||
fileRef: inputPath,
|
||||
mediaType: 'image',
|
||||
});
|
||||
expect(reverted).toBe(first); // idempotent re-bind of v-1
|
||||
expect(registry.resolveByFileRef(inputPath)?.fileVersionId).toBe('v-1');
|
||||
|
||||
await collectModelPolicyCall({
|
||||
config: bridgeConfig(),
|
||||
batch: batchOf(),
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir, resourceId: reverted.resourceId },
|
||||
});
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
const execution = Object.values(snapshot.executions)[0]!;
|
||||
expect(execution.sourceVersionId).toBe('v-1');
|
||||
});
|
||||
|
||||
it('leaves fixed-policy successes to the orchestrator', async () => {
|
||||
await collectModelPolicyCall({
|
||||
config: bridgeConfig(),
|
||||
batch: batchOf({
|
||||
kind: 'fixed_policy',
|
||||
policyId: 'img',
|
||||
stage: 'preprocessing',
|
||||
}),
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir },
|
||||
});
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
expect(Object.keys(snapshot.executions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does nothing when memory is not configured', async () => {
|
||||
await collectModelPolicyCall({
|
||||
config: bridgeConfig({ getOmniMemoryConfig: () => undefined }),
|
||||
batch: batchOf(),
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir },
|
||||
});
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
expect(Object.keys(snapshot.executions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never throws when an artifact escapes the declared output directory', async () => {
|
||||
const escaping = batchOf();
|
||||
escaping.artifacts[0]!.workspacePath = '../source.jpg';
|
||||
|
||||
await expect(
|
||||
collectModelPolicyCall({
|
||||
config: bridgeConfig(),
|
||||
batch: escaping,
|
||||
descriptor: DESCRIPTOR,
|
||||
args: { inputPath, outputDir },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const snapshot = await readSnapshot();
|
||||
expect(Object.keys(snapshot.executions)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { PolicyArtifactBatch } from '../../core/turn.js';
|
||||
import type { MediaPolicyToolDescriptor } from '../../tools/tools.js';
|
||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
import {
|
||||
MediaMemoryService,
|
||||
MEDIA_DETECTOR_VERSION,
|
||||
type MediaMemoryBinding,
|
||||
type OmniMediaRegistryView,
|
||||
type OmniMemoryConfigView,
|
||||
type PolicyOutputInput,
|
||||
} from '../../services/media-memory/index.js';
|
||||
import {
|
||||
extensionForMime,
|
||||
hashFileSha256,
|
||||
recognizeMediaFile,
|
||||
} from '../recognition.js';
|
||||
import { OmniObjectStore } from '../storage.js';
|
||||
import { computePolicyFingerprint } from './degradation-cache.js';
|
||||
import {
|
||||
assertRequiredOutputsPresent,
|
||||
validateArtifact,
|
||||
} from './orchestrator.js';
|
||||
|
||||
const debugLogger = createDebugLogger('omni:memory');
|
||||
|
||||
/**
|
||||
* Model/client-origin half of the `OmniPolicySucceeded` boundary (memory
|
||||
* design M §7.1: «固定调用、模型 ToolCall 和 direct client 调用在成功后
|
||||
* 都进入同一个 OmniPolicySucceeded 逻辑边界»).
|
||||
*
|
||||
* The fixed-policy orchestrator commits its own successes inline, where it
|
||||
* already holds the staging dir, the promoted objects and the source
|
||||
* binding. A gated model call has no orchestrator around it: the scheduler
|
||||
* (and ACP's own executor) capture the raw `PolicyArtifactBatch` and hand
|
||||
* it here, which walks the same gates in the same order — validate against
|
||||
* the descriptor, require declared outputs, promote to the object store,
|
||||
* THEN commit (M §6.4 / S §5: no record may reference bytes that are not
|
||||
* yet in `objects/`).
|
||||
*
|
||||
* Without this, evidence gathering never accumulated: recall reported a
|
||||
* gap, the advisor suggested a tool, the model called it, the tool
|
||||
* succeeded — and the next session's recall reported the identical gap
|
||||
* because nothing was ever written.
|
||||
*
|
||||
* Collection failure never affects the tool call (D12): everything is
|
||||
* caught and logged.
|
||||
*/
|
||||
export async function collectModelPolicyCall(params: {
|
||||
config: Config;
|
||||
batch: PolicyArtifactBatch;
|
||||
descriptor: MediaPolicyToolDescriptor;
|
||||
/** Resolved arguments the tool actually ran with (post-gate: a
|
||||
* `resourceId` the caller passed has already become `inputPath`). */
|
||||
args: Record<string, unknown>;
|
||||
/** Epoch ms when the tool actually began executing. The caller owns this
|
||||
* — measuring it here would time the COLLECTION, not the work. */
|
||||
startedAt?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { config, batch, descriptor, args, signal } = params;
|
||||
// Fixed-policy successes are committed by the orchestrator itself; a
|
||||
// second commit here would be a duplicate write of the same execution.
|
||||
if (batch.executionOrigin.kind === 'fixed_policy') return;
|
||||
const memoryConfig = (config as OmniMemoryConfigView).getOmniMemoryConfig?.();
|
||||
if (!memoryConfig) return;
|
||||
|
||||
const inputPath = args['inputPath'];
|
||||
const outputDir = args['outputDir'];
|
||||
if (typeof inputPath !== 'string' || typeof outputDir !== 'string') return;
|
||||
|
||||
try {
|
||||
const store = new OmniObjectStore(config.storage.getQwenDir());
|
||||
const memory = new MediaMemoryService(store.getOmniRootDir(), {
|
||||
maxInlineTextBytes: memoryConfig.collection.maxInlineTextBytes,
|
||||
});
|
||||
|
||||
const source = await resolveSourceBinding({
|
||||
config,
|
||||
memory,
|
||||
inputPath,
|
||||
signal,
|
||||
});
|
||||
if (!source) return;
|
||||
|
||||
// Fall back to "now" only when the caller could not supply the real
|
||||
// start; that degrades the recorded duration, never correctness.
|
||||
const startedAt = new Date(params.startedAt ?? Date.now()).toISOString();
|
||||
// Same containment rule the orchestrator applies to its staging dir:
|
||||
// an artifact must live inside the output directory the call declared.
|
||||
const validated = await Promise.all(
|
||||
batch.artifacts.map((artifact) =>
|
||||
validateArtifact(artifact, descriptor, path.resolve(outputDir), signal),
|
||||
),
|
||||
);
|
||||
assertRequiredOutputsPresent(descriptor, validated, batch.toolName);
|
||||
|
||||
const outputs: PolicyOutputInput[] = await Promise.all(
|
||||
validated.map(async (artifact) => {
|
||||
const mimeType =
|
||||
artifact.kind === 'media'
|
||||
? artifact.recognized.detectedMimeType
|
||||
: artifact.mimeType;
|
||||
const put = await store.putFile(
|
||||
artifact.absolutePath,
|
||||
artifact.sha256,
|
||||
extensionForMime(mimeType),
|
||||
signal,
|
||||
);
|
||||
return artifact.kind === 'media'
|
||||
? {
|
||||
kind: 'media' as const,
|
||||
objectPath: put.objectPath,
|
||||
sha256: artifact.sha256,
|
||||
mediaType: artifact.recognized.modality,
|
||||
metadata: artifact.recognized.metadata,
|
||||
sizeBytes: artifact.recognized.sizeBytes,
|
||||
mimeType: artifact.recognized.detectedMimeType,
|
||||
role: artifact.role,
|
||||
disclosure: artifact.disclosure,
|
||||
}
|
||||
: {
|
||||
kind: 'text' as const,
|
||||
objectPath: put.objectPath,
|
||||
sha256: artifact.sha256,
|
||||
mimeType: artifact.mimeType,
|
||||
text: artifact.text,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
role: artifact.role,
|
||||
disclosure: artifact.disclosure,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Reserved runtime keys are per-invocation plumbing, not reproducible
|
||||
// configuration — the same exclusion the fingerprint applies.
|
||||
const finalArguments: Record<string, unknown> = { ...args };
|
||||
delete finalArguments['inputPath'];
|
||||
delete finalArguments['outputDir'];
|
||||
delete finalArguments['resourceId'];
|
||||
|
||||
const commit = await memory.commitPolicySucceeded({
|
||||
invocationId: batch.invocationId,
|
||||
source,
|
||||
executionOrigin: batch.executionOrigin,
|
||||
toolName: batch.toolName,
|
||||
...(descriptor.version !== undefined
|
||||
? { toolVersion: descriptor.version }
|
||||
: {}),
|
||||
finalArguments,
|
||||
omniConfigHash: computePolicyFingerprint(
|
||||
batch.toolName,
|
||||
args,
|
||||
descriptor.version,
|
||||
),
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
outputs,
|
||||
});
|
||||
// `created: false` is a content-identity replay: the same file already
|
||||
// recorded this execution (a same-invocation retry, or a degradation
|
||||
// cache hit). Worth distinguishing in the log, because "no new
|
||||
// execution appeared in memory.json" otherwise reads identically to a
|
||||
// collection that silently did nothing.
|
||||
debugLogger.debug(
|
||||
`omni memory: ${commit?.created === false ? 'replayed' : 'recorded'} ` +
|
||||
`${batch.toolName} (${batch.executionOrigin.kind}) with ` +
|
||||
`${outputs.length} output(s)`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
debugLogger.debug(
|
||||
`omni memory: collecting ${batch.toolName} (${batch.executionOrigin.kind}) ` +
|
||||
`failed, skipping: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory identity of the source the call ran against. A handle-driven call
|
||||
* already has one in the session registry (the gate resolved that handle
|
||||
* into `inputPath`), so the file is not re-hashed; a call that named a raw
|
||||
* path falls back to recognizing it, which is an idempotent upsert.
|
||||
*/
|
||||
async function resolveSourceBinding(params: {
|
||||
config: Config;
|
||||
memory: MediaMemoryService;
|
||||
inputPath: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<MediaMemoryBinding | undefined> {
|
||||
const { config, memory, inputPath, signal } = params;
|
||||
const bound = (config as OmniMediaRegistryView)
|
||||
.getOmniMediaResourceRegistry?.()
|
||||
?.resolveByFileRef(inputPath);
|
||||
if (bound) {
|
||||
return {
|
||||
fileId: bound.fileId,
|
||||
fileVersionId: bound.fileVersionId,
|
||||
rootFileId: bound.rootFileId,
|
||||
};
|
||||
}
|
||||
const recognized = await recognizeMediaFile(inputPath, { signal });
|
||||
const sha256 = await hashFileSha256(inputPath, signal);
|
||||
return memory.recordFileRecognized({
|
||||
fileRef: inputPath,
|
||||
sha256,
|
||||
mediaType: recognized.modality,
|
||||
metadata: recognized.metadata,
|
||||
sizeBytes: recognized.sizeBytes,
|
||||
mimeType: recognized.detectedMimeType,
|
||||
origin: 'user',
|
||||
source: { protocol: 'local', locator: path.basename(inputPath) },
|
||||
recognition: {
|
||||
ingestionConfigHash: '',
|
||||
detectorVersion: MEDIA_DETECTOR_VERSION,
|
||||
probeStatus: 'complete',
|
||||
},
|
||||
});
|
||||
}
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user