From 7fc4108eeb89314b8427c0fc2a2902ebb9ea39c6 Mon Sep 17 00:00:00 2001 From: Sanchit Monga Date: Tue, 1 Sep 2026 15:36:37 -0700 Subject: [PATCH 01/15] docs: record the v0.20.33 release traps Four lessons from cutting v0.20.33, which took four candidates. Two were real defects that would have shipped an SDK where TTS does not link. The expensive one: a new NeuRT archive must be added in FOUR places, and three only fail in a build configuration a dev Mac never produces. Miss the force-load lists and every native job is green while validate_consumer_flutter dies an hour later on `Undefined symbol: _g_neurt_tts_ops` -- the engine registers, all CMake targets compile, only a CONSUMER's link fails. Miss the `#if RAC_NEURT_ROUTABLE` guard on the vtable entry and the opposite happens: routable builds are fine and every PR job without NEURUN_TOKEN fails, because the non-routable shell still references a symbol nothing defines. A machine with the payload staged is routable and reproduces neither. This file already notes that prose alone did not stop the checksum-staleness bug recurring twice, so the archive-list half now has a mechanism: `neurt_assert_no_unlisted_archives` fails packaging by name. The vtable guard has none -- hence the instruction to audit all ten slots, and the note that moving core/third_party/neurt aside reproduces a non-routable build in one step. Also recorded: reuse_native_web_run_id is same-version-only (the wasm embeds RAC_VERSION_STRING, and a skipped native_web leaves the candidate unusable for publish_from_run_id); checksum verification must match by NAME as well as URL, because a URL-only cross-check validates 9 of 15 sites and reports success; and `rcli backends --json` against the OSS kit lists no neurt at all, so a routability check there passes cleanly and proves nothing. check_agents_claude_sync.sh All 19 CLAUDE.md -> AGENTS.md symlinks present Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01R6p1R4pwTg5pvVjiEjq9ZA --- AGENTS.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 78fdff889e..747ab13582 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,7 +233,7 @@ improvising the steps: **sdk-release** (version bump through published GitHub Re **sdk-publish** (registry publishing + the `runanywhere-swift` dist repo cut), and **sdk-test-starters** (post-release device/app smoke tests). -### Release pipeline lessons (v0.20.29 through v0.20.31 — read before cutting a release by hand) +### Release pipeline lessons (v0.20.29 through v0.20.33 — read before cutting a release by hand) - **`native_ios` and `native_android` always rebuild fresh archives on every `release.yml` run — there is no reuse/skip mechanism for either job** (unlike `native_web`'s @@ -277,6 +277,37 @@ improvising the steps: **sdk-release** (version bump through published GitHub Re inference rather than just building. `bindings/electron/scripts/bundle-native.ts` already had the correct pattern (copy `dsp/win-arm64/` flat, no extension filter); the overlay script just never matched it. Fixed in #804. +- **A new NeuRT archive must be added in FOUR places, and three of them only fail in a build + configuration your Mac never produces.** v0.20.33 added `libneurt_rac_tts_ops.a` and took two + wasted release candidates to land, because each site fails somewhere different and late: + `engines/neurt/CMakeLists.txt` (probe list + imported target + `_neurt_extra_links`), + `build-core-xcframework.sh` (pre-flight guard AND `_NEURT_ROUTABLE_ARCHIVES` — two lists in one + file), `package-private-engine-overlay.sh`, and the `#if RAC_NEURT_ROUTABLE` guard around the + vtable entry in `rac_plugin_entry_neurt.cpp`. Miss the force-load lists and every native job goes + green while `validate_consumer_flutter` dies an hour later on `Undefined symbol: + _g_neurt_tts_ops` — the engine registers, all CMake targets compile, and only a CONSUMER's link + fails. Miss the vtable guard and the routable build is fine while every PR job WITHOUT + `NEURUN_TOKEN` fails, because a non-routable shell still references a symbol nothing defines. + A dev machine with the payload staged is routable, so it reproduces neither. `libneurt_rac_*.a` + archives now fail packaging by name if they are not in the list + (`neurt_assert_no_unlisted_archives`); the vtable guard has no such mechanism — audit all ten + slots when adding one. Reproduce a non-routable build by moving `core/third_party/neurt` aside. +- **`reuse_native_web_run_id` is SAME-VERSION-ONLY, whatever the diff looks like.** `racommons.wasm` + embeds `RAC_VERSION_STRING` and `validate_public_packages.py` asserts it against the version being + packaged, so reusing ANY prior release's run fails `sdk_web` with *"does not embed + RAC_VERSION_STRING"*. Worse than a red job: `native_web` is skipped, so that candidate never + uploads a web artifact and can never serve as `publish_from_run_id`. Only a re-dispatch at the + same version qualifies. +- **Verify checksums by NAME as well as by URL — the manifests use two different shapes.** The root + `Package.swift` interpolates `\(sdkVersion)` into each URL; the Flutter manifests bind a checksum + to a `name:` through a helper with no URL at the call site at all. A URL-only cross-check silently + validates 9 of 15 sites and reports success. Re-hash every site against the zip it names, and + remember `RAC_CHECKSUM_SKIP` hides `RunAnywhereMLXRuntime` from `publish` entirely, so the Flutter + MLX podspec is verifiable only by hand. +- **A green `rcli backends --json` proves nothing against the OSS kit.** NeuRT ships as a separate + private pack, so the public bottle lists no `neurt` at all — a routability check there passes + cleanly and means nothing. Apply the private overlay + (`cpp-desktop-macos-arm64-neurt-private` workflow artifact) before believing the result. - **General pattern across all four of the above**: each one was invisible to the checks that existed (a version-coherence check, a checksum-skip escape hatch, a "the model loaded" smoke test, a "the build succeeded" CI job) because none of those checks actually ran the From 28c8b6a75fcaeec144623a08d1e248df0561ce8e Mon Sep 17 00:00:00 2001 From: shobhitagnihotri69 Date: Tue, 15 Sep 2026 13:02:38 +0530 Subject: [PATCH 02/15] docs(core,swift): propagate UTF-8 text-run streaming docstrings across LLM and VLM headers (#929, #923) Update stale "Called for each generated token" docstrings to describe delivery as UTF-8-safe text runs generated by the model. Applies the correction consistently across: - core/include/rac/features/llm/rac_llm_component.h - core/include/rac/features/llm/rac_llm_types.h - core/include/rac/features/vlm/rac_vlm_component.h - core/include/rac/features/vlm/rac_vlm_types.h - bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_component.h - bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h Closes #929, #923. --- .../RunAnywhere/CRACommons/include/rac_vlm_component.h | 2 +- .../Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h | 6 +++--- core/include/rac/features/llm/rac_llm_component.h | 2 +- core/include/rac/features/llm/rac_llm_types.h | 4 ++-- core/include/rac/features/vlm/rac_vlm_component.h | 2 +- core/include/rac/features/vlm/rac_vlm_types.h | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_component.h b/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_component.h index 556498e18e..cd61a01d3c 100644 --- a/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_component.h +++ b/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_component.h @@ -163,7 +163,7 @@ RAC_API rac_bool_t rac_vlm_component_supports_streaming(rac_handle_t handle); * @param image Image input * @param prompt Text prompt * @param options Generation options (can be NULL for defaults) - * @param token_callback Called for each generated token + * @param token_callback Called for each UTF-8-safe text run generated by the model * @param complete_callback Called when generation completes * @param error_callback Called on error * @param user_data User context passed to callbacks diff --git a/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h b/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h index 30d6c9915d..be1fb8d917 100644 --- a/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h +++ b/bindings/swift/Sources/RunAnywhere/CRACommons/include/rac_vlm_types.h @@ -370,9 +370,9 @@ typedef struct rac_vlm_info { /** * @brief Simple VLM streaming callback * - * Called for each generated token during streaming. + * Called for each UTF-8-safe text run generated by the model during streaming. * - * @param token The generated token string + * @param token The generated text run string * @param user_data User-provided context * @return RAC_TRUE to continue, RAC_FALSE to stop generation */ @@ -414,7 +414,7 @@ typedef rac_bool_t (*rac_vlm_token_event_callback_fn)(const rac_vlm_token_event_ /** * @brief VLM component token callback * - * @param token The generated token + * @param token The generated UTF-8-safe text run * @param user_data User-provided context * @return RAC_TRUE to continue, RAC_FALSE to stop */ diff --git a/core/include/rac/features/llm/rac_llm_component.h b/core/include/rac/features/llm/rac_llm_component.h index ce3caa6428..ff0a89b7bb 100644 --- a/core/include/rac/features/llm/rac_llm_component.h +++ b/core/include/rac/features/llm/rac_llm_component.h @@ -196,7 +196,7 @@ RAC_API rac_bool_t rac_llm_component_supports_streaming(rac_handle_t handle); * @param handle Component handle * @param prompt Input prompt * @param options Generation options (can be NULL for defaults) - * @param token_callback Called for each generated token + * @param token_callback Called for each UTF-8-safe text run generated by the model * @param complete_callback Called when generation completes * @param error_callback Called on error * @param user_data User context passed to callbacks diff --git a/core/include/rac/features/llm/rac_llm_types.h b/core/include/rac/features/llm/rac_llm_types.h index c339b143f3..b9a2599f3e 100644 --- a/core/include/rac/features/llm/rac_llm_types.h +++ b/core/include/rac/features/llm/rac_llm_types.h @@ -262,14 +262,14 @@ typedef struct rac_llm_info { /** * @brief LLM streaming callback * - * Called for each generated token during streaming, and once more with + * Called for each UTF-8-safe text run generated by the model during streaming, and once more with * `is_final=true` when the engine reports a genuine terminal (EOS / stop / * length / cancel / error). Non-final calls pass `finish_reason=NULL`; the * terminal call passes an OpenAI-style reason string ("stop", "length", * "cancelled", "error", or "unknown"). `token` may be empty on the terminal * call. * - * @param token The generated token string (may be empty on the final call) + * @param token The generated text run string (may be empty on the final call) * @param is_final RAC_TRUE on the terminal event * @param finish_reason NULL until final; then a NUL-terminated reason string * @param tokens_in_delta Engine-reported token count represented by this diff --git a/core/include/rac/features/vlm/rac_vlm_component.h b/core/include/rac/features/vlm/rac_vlm_component.h index 7d5d7392b9..23a8002c31 100644 --- a/core/include/rac/features/vlm/rac_vlm_component.h +++ b/core/include/rac/features/vlm/rac_vlm_component.h @@ -174,7 +174,7 @@ RAC_API rac_bool_t rac_vlm_component_supports_streaming(rac_handle_t handle); * @param image Image input * @param prompt Text prompt * @param options Generation options (can be NULL for defaults) - * @param token_callback Called for each generated token + * @param token_callback Called for each UTF-8-safe text run generated by the model * @param complete_callback Called when generation completes * @param error_callback Called on error * @param user_data User context passed to callbacks diff --git a/core/include/rac/features/vlm/rac_vlm_types.h b/core/include/rac/features/vlm/rac_vlm_types.h index c411daf90e..393e0fff8a 100644 --- a/core/include/rac/features/vlm/rac_vlm_types.h +++ b/core/include/rac/features/vlm/rac_vlm_types.h @@ -406,9 +406,9 @@ typedef struct rac_vlm_info { /** * @brief Simple VLM streaming callback * - * Called for each generated token during streaming. + * Called for each UTF-8-safe text run generated by the model during streaming. * - * @param token The generated token string + * @param token The generated text run string * @param user_data User-provided context * @return RAC_TRUE to continue, RAC_FALSE to stop generation */ @@ -450,7 +450,7 @@ typedef rac_bool_t (*rac_vlm_token_event_callback_fn)(const rac_vlm_token_event_ /** * @brief VLM component token callback * - * @param token The generated token + * @param token The generated UTF-8-safe text run * @param user_data User-provided context * @return RAC_TRUE to continue, RAC_FALSE to stop */ From 4d9729a1110c491c45aabc159b787a4a3c37fba2 Mon Sep 17 00:00:00 2001 From: Sreesanth R Date: Sun, 20 Sep 2026 12:45:49 +0000 Subject: [PATCH 03/15] fix(errors): unify native error category mapping (#880) --- bindings/electron/native/addon.cpp | 8 ++ bindings/electron/test/unit/errors.test.ts | 9 +- .../bridge/extensions/CppBridgeRAG.kt | 34 +++---- bindings/python/runanywhere/errors.py | 40 ++------- bindings/python/tests/test_errors.py | 90 +++++-------------- core/src/foundation/rac_proto_adapters.cpp | 46 +++++++--- core/tests/test_result_to_proto_error.cpp | 33 +++++++ 7 files changed, 122 insertions(+), 138 deletions(-) diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index cb72355794..e649a26caa 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -58,6 +58,7 @@ #include "voice_bridge.h" #include "rac/core/rac_types.h" +#include "rac/core/rac_error_proto.h" #include "rac/features/diarization/rac_diarization_service.h" #include "rac/features/diarization/rac_diarization_types.h" #include "rac/features/embeddings/rac_embeddings_service.h" @@ -273,6 +274,13 @@ Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& Napi::Object value = error.Value(); value.Set("code", Napi::Number::New(env, rac_code_abs(code))); // canonical positive ErrorCode value.Set("cAbiCode", Napi::Number::New(env, static_cast(code))); // raw rac_result_t + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + if (rac_result_to_proto_error(code, &buffer) == RAC_SUCCESS && buffer.data != nullptr && + buffer.size > 0) { + value.Set("sdkError", Napi::Buffer::Copy(env, buffer.data, buffer.size)); + } + rac_proto_buffer_free(&buffer); return error; } diff --git a/bindings/electron/test/unit/errors.test.ts b/bindings/electron/test/unit/errors.test.ts index 7159667063..af2ddc4a6b 100644 --- a/bindings/electron/test/unit/errors.test.ts +++ b/bindings/electron/test/unit/errors.test.ts @@ -37,13 +37,18 @@ test('categoryForCode maps ranges like the canonical table', () => { assert.equal(categoryForCode(0), ErrorCategories.UNSPECIFIED); assert.equal(categoryForCode(100), ErrorCategories.CONFIGURATION); assert.equal(categoryForCode(110), ErrorCategories.MODEL); - // Generation (-130..-149) is in none of the table's ranges, so it takes the - // INTERNAL fallback — exactly as commons' rac_result_to_proto_category does. + // Generation (-130..-149) is explicitly INTERNAL in the shared table. assert.equal(categoryForCode(130), ErrorCategories.INTERNAL); assert.equal(categoryForCode(182), ErrorCategories.IO); assert.equal(categoryForCode(259), ErrorCategories.VALIDATION); assert.equal(categoryForCode(380), ErrorCategories.INTERNAL); + assert.equal(categoryForCode(351), ErrorCategories.IO); + assert.equal(categoryForCode(371), ErrorCategories.VALIDATION); + assert.equal(categoryForCode(401), ErrorCategories.COMPONENT); + assert.equal(categoryForCode(501), ErrorCategories.CONFIGURATION); + assert.equal(categoryForCode(601), ErrorCategories.COMPONENT); assert.equal(categoryForCode(804), ErrorCategories.INTERNAL); + assert.equal(categoryForCode(1000), ErrorCategories.UNSPECIFIED); }); test('SDKException is an Error subclass with the right name', () => { diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt index c6ba0fc7a6..92c2b0cb0a 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt @@ -5,16 +5,15 @@ package com.runanywhere.sdk.foundation.bridge.extensions -import ai.runanywhere.proto.v1.ErrorCategory -import ai.runanywhere.proto.v1.ErrorCode import ai.runanywhere.proto.v1.RAGConfiguration import ai.runanywhere.proto.v1.RAGDocument import ai.runanywhere.proto.v1.RAGQueryOptions import ai.runanywhere.proto.v1.RAGResult import ai.runanywhere.proto.v1.RAGSearchRequest import ai.runanywhere.proto.v1.RAGSearchResponse -import ai.runanywhere.proto.v1.RAGStatistics import ai.runanywhere.proto.v1.RAGStreamEvent +import ai.runanywhere.proto.v1.RAGStatistics +import ai.runanywhere.proto.v1.SDKError import com.runanywhere.sdk.foundation.errors.SDKException import com.runanywhere.sdk.native.bridge.NativeProtoProgressListener import com.runanywhere.sdk.native.bridge.RunAnywhereBridge @@ -241,27 +240,16 @@ object CppBridgeRAG { if (rc == RunAnywhereBridge.RAC_SUCCESS) { return SDKException.operation("racRagSessionCreateProto returned 0") } - val magnitude = if (rc < 0) -rc else rc - val code = ErrorCode.fromValue(magnitude) ?: ErrorCode.ERROR_CODE_UNKNOWN - return SDKException.make( - code = code, - message = "RAG proto session create failed: $rc", - category = categoryForRacResult(magnitude), - cAbiCode = rc, - ) - } - - private fun categoryForRacResult(magnitude: Int): ErrorCategory = - when (magnitude) { - in 110..129 -> ErrorCategory.ERROR_CATEGORY_MODEL - in 150..179 -> ErrorCategory.ERROR_CATEGORY_NETWORK - in 180..219, in 330..369 -> ErrorCategory.ERROR_CATEGORY_IO - in 250..279 -> ErrorCategory.ERROR_CATEGORY_VALIDATION - in 320..329 -> ErrorCategory.ERROR_CATEGORY_AUTH - in 100..109 -> ErrorCategory.ERROR_CATEGORY_CONFIGURATION - in 400..499, in 600..999 -> ErrorCategory.ERROR_CATEGORY_INTERNAL - else -> ErrorCategory.ERROR_CATEGORY_COMPONENT + val serialized = RunAnywhereBridge.racResultToProtoError(rc) + if (serialized != null) { + try { + return SDKException(SDKError.ADAPTER.decode(serialized)) + } catch (_: Exception) { + // Fall through to the operation error if native serialization fails. + } } + return SDKException.operation("RAG proto session create failed: $rc") + } private fun > decodeOrThrow( adapter: ProtoAdapter, diff --git a/bindings/python/runanywhere/errors.py b/bindings/python/runanywhere/errors.py index 7621f353f5..d2326f3e64 100644 --- a/bindings/python/runanywhere/errors.py +++ b/bindings/python/runanywhere/errors.py @@ -7,38 +7,6 @@ from ._generated_errors import ErrorCategory, ErrorCode -def category_for_code(code: int) -> ErrorCategory: - """Map an ErrorCode (positive) or ``rac_result_t`` (negative) to its ErrorCategory. - - Faithful port of commons ``rac_result_to_proto_category`` in - ``rac_proto_adapters.cpp``. Unmapped failure codes return INTERNAL (not - UNSPECIFIED). AUTH is only 320–329; security codes 330–349 fall through to - INTERNAL, matching the C ABI. - """ - # Normalize to negative rac_result_t for the commons range table. - if code > 0: - n = -code - else: - n = code - if n >= 0: - return ErrorCategory.UNSPECIFIED - if -179 <= n <= -150: - return ErrorCategory.NETWORK - if -279 <= n <= -250: - return ErrorCategory.VALIDATION - if -129 <= n <= -110: - return ErrorCategory.MODEL - if (-219 <= n <= -180) or (-299 <= n <= -280): - return ErrorCategory.IO - if -329 <= n <= -320: - return ErrorCategory.AUTH - if -109 <= n <= -100: - return ErrorCategory.CONFIGURATION - if (-249 <= n <= -230) or (-319 <= n <= -300): - return ErrorCategory.COMPONENT - return ErrorCategory.INTERNAL - - class SDKException(Exception): """The single throwable type the SDK raises. @@ -69,7 +37,7 @@ def __init__( super().__init__(message or "SDK error") self.message = message or "SDK error" self.code = code - self.category = category if category is not None else category_for_code(int(code)) + self.category = category if category is not None else ErrorCategory.UNSPECIFIED if c_abi_code is not None: self.c_abi_code = c_abi_code elif 0 < int(code) <= 899: @@ -156,6 +124,7 @@ def invalid_input(details: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.INVALID_INPUT, f"Invalid input: {details}" if details else "Invalid input", + category=ErrorCategory.VALIDATION, ) @staticmethod @@ -173,6 +142,7 @@ def model_not_found(model_id: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.MODEL_NOT_FOUND, f"Model not found: {model_id}" if model_id else "Model not found", + category=ErrorCategory.MODEL, ) @staticmethod @@ -182,6 +152,7 @@ def model_load_failed( return SDKException.of( ErrorCode.MODEL_LOAD_FAILED, f"Failed to load model: {model_id}" if model_id else "Failed to load model", + category=ErrorCategory.MODEL, nested_message=str(cause) if cause is not None else None, ) @@ -192,6 +163,7 @@ def generation_failed( return SDKException.of( ErrorCode.GENERATION_FAILED, details if details is not None else "Generation failed", + category=ErrorCategory.INTERNAL, nested_message=str(cause) if cause is not None else None, ) @@ -218,6 +190,7 @@ def not_implemented(feature: str | None = None) -> "SDKException": return SDKException.of( ErrorCode.NOT_IMPLEMENTED, f"{feature} not implemented" if feature else "Not implemented", + category=ErrorCategory.INTERNAL, ) @staticmethod @@ -249,6 +222,7 @@ def unknown( return SDKException.of( ErrorCode.UNKNOWN, details if details is not None else "Unknown error", + category=ErrorCategory.INTERNAL, nested_message=str(cause) if cause is not None else None, ) diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index e839fc54e0..958e80871b 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -12,7 +12,6 @@ ErrorCode, SDKException, as_sdk_exception, - category_for_code, is_sdk_exception, raise_for_rac, ) @@ -51,67 +50,27 @@ def test_error_category_values(): # --------------------------------------------------------------------------- # -# category_for_code range table (verbatim port) # +# Proto-backed category behavior # # --------------------------------------------------------------------------- # -@pytest.mark.parametrize( - "code,expected", - [ - (0, ErrorCategory.UNSPECIFIED), - (100, ErrorCategory.CONFIGURATION), - (109, ErrorCategory.CONFIGURATION), - (110, ErrorCategory.MODEL), - (111, ErrorCategory.MODEL), - (129, ErrorCategory.MODEL), - # Generation range is not named in commons → INTERNAL - (130, ErrorCategory.INTERNAL), - (149, ErrorCategory.INTERNAL), - (150, ErrorCategory.NETWORK), - (179, ErrorCategory.NETWORK), - (180, ErrorCategory.IO), - (182, ErrorCategory.IO), - (219, ErrorCategory.IO), - (220, ErrorCategory.INTERNAL), - (229, ErrorCategory.INTERNAL), - (230, ErrorCategory.COMPONENT), - (231, ErrorCategory.COMPONENT), - (249, ErrorCategory.COMPONENT), - (250, ErrorCategory.VALIDATION), - (259, ErrorCategory.VALIDATION), - (279, ErrorCategory.VALIDATION), - (280, ErrorCategory.IO), - (299, ErrorCategory.IO), - (300, ErrorCategory.COMPONENT), - (319, ErrorCategory.COMPONENT), - # AUTH is only 320–329; security 330–349 → INTERNAL (commons) - (320, ErrorCategory.AUTH), - (329, ErrorCategory.AUTH), - (330, ErrorCategory.INTERNAL), - (349, ErrorCategory.INTERNAL), - (350, ErrorCategory.INTERNAL), - (369, ErrorCategory.INTERNAL), - (370, ErrorCategory.INTERNAL), - (379, ErrorCategory.INTERNAL), - (380, ErrorCategory.INTERNAL), - (389, ErrorCategory.INTERNAL), - (400, ErrorCategory.INTERNAL), - (499, ErrorCategory.INTERNAL), - (500, ErrorCategory.INTERNAL), - (599, ErrorCategory.INTERNAL), - (600, ErrorCategory.INTERNAL), - (699, ErrorCategory.INTERNAL), - (700, ErrorCategory.INTERNAL), - (800, ErrorCategory.INTERNAL), - (804, ErrorCategory.INTERNAL), - (999, ErrorCategory.INTERNAL), - (1000, ErrorCategory.INTERNAL), - (-5, ErrorCategory.INTERNAL), - (-111, ErrorCategory.MODEL), - (-320, ErrorCategory.AUTH), - (-330, ErrorCategory.INTERNAL), - ], -) -def test_category_for_code(code, expected): - assert category_for_code(code) == expected +def test_constructor_does_not_recreate_native_category_mapping(): + assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.UNSPECIFIED + + +def test_from_proto_preserves_wire_category(): + class ProtoError: + code = ErrorCode.ADAPTER_NOT_SET + category = ErrorCategory.CONFIGURATION + message = "adapter missing" + c_abi_code = -500 + nested_message = "" + param = "" + + def HasField(self, field): + return field in {"c_abi_code", "nested_message", "param"} + + error = SDKException.from_proto(ProtoError()) + assert error.code == ErrorCode.ADAPTER_NOT_SET + assert error.category == ErrorCategory.CONFIGURATION def test_error_code_covers_idl_surface(): @@ -123,9 +82,8 @@ def test_error_code_covers_idl_surface(): def test_category_defaults_applied_in_ctor(): - # No explicit category -> derived from code via category_for_code. - assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.MODEL - assert SDKException(ErrorCode.INVALID_INPUT, "x").category == ErrorCategory.VALIDATION + # Categories are supplied by proto errors or explicit local factories. + assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.UNSPECIFIED # --------------------------------------------------------------------------- # @@ -155,7 +113,7 @@ def test_raise_for_rac_maps_minus_111_to_model_load_failed(): e = ei.value assert e.code == ErrorCode.MODEL_LOAD_FAILED assert e.c_abi_code == -111 - assert e.category == ErrorCategory.MODEL + assert e.category == ErrorCategory.UNSPECIFIED def test_raise_for_rac_unknown_code_falls_back_to_unknown(): @@ -173,7 +131,7 @@ def test_raise_for_rac_custom_message(): assert e.code == ErrorCode.CANCELLED assert str(e) == "user cancelled" assert e.c_abi_code == -380 - assert e.category == ErrorCategory.INTERNAL + assert e.category == ErrorCategory.UNSPECIFIED # --------------------------------------------------------------------------- # diff --git a/core/src/foundation/rac_proto_adapters.cpp b/core/src/foundation/rac_proto_adapters.cpp index 68ef524b78..da47235a67 100644 --- a/core/src/foundation/rac_proto_adapters.cpp +++ b/core/src/foundation/rac_proto_adapters.cpp @@ -1077,27 +1077,45 @@ bool rac_model_storage_metrics_from_proto(const ::runanywhere::v1::ModelStorageM // =========================================================================== ::runanywhere::v1::ErrorCategory rac_result_to_proto_category(rac_result_t code) { - // Non-negative codes (success / invalid) carry no error category. if (code >= 0) return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; - if (code <= -150 && code >= -179) - return ::runanywhere::v1::ERROR_CATEGORY_NETWORK; - if (code <= -250 && code >= -279) - return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; - if (code <= -110 && code >= -129) + + const int64_t magnitude = -static_cast(code); + if (magnitude >= 100 && magnitude <= 109) + return ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION; + if (magnitude >= 110 && magnitude <= 129) return ::runanywhere::v1::ERROR_CATEGORY_MODEL; - if ((code <= -180 && code >= -219) || (code <= -280 && code >= -299)) + if (magnitude >= 130 && magnitude <= 149) + return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + if (magnitude >= 150 && magnitude <= 179) + return ::runanywhere::v1::ERROR_CATEGORY_NETWORK; + if ((magnitude >= 180 && magnitude <= 219) || (magnitude >= 280 && magnitude <= 299)) return ::runanywhere::v1::ERROR_CATEGORY_IO; - if (code <= -320 && code >= -329) + if (magnitude >= 220 && magnitude <= 229) + return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + if (magnitude >= 230 && magnitude <= 249) + return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; + if (magnitude >= 250 && magnitude <= 279) + return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; + if (magnitude >= 300 && magnitude <= 319) + return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; + if (magnitude >= 320 && magnitude <= 349) return ::runanywhere::v1::ERROR_CATEGORY_AUTH; - if (code <= -100 && code >= -109) + if (magnitude >= 350 && magnitude <= 369) + return ::runanywhere::v1::ERROR_CATEGORY_IO; + if (magnitude >= 370 && magnitude <= 379) + return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; + if (magnitude >= 380 && magnitude <= 389) + return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + if (magnitude >= 400 && magnitude <= 499) + return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; + if (magnitude >= 500 && magnitude <= 599) return ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION; - if ((code <= -230 && code >= -249) || (code <= -300 && code >= -319)) + if (magnitude >= 600 && magnitude <= 699) return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - // Any other negative code is an unmapped error -> INTERNAL (canonical - // fallback; rac_error_proto.cpp previously returned UNSPECIFIED here, the - // drift this consolidation fixes). - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + if (magnitude >= 700 && magnitude <= 999) + return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; + return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; } ::runanywhere::v1::ErrorCategory rac_category_to_proto(rac_error_category_t category) { diff --git a/core/tests/test_result_to_proto_error.cpp b/core/tests/test_result_to_proto_error.cpp index 670b0d3a83..c679092b03 100644 --- a/core/tests/test_result_to_proto_error.cpp +++ b/core/tests/test_result_to_proto_error.cpp @@ -20,6 +20,7 @@ #include "rac/core/rac_error.h" #include "rac/core/rac_error_proto.h" +#include "rac/foundation/rac_proto_adapters.h" #include "rac/foundation/rac_proto_buffer.h" #if defined(RAC_HAVE_PROTOBUF) @@ -267,6 +268,34 @@ void test_null_buffer_returns_invalid_argument() { CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "null buffer rejected"); } +void test_canonical_category_ranges() { + struct Case { + rac_result_t code; + ::runanywhere::v1::ErrorCategory category; + }; + const Case cases[] = { + {static_cast(-100), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-130), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-230), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-330), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, + {static_cast(-351), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-371), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-401), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-501), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-601), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-701), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-801), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-901), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + }; + for (const Case& test_case : cases) { + CHECK(rac::foundation::rac_result_to_proto_category(test_case.code) == test_case.category, + "canonical category range"); + CHECK(rac::foundation::rac_result_to_proto_category(-test_case.code) == + ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "positive code is unspecified"); + } +} + void test_success_code_writes_default_proto() { rac_proto_buffer_t buffer; rac_proto_buffer_init(&buffer); @@ -302,6 +331,8 @@ void test_unknown_negative_code_is_handled() { const bool parsed_ok = parsed.ParseFromArray(buffer.data, static_cast(buffer.size)); CHECK(parsed_ok, "bogus code parses"); CHECK(parsed.c_abi_code() == -12345, "bogus c_abi_code preserved"); + CHECK(parsed.category() == ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "bogus category is unspecified"); CHECK(!parsed.message().empty(), "bogus code message not empty"); rac_proto_buffer_free(&buffer); @@ -315,6 +346,8 @@ int main() { #if defined(RAC_HAVE_PROTOBUF) std::printf("[ RUN ] test_all_canonical_codes\n"); test_all_canonical_codes(); + std::printf("[ RUN ] test_canonical_category_ranges\n"); + test_canonical_category_ranges(); std::printf("[ RUN ] test_null_buffer_returns_invalid_argument\n"); test_null_buffer_returns_invalid_argument(); std::printf("[ RUN ] test_success_code_writes_default_proto\n"); From c71f70e261b545d2efce530e3cac8a7c34ace4a1 Mon Sep 17 00:00:00 2001 From: Sreesanth R Date: Sun, 20 Sep 2026 13:57:35 +0000 Subject: [PATCH 04/15] fix(errors): finish PR 960 review cleanup --- bindings/electron/src/process/dispatch.ts | 3 ++ bindings/electron/src/process/rpc.ts | 1 + bindings/electron/test/unit/dispatch.test.ts | 18 ++++++++++ .../bridge/extensions/CppBridgeRAG.kt | 12 ++----- .../src/convenience/errors_category.ts | 7 ++-- .../python/docs/reference/architecture.md | 4 +-- .../python/docs/reference/best-practices.md | 4 +-- bindings/python/runanywhere/errors.py | 36 ++++++++++++++++++- bindings/python/tests/test_errors.py | 22 ++++++++---- 9 files changed, 82 insertions(+), 25 deletions(-) diff --git a/bindings/electron/src/process/dispatch.ts b/bindings/electron/src/process/dispatch.ts index 02ef4a8969..b5b5ad684a 100644 --- a/bindings/electron/src/process/dispatch.ts +++ b/bindings/electron/src/process/dispatch.ts @@ -124,6 +124,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { code?: unknown; cAbiCode?: unknown; category?: unknown; + sdkError?: unknown; nestedMessage?: unknown; fieldPath?: unknown; }; @@ -137,6 +138,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { ...(typeof extra.category === 'number' && Number.isFinite(extra.category) ? { category: extra.category } : {}), + ...(extra.sdkError instanceof Uint8Array ? { sdkError: extra.sdkError } : {}), ...(typeof extra.nestedMessage === 'string' ? { nestedMessage: extra.nestedMessage } : {}), ...(typeof extra.fieldPath === 'string' ? { fieldPath: extra.fieldPath } : {}), }; @@ -154,6 +156,7 @@ function rpcError(e: unknown): string | RpcErrorPayload { ...(typeof obj.category === 'number' && Number.isFinite(obj.category) ? { category: obj.category } : {}), + ...(obj.sdkError instanceof Uint8Array ? { sdkError: obj.sdkError } : {}), ...(typeof obj.nestedMessage === 'string' ? { nestedMessage: obj.nestedMessage } : {}), ...(typeof obj.fieldPath === 'string' ? { fieldPath: obj.fieldPath } : {}), }; diff --git a/bindings/electron/src/process/rpc.ts b/bindings/electron/src/process/rpc.ts index fa2aa0eb98..87941176b9 100644 --- a/bindings/electron/src/process/rpc.ts +++ b/bindings/electron/src/process/rpc.ts @@ -16,6 +16,7 @@ export interface RpcErrorPayload { code?: number; cAbiCode?: number; category?: number; + sdkError?: Uint8Array; nestedMessage?: string; fieldPath?: string; } diff --git a/bindings/electron/test/unit/dispatch.test.ts b/bindings/electron/test/unit/dispatch.test.ts index cade199dc2..07c02a236b 100644 --- a/bindings/electron/test/unit/dispatch.test.ts +++ b/bindings/electron/test/unit/dispatch.test.ts @@ -372,6 +372,24 @@ test('a structured native Error keeps numeric SDK fields over RPC', async () => ]); }); +test('a structured native Error keeps serialized sdkError bytes over RPC', async () => { + const port = makePort(); + const sdkError = new Uint8Array([8, 3, 18, 5, 109, 111, 100, 101, 108]); + const err = Object.assign(new Error('load_model failed: -111'), { sdkError }); + const deps = makeDeps({ + api: { + embed: () => { + throw err; + }, + }, + }); + + dispatch(port, { id: 24, method: 'embed', args: [] }, deps); + await tick(); + + assert.deepEqual(port.posts, [{ id: 24, ok: false, error: { name: 'Error', message: err.message, sdkError } }]); +}); + test('a non-Error thrown value is String()-ified', async () => { const port = makePort(); const deps = makeDeps({ diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt index 92c2b0cb0a..8c12cdfde5 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt @@ -13,7 +13,6 @@ import ai.runanywhere.proto.v1.RAGSearchRequest import ai.runanywhere.proto.v1.RAGSearchResponse import ai.runanywhere.proto.v1.RAGStreamEvent import ai.runanywhere.proto.v1.RAGStatistics -import ai.runanywhere.proto.v1.SDKError import com.runanywhere.sdk.foundation.errors.SDKException import com.runanywhere.sdk.native.bridge.NativeProtoProgressListener import com.runanywhere.sdk.native.bridge.RunAnywhereBridge @@ -240,15 +239,8 @@ object CppBridgeRAG { if (rc == RunAnywhereBridge.RAC_SUCCESS) { return SDKException.operation("racRagSessionCreateProto returned 0") } - val serialized = RunAnywhereBridge.racResultToProtoError(rc) - if (serialized != null) { - try { - return SDKException(SDKError.ADAPTER.decode(serialized)) - } catch (_: Exception) { - // Fall through to the operation error if native serialization fails. - } - } - return SDKException.operation("RAG proto session create failed: $rc") + return SDKException.fromRACResult(rc) + ?: SDKException.operation("RAG proto session create failed: $rc") } private fun > decodeOrThrow( diff --git a/bindings/proto-ts/src/convenience/errors_category.ts b/bindings/proto-ts/src/convenience/errors_category.ts index bc24f4d955..1df2bc9ca5 100644 --- a/bindings/proto-ts/src/convenience/errors_category.ts +++ b/bindings/proto-ts/src/convenience/errors_category.ts @@ -14,10 +14,9 @@ * proto codes keep meaningful categories instead of collapsing to INTERNAL. * * C++ commons (`rac::foundation::rac_result_to_proto_category` in - * `core/src/foundation/rac_proto_adapters.cpp`) currently - * only maps |100|–|329| and falls through to INTERNAL — that is a known - * under-mapping, deliberately deferred (see `ts_shared_layer.md` Known issue). - * Do NOT “fix” this TS table down to match commons. + * `core/src/foundation/rac_proto_adapters.cpp`) uses the same 18-range table + * for serialized `SDKError` payloads. Negative values outside the mapped + * ranges return `UNSPECIFIED`. * * Callers may pass either the signed C ABI code or the positive proto * `ErrorCode` (abs magnitude); both are accepted via `Math.abs`. diff --git a/bindings/python/docs/reference/architecture.md b/bindings/python/docs/reference/architecture.md index 3f7e807961..5302cd3eaa 100644 --- a/bindings/python/docs/reference/architecture.md +++ b/bindings/python/docs/reference/architecture.md @@ -137,8 +137,8 @@ through the process-wide singleton `bus`, exposed as `runanywhere.events`. Subsc `errors.py` defines `SDKException` (the single throwable) carrying a canonical `code` (`ErrorCode`, exhaustive vs `idl/errors.proto`) + `category` (`ErrorCategory`) for -cross-SDK-uniform handling. `category_for_code` is a faithful port of commons -`rac_result_to_proto_category` (keep in sync). Category-specific static factories +cross-SDK-uniform handling. `category_for_code` mirrors the canonical 18-range +commons mapping; unmapped negative values remain `UNSPECIFIED`. Category-specific static factories (`not_initialized`, `validation_failed`, `model_not_found`, `generation_failed`, `storage_error`, `invalid_state`, …) build the right code/category; `raise_for_rac(rac_code)` maps a negative `rac_result_t` back to an `ErrorCode` (preserving the raw ABI value as diff --git a/bindings/python/docs/reference/best-practices.md b/bindings/python/docs/reference/best-practices.md index 9751264593..1e40236ecd 100644 --- a/bindings/python/docs/reference/best-practices.md +++ b/bindings/python/docs/reference/best-practices.md @@ -24,8 +24,8 @@ applications of it:) - Public options/results are dataclasses or IntEnums — never raw string status codes. - Native error codes come from `idl/errors.proto` / `rac_error.h`. Keep `ErrorCode` / `ErrorCategory` exhaustive relative to the IDL and map categories with - `category_for_code` as a faithful port of commons `rac_result_to_proto_category` - (AUTH is only 320–329; unmapped failures → INTERNAL, not UNSPECIFIED). + `category_for_code` using the canonical 18-range mapping (including AUTH + 320–349); unmapped negative values remain `UNSPECIFIED`. - Registry framework/category ints are **C ABI enums** (`RAC_FRAMEWORK_*`, `RAC_MODEL_CATEGORY_*`), not proto wire values. Name them and pin them in tests. - Generated RAG protos live in `runanywhere/_proto/`; regenerate via diff --git a/bindings/python/runanywhere/errors.py b/bindings/python/runanywhere/errors.py index d2326f3e64..d118b2afba 100644 --- a/bindings/python/runanywhere/errors.py +++ b/bindings/python/runanywhere/errors.py @@ -7,6 +7,38 @@ from ._generated_errors import ErrorCategory, ErrorCode +def category_for_code(code: int) -> ErrorCategory: + """Map a signed C ABI code or positive proto code to its canonical category.""" + magnitude = abs(int(code)) + if magnitude == 0: + return ErrorCategory.UNSPECIFIED + if 100 <= magnitude <= 109: + return ErrorCategory.CONFIGURATION + if 110 <= magnitude <= 129: + return ErrorCategory.MODEL + if 130 <= magnitude <= 149 or 220 <= magnitude <= 229: + return ErrorCategory.INTERNAL + if 150 <= magnitude <= 179: + return ErrorCategory.NETWORK + if 180 <= magnitude <= 219 or 280 <= magnitude <= 299 or 350 <= magnitude <= 369: + return ErrorCategory.IO + if 230 <= magnitude <= 249 or 300 <= magnitude <= 319: + return ErrorCategory.COMPONENT + if 250 <= magnitude <= 279 or 370 <= magnitude <= 379: + return ErrorCategory.VALIDATION + if 320 <= magnitude <= 349: + return ErrorCategory.AUTH + if 380 <= magnitude <= 389 or 700 <= magnitude <= 999: + return ErrorCategory.INTERNAL + if 400 <= magnitude <= 499: + return ErrorCategory.COMPONENT + if 500 <= magnitude <= 599: + return ErrorCategory.CONFIGURATION + if 600 <= magnitude <= 699: + return ErrorCategory.COMPONENT + return ErrorCategory.UNSPECIFIED + + class SDKException(Exception): """The single throwable type the SDK raises. @@ -37,7 +69,7 @@ def __init__( super().__init__(message or "SDK error") self.message = message or "SDK error" self.code = code - self.category = category if category is not None else ErrorCategory.UNSPECIFIED + self.category = category if category is not None else category_for_code(int(code)) if c_abi_code is not None: self.c_abi_code = c_abi_code elif 0 < int(code) <= 899: @@ -174,6 +206,7 @@ def storage_error( return SDKException.of( ErrorCode.STORAGE_ERROR, details if details is not None else "Storage error", + category=ErrorCategory.IO, nested_message=str(cause) if cause is not None else None, ) @@ -259,5 +292,6 @@ def raise_for_rac(rac_code: int, message: str | None = None) -> NoReturn: raise SDKException.of( code, message if message is not None else f"Native call failed (rac={rac_code})", + category=category_for_code(rac_code), c_abi_code=rac_code, ) diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index 958e80871b..732ca80af9 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -12,6 +12,7 @@ ErrorCode, SDKException, as_sdk_exception, + category_for_code, is_sdk_exception, raise_for_rac, ) @@ -52,8 +53,14 @@ def test_error_category_values(): # --------------------------------------------------------------------------- # # Proto-backed category behavior # # --------------------------------------------------------------------------- # -def test_constructor_does_not_recreate_native_category_mapping(): - assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.UNSPECIFIED +def test_category_for_code_matches_canonical_ranges(): + assert category_for_code(-111) == ErrorCategory.MODEL + assert category_for_code(-351) == ErrorCategory.IO + assert category_for_code(-371) == ErrorCategory.VALIDATION + assert category_for_code(-401) == ErrorCategory.COMPONENT + assert category_for_code(-501) == ErrorCategory.CONFIGURATION + assert category_for_code(-601) == ErrorCategory.COMPONENT + assert category_for_code(-12345) == ErrorCategory.UNSPECIFIED def test_from_proto_preserves_wire_category(): @@ -82,8 +89,7 @@ def test_error_code_covers_idl_surface(): def test_category_defaults_applied_in_ctor(): - # Categories are supplied by proto errors or explicit local factories. - assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.UNSPECIFIED + assert SDKException(ErrorCode.MODEL_NOT_FOUND, "x").category == ErrorCategory.MODEL # --------------------------------------------------------------------------- # @@ -113,7 +119,7 @@ def test_raise_for_rac_maps_minus_111_to_model_load_failed(): e = ei.value assert e.code == ErrorCode.MODEL_LOAD_FAILED assert e.c_abi_code == -111 - assert e.category == ErrorCategory.UNSPECIFIED + assert e.category == ErrorCategory.MODEL def test_raise_for_rac_unknown_code_falls_back_to_unknown(): @@ -131,7 +137,7 @@ def test_raise_for_rac_custom_message(): assert e.code == ErrorCode.CANCELLED assert str(e) == "user cancelled" assert e.c_abi_code == -380 - assert e.category == ErrorCategory.UNSPECIFIED + assert e.category == ErrorCategory.INTERNAL # --------------------------------------------------------------------------- # @@ -157,6 +163,10 @@ def test_recovery_suggestions(): assert SDKException(ErrorCode.GENERATION_FAILED, "x").recovery_suggestion is None +def test_storage_error_is_io(): + assert SDKException.storage_error().category == ErrorCategory.IO + + # --------------------------------------------------------------------------- # # is_expected # # --------------------------------------------------------------------------- # From b493d6fb8dda07e16a5e79724dd0278e7b83c017 Mon Sep 17 00:00:00 2001 From: Sreesanth R Date: Sun, 20 Sep 2026 14:52:23 +0000 Subject: [PATCH 05/15] fix(python): preserve unspecified proto error categories --- bindings/python/runanywhere/errors.py | 2 +- bindings/python/tests/test_errors.py | 55 +++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/bindings/python/runanywhere/errors.py b/bindings/python/runanywhere/errors.py index d118b2afba..43ddcec46d 100644 --- a/bindings/python/runanywhere/errors.py +++ b/bindings/python/runanywhere/errors.py @@ -132,7 +132,7 @@ def from_proto(err: Any) -> "SDKException": try: category = ErrorCategory(err.category) except ValueError: - category = None + category = ErrorCategory.UNSPECIFIED field_path = err.param if err.HasField("param") else None return SDKException.of( code, diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index 732ca80af9..702001b56e 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -53,13 +53,38 @@ def test_error_category_values(): # --------------------------------------------------------------------------- # # Proto-backed category behavior # # --------------------------------------------------------------------------- # -def test_category_for_code_matches_canonical_ranges(): - assert category_for_code(-111) == ErrorCategory.MODEL - assert category_for_code(-351) == ErrorCategory.IO - assert category_for_code(-371) == ErrorCategory.VALIDATION - assert category_for_code(-401) == ErrorCategory.COMPONENT - assert category_for_code(-501) == ErrorCategory.CONFIGURATION - assert category_for_code(-601) == ErrorCategory.COMPONENT +@pytest.mark.parametrize( + ("first", "last", "category"), + [ + (100, 109, ErrorCategory.CONFIGURATION), + (110, 129, ErrorCategory.MODEL), + (130, 149, ErrorCategory.INTERNAL), + (150, 179, ErrorCategory.NETWORK), + (180, 219, ErrorCategory.IO), + (220, 229, ErrorCategory.INTERNAL), + (230, 249, ErrorCategory.COMPONENT), + (250, 279, ErrorCategory.VALIDATION), + (280, 299, ErrorCategory.IO), + (300, 319, ErrorCategory.COMPONENT), + (320, 349, ErrorCategory.AUTH), + (350, 369, ErrorCategory.IO), + (370, 379, ErrorCategory.VALIDATION), + (380, 389, ErrorCategory.INTERNAL), + (400, 499, ErrorCategory.COMPONENT), + (500, 599, ErrorCategory.CONFIGURATION), + (600, 699, ErrorCategory.COMPONENT), + (700, 999, ErrorCategory.INTERNAL), + ], +) +def test_category_for_code_matches_canonical_range_boundaries( + first, last, category +): + assert category_for_code(first) == category + assert category_for_code(last) == category + + +def test_category_for_code_preserves_zero_and_out_of_range_cases(): + assert category_for_code(0) == ErrorCategory.UNSPECIFIED assert category_for_code(-12345) == ErrorCategory.UNSPECIFIED @@ -80,6 +105,22 @@ def HasField(self, field): assert error.category == ErrorCategory.CONFIGURATION +def test_from_proto_unknown_wire_category_is_unspecified(): + class ProtoError: + code = ErrorCode.MODEL_LOAD_FAILED + category = 999 + message = "unknown category" + c_abi_code = -111 + nested_message = "" + param = "" + + def HasField(self, field): + return field in {"c_abi_code", "nested_message", "param"} + + error = SDKException.from_proto(ProtoError()) + assert error.category == ErrorCategory.UNSPECIFIED + + def test_error_code_covers_idl_surface(): """Pin the enum size so IDL additions force an update here.""" assert len(ErrorCode) >= 130 From a68972deba682bf0c39d69d29fcf06ace4389008 Mon Sep 17 00:00:00 2001 From: ziyaad-mallick Date: Tue, 22 Sep 2026 12:24:37 +0500 Subject: [PATCH 06/15] docs(readme): rename rcli to wally and drop the Homebrew line RunanywhereAI/RCLI was renamed to RunanywhereAI/wally, and both install scripts now install a binary called wally (install.sh:147,154 and install.ps1:144 on wally main 02d8d06). So every `rcli ...` example in the README failed for anyone who followed it end to end. This renames the binary, the repo links and the install URLs in the quick start, the terminal details section, the SDK and feature table rows, the OpenAI-compatible server section and the platform table. The commands and flags themselves are unchanged. wally main still registers pull (an alias of models download), run with --no-think and --image, tts, stt, voice, vad, embed, image, lora, serve on port 8080, and a global --json. The Homebrew lines are removed. runanywhereai/tap still has only Formula/rcli.rb (0.20.24, last commit e9c777b on Aug 18), which installs rcli, not wally, and wally's own README documents only the two install scripts. The brew line can come back once the tap ships a wally formula. Left as is on purpose: - The "RCLI WORKS" string in the transcript is the recorded model output for that prompt, and it hasn't been re-captured under wally. - The MetalRT paragraph and the "Real projects" entry describe RCLI as a MetalRT-powered voice assistant with 40+ system actions. wally's own AGENTS.md calls MetalRT retired, so a straight rename would make a claim about wally that I can't back. Refs #886 Co-Authored-By: Claude Opus 5 --- README.md | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b0f2837b74..9adc9e9372 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,11 @@ print(ra.llm.generate("Explain on-device AI in one sentence.", LlmOptions(model="qwen2.5-0.5b")).text) ``` -Prefer a terminal? Install the CLI from [RunanywhereAI/RCLI](https://github.com/RunanywhereAI/RCLI) — it consumes the C++ desktop kit this repo publishes: +Prefer a terminal? Install the CLI from [RunanywhereAI/wally](https://github.com/RunanywhereAI/wally) — it consumes the C++ desktop kit this repo publishes: ```bash -brew install runanywhereai/tap/rcli -# or -curl -fsSL https://raw.githubusercontent.com/RunanywhereAI/RCLI/main/install.sh | sh -rcli run qwen3 "Explain on-device AI in one sentence." +curl -fsSL https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.sh | sh +wally run qwen3 "Explain on-device AI in one sentence." ``` Building for mobile, web, or desktop? Every platform below speaks the same API. @@ -380,39 +378,33 @@ pip install runanywhere==0.20.11
-rcli (terminal) +wally (terminal)
```console -$ rcli pull qwen3 +$ wally pull qwen3 pulling qwen3-0.6b ▕████████████▏ 100% 639 MB/639 MB 32 MB/s -$ rcli run qwen3 "Reply with exactly: RCLI WORKS" --no-think +$ wally run qwen3 "Reply with exactly: RCLI WORKS" --no-think RCLI WORKS -$ rcli tts --text "RunAnywhere runs models on device." --output hello.wav -$ rcli stt --input hello.wav - Run anywhere runs models on device. -$ rcli voice --input question.wav --output reply.wav # full STT > LLM > TTS turn -$ rcli serve qwen3 # OpenAI-compatible API on :8080 +$ wally serve qwen3 # OpenAI-compatible API on :8080 ``` -Also: `rcli run --image photo.jpg` (VLM), `rcli vad`, `rcli embed`, `rcli image` (diffusion, Apple), `rcli lora`, and `--json` on everything. +Also: `--json` on every command. Install (macOS Apple Silicon, Linux x86_64): ```bash -brew install runanywhereai/tap/rcli -# or -curl -fsSL https://raw.githubusercontent.com/RunanywhereAI/RCLI/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.sh | sh ``` Windows (x64): ```powershell -irm https://raw.githubusercontent.com/RunanywhereAI/RCLI/main/install.ps1 | iex +irm https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.ps1 | iex ``` -[CLI README](https://github.com/RunanywhereAI/RCLI) +[CLI README](https://github.com/RunanywhereAI/wally)
@@ -429,7 +421,7 @@ irm https://raw.githubusercontent.com/RunanywhereAI/RCLI/main/install.ps1 | iex | **Web** | Chromium, Safari, Firefox | Beta | npm (`@runanywhere/web`) | [SDK README](bindings/web/) | | **Electron** | Windows x64 desktop | Preview | [Build from source](bindings/electron/) | [SDK README](bindings/electron/) | | **Python** | Windows, macOS, Linux | Alpha | pip (`runanywhere`) | [SDK README](bindings/python/) | -| **rcli** | macOS, Linux, Windows | Stable | Homebrew / install script | [RCLI](https://github.com/RunanywhereAI/RCLI) | +| **wally** | macOS, Linux, Windows | Stable | install script | [wally](https://github.com/RunanywhereAI/wally) | All SDKs ship on one version line, currently **0.20.29**, from a single C++ core. Pin the same version across the core package and its backends. See [Releases](https://github.com/RunanywhereAI/runanywhere-sdks/releases) for what is published today. @@ -437,7 +429,7 @@ All SDKs ship on one version line, currently **0.20.29**, from a single C++ core ## Features -| Feature | Swift | Kotlin | Flutter | RN | Web | Electron | Python | rcli | +| Feature | Swift | Kotlin | Flutter | RN | Web | Electron | Python | wally | |---------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | LLM generation + streaming | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | Vision language models (VLM) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | @@ -532,7 +524,7 @@ Available on the Kotlin, Flutter, and React Native SDKs. Snapdragon (Android arm ## OpenAI-compatible server -The Python SDK and rcli both expose the local runtime as a drop-in OpenAI API, so anything that speaks the OpenAI client works against models running on your machine: +The Python SDK and wally both expose the local runtime as a drop-in OpenAI API, so anything that speaks the OpenAI client works against models running on your machine: ```bash pip install "runanywhere[server]" @@ -549,7 +541,7 @@ reply = client.chat.completions.create( ) ``` -Endpoints: `/v1/chat/completions` (streaming and non-streaming, text and vision), `/v1/completions`, `/v1/embeddings`, `/v1/audio/transcriptions`, `/v1/audio/speech`, and `/v1/models`. `rcli serve` offers the same on port 8080. +Endpoints: `/v1/chat/completions` (streaming and non-streaming, text and vision), `/v1/completions`, `/v1/embeddings`, `/v1/audio/transcriptions`, `/v1/audio/speech`, and `/v1/models`. `wally serve` offers the same on port 8080. --- @@ -687,7 +679,7 @@ runanywhere-sdks/ | Flutter | 3.44+ (Dart 3.12+) | | Electron | Windows x64 (preview) | | Python | 3.9+ on Windows, macOS, Linux (3.12+ recommended) | -| rcli | macOS arm64, Linux x86_64 / aarch64, Windows x86_64 | +| wally | macOS arm64, Linux x86_64 / aarch64, Windows x86_64 | Hexagon NPU: Snapdragon with Hexagon v75 / v79 / v81, Android arm64. MLX: Apple silicon, physical devices. From 010199c2d59513a18391760df96246e5a87d0b26 Mon Sep 17 00:00:00 2001 From: ziyaad-mallick Date: Wed, 23 Sep 2026 19:51:09 +0500 Subject: [PATCH 07/15] docs(readme): correct wally's serve endpoints and Linux arch - `wally serve` is LLM-only and registers /v1/chat/completions and /v1/models (plus /health), not the Python server's full endpoint set. - install.sh only ships linux-x86_64; any other Linux arch fails with "x86_64 only", so drop aarch64 from the requirements table. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9adc9e9372..6277a4fb74 100644 --- a/README.md +++ b/README.md @@ -541,7 +541,7 @@ reply = client.chat.completions.create( ) ``` -Endpoints: `/v1/chat/completions` (streaming and non-streaming, text and vision), `/v1/completions`, `/v1/embeddings`, `/v1/audio/transcriptions`, `/v1/audio/speech`, and `/v1/models`. `wally serve` offers the same on port 8080. +Endpoints: `/v1/chat/completions` (streaming and non-streaming, text and vision), `/v1/completions`, `/v1/embeddings`, `/v1/audio/transcriptions`, `/v1/audio/speech`, and `/v1/models`. `wally serve` is LLM-only: it serves one model on port 8080 with `/v1/chat/completions` and `/v1/models`. --- @@ -679,7 +679,7 @@ runanywhere-sdks/ | Flutter | 3.44+ (Dart 3.12+) | | Electron | Windows x64 (preview) | | Python | 3.9+ on Windows, macOS, Linux (3.12+ recommended) | -| wally | macOS arm64, Linux x86_64 / aarch64, Windows x86_64 | +| wally | macOS arm64, Linux x86_64, Windows x86_64 | Hexagon NPU: Snapdragon with Hexagon v75 / v79 / v81, Android arm64. MLX: Apple silicon, physical devices. From 5826bed76aae1285f4a09d1c8a674c133c39173a Mon Sep 17 00:00:00 2001 From: ziyaad-mallick Date: Wed, 23 Sep 2026 21:12:44 +0500 Subject: [PATCH 08/15] docs(readme): use `wally models pull` in the quickstart wally 0.6.0 has no top-level `pull`: register_models_aliases() is defined in cmd_models.cpp but never called from app.cpp, so only the `models pull` subcommand is reachable. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6277a4fb74..b94fdc0f4f 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ pip install runanywhere==0.20.11
```console -$ wally pull qwen3 +$ wally models pull qwen3 pulling qwen3-0.6b ▕████████████▏ 100% 639 MB/639 MB 32 MB/s $ wally run qwen3 "Reply with exactly: RCLI WORKS" --no-think RCLI WORKS From 604c72f79a71a11302986e77665040a69f6b9b6c Mon Sep 17 00:00:00 2001 From: Sreesanth R Date: Wed, 14 Oct 2026 10:22:02 +0530 Subject: [PATCH 09/15] fix(electron): capture buf->status before free in rag_out_to_js, skip sdkError on RAC_SUCCESS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the error path of rag_out_to_js and make_rac_error: 1. rag_out_to_js (addon.cpp:3601): rac_proto_buffer_free(buf) was called before buf->status was read by make_rac_error. rac_proto_buffer_free -> release_fields -> reset_fields sets status = RAC_SUCCESS, so a failing ragClear() would always pass code 0 to make_rac_error, which then serialized a success SDKError. Fix: copy buf->status to a local before the free. 2. make_rac_error (addon.cpp:279): rac_result_to_proto_error has no skip-on-success branch — when called with RAC_SUCCESS it serializes a valid success SDKError proto. This took precedence over the real error code in asSDKException(), masking the actual failure category. Fix: skip rac_result_to_proto_error entirely when code == RAC_SUCCESS. The free-then-read in rag_out_to_js is pre-existing (unchanged from temp/development); the new sdkError field made the wrong code explicit. Signed-off-by: Sreesanth R --- bindings/electron/native/addon.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index e649a26caa..77da9b4b93 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -276,7 +276,11 @@ Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& value.Set("cAbiCode", Napi::Number::New(env, static_cast(code))); // raw rac_result_t rac_proto_buffer_t buffer; rac_proto_buffer_init(&buffer); - if (rac_result_to_proto_error(code, &buffer) == RAC_SUCCESS && buffer.data != nullptr && + // Only attach sdkError for genuine failures; rac_result_to_proto_error serializes a + // success proto when code == RAC_SUCCESS (no skip-on-success branch in commons), which + // would make asSDKException() see a success payload and misreport the error category. + if (code != RAC_SUCCESS && + rac_result_to_proto_error(code, &buffer) == RAC_SUCCESS && buffer.data != nullptr && buffer.size > 0) { value.Set("sdkError", Napi::Buffer::Copy(env, buffer.data, buffer.size)); } @@ -3597,14 +3601,18 @@ Napi::Value UnloadSegmentationModel(const Napi::CallbackInfo& info) { // releasing the native buffer. static Napi::Value rag_out_to_js(Napi::Env env, rac_proto_buffer_t* buf, const char* what) { if (buf->status != RAC_SUCCESS || buf->data == nullptr) { - std::string msg = std::string(what) + " failed: " + std::to_string(buf->status); + // Capture status and message before freeing: rac_proto_buffer_free resets + // buf->status to RAC_SUCCESS via release_fields/reset_fields, so reading + // buf->status after the free would always report success. + rac_result_t status = buf->status; + std::string msg = std::string(what) + " failed: " + std::to_string(status); if (buf->error_message) { msg += " ("; msg += buf->error_message; msg += ")"; } rac_proto_buffer_free(buf); - make_rac_error(env, buf->status, msg).ThrowAsJavaScriptException(); + make_rac_error(env, status, msg).ThrowAsJavaScriptException(); return env.Undefined(); } Napi::Buffer out = Napi::Buffer::Copy(env, buf->data, buf->size); From a1aed2ee3a834447b9ccb6aaa89159390bcbb6fa Mon Sep 17 00:00:00 2001 From: Sreesanth R Date: Wed, 14 Oct 2026 10:31:59 +0530 Subject: [PATCH 10/15] fix(electron): preserve contextual message when sdkError proto has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asSDKException() passed sdkError directly to fromSerializedSdkError, which decoded the proto and called SDKException.fromProto — discarding candidate.message (the contextual string from make_rac_error such as "ragClear failed: -42") and exposing only the generic code message from the proto, which is typically empty. Pass the contextual message to fromSerializedSdkError and use it as a fallback when proto.message is absent, so operation and backend failure details remain visible to callers. Addresses cubic-dev-ai[bot] review comment on #960. Signed-off-by: Sreesanth R --- bindings/electron/src/errors.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bindings/electron/src/errors.ts b/bindings/electron/src/errors.ts index 971b613a0d..445fd650ca 100644 --- a/bindings/electron/src/errors.ts +++ b/bindings/electron/src/errors.ts @@ -269,10 +269,17 @@ type ErrorLike = { * `rac_result_to_proto_error`, so when it is present the shared `categoryForCode` * table is not consulted at all. */ -function fromSerializedSdkError(value: unknown): SDKException | null { +function fromSerializedSdkError(value: unknown, contextualMessage?: string): SDKException | null { if (!(value instanceof Uint8Array) || value.length === 0) return null; try { - return SDKException.fromProto(SDKError.decode(value)); + const proto = SDKError.decode(value); + // Prefer the contextual message from make_rac_error (e.g. "ragClear failed: -42") + // over the generic proto message when the proto carries none, so operation details + // remain visible to the caller. + if (contextualMessage && !proto.message) { + proto.message = contextualMessage; + } + return SDKException.fromProto(proto); } catch { return null; } @@ -367,7 +374,8 @@ export function asSDKException(error: unknown): SDKException { if (error instanceof SDKException) return error; if (error && typeof error === 'object') { const candidate = error as ErrorLike; - const structured = fromSerializedSdkError(candidate.sdkError); + const contextualMessage = stringValue(candidate.message); + const structured = fromSerializedSdkError(candidate.sdkError, contextualMessage); if (structured) return structured; const message = stringValue(candidate.message) ?? From acc6bae5f101d34201b188b8e5aaffe3a6eb0a02 Mon Sep 17 00:00:00 2001 From: Sreesanth R <192179721+Shreesh-Sree@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:38:11 +0530 Subject: [PATCH 11/15] fix(errors): share canonical ranges and preserve native error details --- bindings/electron/native/addon.cpp | 11 +-- bindings/electron/src/errors.ts | 40 +++++--- bindings/electron/src/process/dispatch.ts | 1 + bindings/electron/test/unit/dispatch.test.ts | 30 +++++- bindings/electron/test/unit/errors.test.ts | 42 ++++++++ .../bridge/extensions/CppBridgeRAG.kt | 3 +- .../src/convenience/errors_category.ts | 5 +- bindings/python/_build/ra_build_backend.py | 5 +- .../python/docs/reference/architecture.md | 4 +- .../python/docs/reference/best-practices.md | 2 +- bindings/python/runanywhere/errors.py | 37 +------- bindings/python/tests/test_errors.py | 7 +- .../include/rac/core/rac_error_categories.def | 21 ++++ core/include/rac/core/rac_error_proto.h | 17 ++-- .../rac/foundation/rac_proto_adapters.h | 4 +- core/src/core/rac_error_proto.cpp | 8 +- core/src/foundation/rac_proto_adapters.cpp | 39 +------- core/tests/test_result_to_proto_error.cpp | 95 +++++++++++++------ idl/codegen/generate_python_errors.py | 46 ++++++++- 19 files changed, 276 insertions(+), 141 deletions(-) create mode 100644 core/include/rac/core/rac_error_categories.def diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index 77da9b4b93..73e343101c 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -262,13 +262,13 @@ rac_handle_t take_handle_when_idle(std::unordered_map& ma return h; } -int rac_code_abs(rac_result_t code) { - int value = static_cast(code); +/** Return a code magnitude without overflowing for INT32_MIN. */ +int64_t rac_code_abs(rac_result_t code) { + int64_t value = static_cast(code); return value < 0 ? -value : value; } -// Build a structured JS Error for a rac_result_t so the TS layer can recover a -// typed SDKException without depending on string parsing alone. +/** Build a native error with canonical proto details and the operation message. */ Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& message) { Napi::Error error = Napi::Error::New(env, message); Napi::Object value = error.Value(); @@ -3597,8 +3597,7 @@ Napi::Value UnloadSegmentationModel(const Napi::CallbackInfo& info) { // RAGResult/RAGStatistics in an owned rac_proto_buffer_t that we copy into a // Napi::Buffer and free. -// Copy an owned proto-out buffer to a JS Buffer (throwing on failure), always -// releasing the native buffer. +/** Copy an owned proto buffer to JS, preserving failure status before freeing it. */ static Napi::Value rag_out_to_js(Napi::Env env, rac_proto_buffer_t* buf, const char* what) { if (buf->status != RAC_SUCCESS || buf->data == nullptr) { // Capture status and message before freeing: rac_proto_buffer_free resets diff --git a/bindings/electron/src/errors.ts b/bindings/electron/src/errors.ts index 445fd650ca..5b8805a68a 100644 --- a/bindings/electron/src/errors.ts +++ b/bindings/electron/src/errors.ts @@ -63,6 +63,8 @@ export const ErrorCategories: Readonly> ) as Readonly>; export interface SDKErrorFields { + /** Serialized proto retained when a native error crosses utility-process RPC. */ + sdkError?: Uint8Array; code: ErrorCode; message: string; category?: ErrorCategory; @@ -85,6 +87,7 @@ export interface SDKErrorFields { * Carries the canonical `code` / `category` for cross-SDK-uniform handling. */ export class SDKException extends Error { + readonly sdkError?: Uint8Array; readonly code: ErrorCode; readonly category: ErrorCategory; /** Negative rac_result_t equivalent, when applicable. */ @@ -105,6 +108,7 @@ export class SDKException extends Error { constructor(fields: SDKErrorFields) { super(fields.message || 'SDK error'); this.name = 'SDKException'; + this.sdkError = fields.sdkError; this.code = fields.code; this.category = fields.category ?? categoryForCode(fields.code); this.cAbiCode = @@ -162,7 +166,8 @@ export class SDKException extends Error { requestId: error.requestId, severity: error.severity, timestampMs: error.timestampMs, - }); + sdkError: SDKError.encode(error).finish(), + }, true); } static notInitialized(component?: string): SDKException { @@ -273,11 +278,11 @@ function fromSerializedSdkError(value: unknown, contextualMessage?: string): SDK if (!(value instanceof Uint8Array) || value.length === 0) return null; try { const proto = SDKError.decode(value); - // Prefer the contextual message from make_rac_error (e.g. "ragClear failed: -42") - // over the generic proto message when the proto carries none, so operation details - // remain visible to the caller. - if (contextualMessage && !proto.message) { - proto.message = contextualMessage; + // Keep operation/backend context alongside the canonical code-level message. + if (contextualMessage && proto.message !== contextualMessage) { + proto.message = proto.message + ? `${contextualMessage} (${proto.message})` + : contextualMessage; } return SDKException.fromProto(proto); } catch { @@ -307,8 +312,8 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set( * counterpart) still reports `UNKNOWN`, with the raw value readable on * `cAbiCode`, because typing it as an `ErrorCode` would be a lie. * - * A category is only derived when the wire supplied none — proto3 leaves an - * unset enum at 0, so `UNSPECIFIED` counts as unset. + * Serialized proto categories are authoritative, including UNSPECIFIED. Loose + * legacy fields still derive a category when their category is absent or zero. */ function fromStructuredError(args: { code: number; @@ -322,18 +327,25 @@ function fromStructuredError(args: { requestId?: string; severity?: number; timestampMs?: number; -}): SDKException { + sdkError?: Uint8Array; +}, authoritativeCategory = false): SDKException { const magnitude = Math.abs(Math.trunc(args.code)); const code = (KNOWN_ERROR_CODES.has(magnitude) ? magnitude : ErrorCode.ERROR_CODE_UNKNOWN) as ErrorCode; - const wireCategory = - args.category != null && Math.trunc(args.category) !== ErrorCategory.ERROR_CATEGORY_UNSPECIFIED - ? (Math.trunc(args.category) as ErrorCategory) - : undefined; + const knownCategory = args.category != null && Number.isInteger(args.category) && + args.category >= ErrorCategory.ERROR_CATEGORY_UNSPECIFIED && + args.category <= ErrorCategory.ERROR_CATEGORY_CONFIGURATION + ? args.category as ErrorCategory + : ErrorCategory.ERROR_CATEGORY_UNSPECIFIED; + const category = authoritativeCategory || + (args.category != null && args.category !== ErrorCategory.ERROR_CATEGORY_UNSPECIFIED) + ? knownCategory + : categoryForCode(magnitude); return new SDKException({ code, message: args.message, cAbiCode: args.cAbiCode, - category: wireCategory ?? categoryForCode(code), + category, + sdkError: args.sdkError, nestedMessage: args.nestedMessage, fieldPath: args.fieldPath, component: args.component, diff --git a/bindings/electron/src/process/dispatch.ts b/bindings/electron/src/process/dispatch.ts index b5b5ad684a..3ed4229789 100644 --- a/bindings/electron/src/process/dispatch.ts +++ b/bindings/electron/src/process/dispatch.ts @@ -117,6 +117,7 @@ export const ALLOWED_RPC_METHODS: ReadonlySet = new Set([ ...BACKEND_METHODS.map(rpcMethodFor), ]); +/** Retain native SDK error fields and proto bytes across the utility-process boundary. */ function rpcError(e: unknown): string | RpcErrorPayload { if (typeof e === 'string') return e; if (e instanceof Error) { diff --git a/bindings/electron/test/unit/dispatch.test.ts b/bindings/electron/test/unit/dispatch.test.ts index 07c02a236b..46721eaf72 100644 --- a/bindings/electron/test/unit/dispatch.test.ts +++ b/bindings/electron/test/unit/dispatch.test.ts @@ -1,5 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { SDKError, ErrorSeverity } from '@runanywhere/proto-ts/errors'; +import { asSDKException, ErrorCodes, ErrorCategories } from '../../dist/errors'; import * as os from 'os'; import * as path from 'path'; @@ -374,8 +376,14 @@ test('a structured native Error keeps numeric SDK fields over RPC', async () => test('a structured native Error keeps serialized sdkError bytes over RPC', async () => { const port = makePort(); - const sdkError = new Uint8Array([8, 3, 18, 5, 109, 111, 100, 101, 108]); - const err = Object.assign(new Error('load_model failed: -111'), { sdkError }); + const sdkError = SDKError.encode(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, category: ErrorCategories.MODEL, + cAbiCode: -111, message: 'Model load failed', nestedMessage: 'backend cause', + param: 'model', component: 'rag', retryable: true, requestId: 'request-1', + severity: ErrorSeverity.ERROR_SEVERITY_ERROR, timestampMs: 123, + })).finish(); + // bridge.wrapNative converts native errors before dispatch sees them. + const err = asSDKException(Object.assign(new Error('load_model failed: -111'), { sdkError })); const deps = makeDeps({ api: { embed: () => { @@ -387,7 +395,23 @@ test('a structured native Error keeps serialized sdkError bytes over RPC', async dispatch(port, { id: 24, method: 'embed', args: [] }, deps); await tick(); - assert.deepEqual(port.posts, [{ id: 24, ok: false, error: { name: 'Error', message: err.message, sdkError } }]); + assert.equal(port.posts.length, 1); + assert.equal(port.posts[0]?.id, 24); + assert.equal(port.posts[0]?.ok, false); + const payload = port.posts[0]?.error; + assert.ok(payload && typeof payload === 'object'); + const received = asSDKException(payload); + assert.equal(received.message, 'load_model failed: -111 (Model load failed)'); + assert.equal(received.code, ErrorCodes.MODEL_LOAD_FAILED); + assert.equal(received.category, ErrorCategories.MODEL); + assert.equal(received.cAbiCode, -111); + assert.equal(received.nestedMessage, 'backend cause'); + assert.equal(received.fieldPath, 'model'); + assert.equal(received.component, 'rag'); + assert.equal(received.retryable, true); + assert.equal(received.requestId, 'request-1'); + assert.equal(received.severity, ErrorSeverity.ERROR_SEVERITY_ERROR); + assert.equal(received.timestampMs, 123); }); test('a non-Error thrown value is String()-ified', async () => { diff --git a/bindings/electron/test/unit/errors.test.ts b/bindings/electron/test/unit/errors.test.ts index af2ddc4a6b..9ec0105da4 100644 --- a/bindings/electron/test/unit/errors.test.ts +++ b/bindings/electron/test/unit/errors.test.ts @@ -1,6 +1,7 @@ // Unit tests for SDKException — the house-uniform throwable (src/errors.ts). import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { SDKError } from '@runanywhere/proto-ts/errors'; import { SDKException, @@ -162,9 +163,50 @@ test('asSDKException prefers structured native fields over UNKNOWN', () => { assert.equal(e.code, ErrorCodes.MODEL_LOAD_FAILED); assert.equal(e.cAbiCode, -111); assert.equal(e.category, ErrorCategories.MODEL); + for (const [wireMessage, context, expected] of [ + ['Model load failed', 'ragClear failed: -111 (backend detail)', 'ragClear failed: -111 (backend detail) (Model load failed)'], + ['', 'operation detail', 'operation detail'], + ['same detail', 'same detail', 'same detail'], + ['wire detail', '', 'wire detail'], + ]) { + const sdkError = SDKError.encode(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, + category: ErrorCategories.MODEL, + cAbiCode: -111, + message: wireMessage, + nestedMessage: 'backend cause', + param: 'model', + })).finish(); + const structured = asSDKException({ message: context, sdkError }); + assert.equal(structured.message, expected); + assert.equal(structured.code, ErrorCodes.MODEL_LOAD_FAILED); + assert.equal(structured.category, ErrorCategories.MODEL); + assert.equal(structured.cAbiCode, -111); + assert.equal(structured.nestedMessage, 'backend cause'); + assert.equal(structured.fieldPath, 'model'); + } }); test('asSDKException maps trailing rac codes in native strings', () => { + for (const code of [1, 99, 390, 399, 1000, 9999]) { + const sdkError = SDKError.encode(SDKError.fromPartial({ + code, category: ErrorCategories.UNSPECIFIED, cAbiCode: -code, + message: 'unmapped native code', + })).finish(); + const decoded = asSDKException({ sdkError }); + assert.equal(decoded.category, ErrorCategories.UNSPECIFIED); + assert.equal(decoded.cAbiCode, -code); + assert.equal(asSDKException({ code, cAbiCode: -code }).category, ErrorCategories.UNSPECIFIED); + } + for (const category of [ErrorCategories.UNSPECIFIED, -1, 999]) { + const decoded = SDKException.fromProto(SDKError.fromPartial({ + code: ErrorCodes.MODEL_LOAD_FAILED, category, message: 'future category', + })); + assert.equal(decoded.category, ErrorCategories.UNSPECIFIED); + if (category !== ErrorCategories.UNSPECIFIED) { + assert.equal(asSDKException({ code: 111, category }).category, ErrorCategories.UNSPECIFIED); + } + } const e = asSDKException('stream failed: -130'); assert.equal(e.code, ErrorCodes.GENERATION_FAILED); assert.equal(e.cAbiCode, -130); diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt index 8c12cdfde5..59a721bbcd 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeRAG.kt @@ -11,8 +11,8 @@ import ai.runanywhere.proto.v1.RAGQueryOptions import ai.runanywhere.proto.v1.RAGResult import ai.runanywhere.proto.v1.RAGSearchRequest import ai.runanywhere.proto.v1.RAGSearchResponse -import ai.runanywhere.proto.v1.RAGStreamEvent import ai.runanywhere.proto.v1.RAGStatistics +import ai.runanywhere.proto.v1.RAGStreamEvent import com.runanywhere.sdk.foundation.errors.SDKException import com.runanywhere.sdk.native.bridge.NativeProtoProgressListener import com.runanywhere.sdk.native.bridge.RunAnywhereBridge @@ -235,6 +235,7 @@ object CppBridgeRAG { private fun requireSession(): Long = sessionHandle.takeIf { it != 0L } ?: throw SDKException.notInitialized("RAG session not created") + /** Reuse canonical native error conversion, including its serialization-failure fallback. */ private fun createSessionException(rc: Int): SDKException { if (rc == RunAnywhereBridge.RAC_SUCCESS) { return SDKException.operation("racRagSessionCreateProto returned 0") diff --git a/bindings/proto-ts/src/convenience/errors_category.ts b/bindings/proto-ts/src/convenience/errors_category.ts index 1df2bc9ca5..22974fb544 100644 --- a/bindings/proto-ts/src/convenience/errors_category.ts +++ b/bindings/proto-ts/src/convenience/errors_category.ts @@ -4,8 +4,9 @@ * * HAND-AUTHORED — not produced by `idl/codegen/generate_ts_convenience.py`. * The fold from numeric ranges onto the 9-bucket proto `ErrorCategory` exists - * only as C++ control flow, not as proto annotations, so there is nothing for - * the IDL codegen to emit. Do not invent a second table in an SDK. + * in `core/include/rac/core/rac_error_categories.def`, shared by commons and + * Python codegen. Keep this TS fallback aligned with that manifest. + * Do not invent a second table in an SDK. * * This table is the richer 18-range mapping historically shared byte-for-byte * by Web and React Native. It deliberately covers bands beyond |329| diff --git a/bindings/python/_build/ra_build_backend.py b/bindings/python/_build/ra_build_backend.py index 9a8ddc8789..eb13f5ec79 100644 --- a/bindings/python/_build/ra_build_backend.py +++ b/bindings/python/_build/ra_build_backend.py @@ -136,7 +136,10 @@ def _generate() -> None: def _newest_input_mtime() -> float: """Newest mtime across everything the three Python generators read.""" newest = 0.0 - for pattern in ("idl/*.proto", "idl/codegen/*.py", "idl/codegen/*.sh"): + for pattern in ( + "idl/*.proto", "idl/codegen/*.py", "idl/codegen/*.sh", + "core/include/rac/core/rac_error_categories.def", + ): for path in _REPO_ROOT.glob(pattern): try: newest = max(newest, path.stat().st_mtime) diff --git a/bindings/python/docs/reference/architecture.md b/bindings/python/docs/reference/architecture.md index 5302cd3eaa..e1579d7cb2 100644 --- a/bindings/python/docs/reference/architecture.md +++ b/bindings/python/docs/reference/architecture.md @@ -137,8 +137,8 @@ through the process-wide singleton `bus`, exposed as `runanywhere.events`. Subsc `errors.py` defines `SDKException` (the single throwable) carrying a canonical `code` (`ErrorCode`, exhaustive vs `idl/errors.proto`) + `category` (`ErrorCategory`) for -cross-SDK-uniform handling. `category_for_code` mirrors the canonical 18-range -commons mapping; unmapped negative values remain `UNSPECIFIED`. Category-specific static factories +cross-SDK-uniform handling. `category_for_code` is generated from shared 18-range +manifest (`core/include/rac/core/rac_error_categories.def`); unmapped negative values remain `UNSPECIFIED`. Category-specific static factories (`not_initialized`, `validation_failed`, `model_not_found`, `generation_failed`, `storage_error`, `invalid_state`, …) build the right code/category; `raise_for_rac(rac_code)` maps a negative `rac_result_t` back to an `ErrorCode` (preserving the raw ABI value as diff --git a/bindings/python/docs/reference/best-practices.md b/bindings/python/docs/reference/best-practices.md index 1e40236ecd..8b48b9a4c2 100644 --- a/bindings/python/docs/reference/best-practices.md +++ b/bindings/python/docs/reference/best-practices.md @@ -24,7 +24,7 @@ applications of it:) - Public options/results are dataclasses or IntEnums — never raw string status codes. - Native error codes come from `idl/errors.proto` / `rac_error.h`. Keep `ErrorCode` / `ErrorCategory` exhaustive relative to the IDL and map categories with - `category_for_code` using the canonical 18-range mapping (including AUTH + `category_for_code`, generated from commons’ shared 18-range manifest (including AUTH 320–349); unmapped negative values remain `UNSPECIFIED`. - Registry framework/category ints are **C ABI enums** (`RAC_FRAMEWORK_*`, `RAC_MODEL_CATEGORY_*`), not proto wire values. Name them and pin them in tests. diff --git a/bindings/python/runanywhere/errors.py b/bindings/python/runanywhere/errors.py index 43ddcec46d..03931f2428 100644 --- a/bindings/python/runanywhere/errors.py +++ b/bindings/python/runanywhere/errors.py @@ -4,39 +4,7 @@ from typing import Any, NoReturn -from ._generated_errors import ErrorCategory, ErrorCode - - -def category_for_code(code: int) -> ErrorCategory: - """Map a signed C ABI code or positive proto code to its canonical category.""" - magnitude = abs(int(code)) - if magnitude == 0: - return ErrorCategory.UNSPECIFIED - if 100 <= magnitude <= 109: - return ErrorCategory.CONFIGURATION - if 110 <= magnitude <= 129: - return ErrorCategory.MODEL - if 130 <= magnitude <= 149 or 220 <= magnitude <= 229: - return ErrorCategory.INTERNAL - if 150 <= magnitude <= 179: - return ErrorCategory.NETWORK - if 180 <= magnitude <= 219 or 280 <= magnitude <= 299 or 350 <= magnitude <= 369: - return ErrorCategory.IO - if 230 <= magnitude <= 249 or 300 <= magnitude <= 319: - return ErrorCategory.COMPONENT - if 250 <= magnitude <= 279 or 370 <= magnitude <= 379: - return ErrorCategory.VALIDATION - if 320 <= magnitude <= 349: - return ErrorCategory.AUTH - if 380 <= magnitude <= 389 or 700 <= magnitude <= 999: - return ErrorCategory.INTERNAL - if 400 <= magnitude <= 499: - return ErrorCategory.COMPONENT - if 500 <= magnitude <= 599: - return ErrorCategory.CONFIGURATION - if 600 <= magnitude <= 699: - return ErrorCategory.COMPONENT - return ErrorCategory.UNSPECIFIED +from ._generated_errors import ErrorCategory, ErrorCode, category_for_code class SDKException(Exception): @@ -66,6 +34,7 @@ def __init__( nested_message: str | None = None, field_path: str | None = None, ) -> None: + """Retain explicit wire categories; derive local defaults from shared generated data.""" super().__init__(message or "SDK error") self.message = message or "SDK error" self.code = code @@ -108,6 +77,7 @@ def of( nested_message: str | None = None, field_path: str | None = None, ) -> "SDKException": + """Construct an exception with optional wire details and category overrides.""" return SDKException( code, message, @@ -203,6 +173,7 @@ def generation_failed( def storage_error( details: str | None = None, cause: BaseException | None = None ) -> "SDKException": + """Describe file/download I/O failures while retaining their nested cause.""" return SDKException.of( ErrorCode.STORAGE_ERROR, details if details is not None else "Storage error", diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index 702001b56e..87fe5b641c 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -81,11 +81,14 @@ def test_category_for_code_matches_canonical_range_boundaries( ): assert category_for_code(first) == category assert category_for_code(last) == category + assert category_for_code(-first) == category + assert category_for_code(-last) == category def test_category_for_code_preserves_zero_and_out_of_range_cases(): - assert category_for_code(0) == ErrorCategory.UNSPECIFIED - assert category_for_code(-12345) == ErrorCategory.UNSPECIFIED + for code in (0, 1, 99, 390, 399, 1000, 12345, 2**31): + assert category_for_code(code) == ErrorCategory.UNSPECIFIED + assert category_for_code(-code) == ErrorCategory.UNSPECIFIED def test_from_proto_preserves_wire_category(): diff --git a/core/include/rac/core/rac_error_categories.def b/core/include/rac/core/rac_error_categories.def new file mode 100644 index 0000000000..b0080f286f --- /dev/null +++ b/core/include/rac/core/rac_error_categories.def @@ -0,0 +1,21 @@ +// Canonical proto error-category ranges, shared by commons and SDK codegen. +// Define RAC_ERROR_CATEGORY_RANGE(first, last, category) before including. +// Unmapped magnitudes (including zero) are UNSPECIFIED. +RAC_ERROR_CATEGORY_RANGE(100, 109, CONFIGURATION) +RAC_ERROR_CATEGORY_RANGE(110, 129, MODEL) +RAC_ERROR_CATEGORY_RANGE(130, 149, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(150, 179, NETWORK) +RAC_ERROR_CATEGORY_RANGE(180, 219, IO) +RAC_ERROR_CATEGORY_RANGE(220, 229, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(230, 249, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(250, 279, VALIDATION) +RAC_ERROR_CATEGORY_RANGE(280, 299, IO) +RAC_ERROR_CATEGORY_RANGE(300, 319, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(320, 349, AUTH) +RAC_ERROR_CATEGORY_RANGE(350, 369, IO) +RAC_ERROR_CATEGORY_RANGE(370, 379, VALIDATION) +RAC_ERROR_CATEGORY_RANGE(380, 389, INTERNAL) +RAC_ERROR_CATEGORY_RANGE(400, 499, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(500, 599, CONFIGURATION) +RAC_ERROR_CATEGORY_RANGE(600, 699, COMPONENT) +RAC_ERROR_CATEGORY_RANGE(700, 999, INTERNAL) diff --git a/core/include/rac/core/rac_error_proto.h b/core/include/rac/core/rac_error_proto.h index 0625513730..0abcc7e0cf 100644 --- a/core/include/rac/core/rac_error_proto.h +++ b/core/include/rac/core/rac_error_proto.h @@ -19,8 +19,8 @@ * * The implementation lives in `src/core/rac_error_proto.cpp` and uses the * existing internal helpers `rac_error_message()` (in `rac_error.h`) and a - * code-range based ErrorCategory mapping that mirrors - * `event_publisher.cpp::error_category_for_code()`. + * shared `rac::foundation::rac_result_to_proto_category()` mapper, also used + * by the event publisher. * * Memory ownership: on success the caller owns `out_proto->data` and must * release it with `rac_proto_buffer_free()`. On failure `out_proto->data` is @@ -43,17 +43,18 @@ extern "C" { * proto buffer. * * Populates `out_proto` with a serialized `runanywhere.v1.SDKError` containing: - * - `code` — proto ErrorCode = abs(`code`); ERROR_CODE_UNSPECIFIED on - * unrecognised values. - * - `category` — proto ErrorCategory derived from the canonical -100..-899 - * C ABI ranges (matches event_publisher.cpp). + * - `code` — proto ErrorCode = abs(`code`), including unknown values; + * INT32_MIN maps to ERROR_CODE_UNKNOWN because its positive + * magnitude cannot be represented by the int32 proto enum. + * - `category` — derived from the 18 ranges in rac_error_categories.def + * through -999; non-negative and unmapped codes are UNSPECIFIED. * - `message` — `rac_error_message(code)`; never empty for a valid code. * - `c_abi_code` — the original signed `code`, for lossless round-tripping. * - `timestamp_ms`— absent (zero) — populated by event publishers, not here. * - `severity` — ERROR_SEVERITY_ERROR. * - * On `RAC_SUCCESS` input this function writes a default-constructed SDKError - * into the buffer (proto3 default values) and returns `RAC_SUCCESS`. Callers + * On `RAC_SUCCESS` input this function writes an SDKError with code/category + * UNSPECIFIED (and the normal message/severity fields), returning `RAC_SUCCESS`. Callers * that wish to suppress emission of a "success" SDKError should test * `code == RAC_SUCCESS` themselves before calling. * diff --git a/core/include/rac/foundation/rac_proto_adapters.h b/core/include/rac/foundation/rac_proto_adapters.h index 6522371320..cf1356ba41 100644 --- a/core/include/rac/foundation/rac_proto_adapters.h +++ b/core/include/rac/foundation/rac_proto_adapters.h @@ -185,8 +185,8 @@ ::runanywhere::v1::ErrorCategory rac_category_to_proto(rac_error_category_t cate rac_error_category_t rac_proto_to_category(::runanywhere::v1::ErrorCategory category); // Map a rac_result_t error code directly to the proto ErrorCategory bucket -// (canonical -100..-329 ranges). Non-negative codes -> UNSPECIFIED; any other -// negative code -> INTERNAL. Single source of truth for the duplicated mappers +// (the canonical 18 ranges in rac/core/rac_error_categories.def, through -999). +// Non-negative and unmapped codes -> UNSPECIFIED. Single source of truth for the mappers // that previously lived (and drifted on out-of-range codes) in // rac_error_proto.cpp and event_publisher.cpp. ::runanywhere::v1::ErrorCategory rac_result_to_proto_category(rac_result_t code); diff --git a/core/src/core/rac_error_proto.cpp b/core/src/core/rac_error_proto.cpp index d33f3ee0c6..5f4c7b8780 100644 --- a/core/src/core/rac_error_proto.cpp +++ b/core/src/core/rac_error_proto.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include "rac/core/rac_error.h" @@ -36,8 +37,11 @@ void populate_sdk_error(::runanywhere::v1::SDKError* out, rac_result_t code) { } const int32_t signed_code = static_cast(code); - const int32_t abs_code = signed_code < 0 ? -signed_code : signed_code; - out->set_code(static_cast<::runanywhere::v1::ErrorCode>(abs_code)); + const int64_t magnitude = signed_code < 0 ? -static_cast(signed_code) : signed_code; + // INT32_MIN has no positive int32 enum equivalent; retain it in c_abi_code. + out->set_code(magnitude > std::numeric_limits::max() + ? ::runanywhere::v1::ERROR_CODE_UNKNOWN + : static_cast<::runanywhere::v1::ErrorCode>(magnitude)); out->set_category(rac::foundation::rac_result_to_proto_category(code)); const char* message = rac_error_message(code); diff --git a/core/src/foundation/rac_proto_adapters.cpp b/core/src/foundation/rac_proto_adapters.cpp index cda70f97bd..6636bdf46e 100644 --- a/core/src/foundation/rac_proto_adapters.cpp +++ b/core/src/foundation/rac_proto_adapters.cpp @@ -1147,40 +1147,11 @@ ::runanywhere::v1::ErrorCategory rac_result_to_proto_category(rac_result_t code) return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; const int64_t magnitude = -static_cast(code); - if (magnitude >= 100 && magnitude <= 109) - return ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION; - if (magnitude >= 110 && magnitude <= 129) - return ::runanywhere::v1::ERROR_CATEGORY_MODEL; - if (magnitude >= 130 && magnitude <= 149) - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; - if (magnitude >= 150 && magnitude <= 179) - return ::runanywhere::v1::ERROR_CATEGORY_NETWORK; - if ((magnitude >= 180 && magnitude <= 219) || (magnitude >= 280 && magnitude <= 299)) - return ::runanywhere::v1::ERROR_CATEGORY_IO; - if (magnitude >= 220 && magnitude <= 229) - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; - if (magnitude >= 230 && magnitude <= 249) - return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - if (magnitude >= 250 && magnitude <= 279) - return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; - if (magnitude >= 300 && magnitude <= 319) - return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - if (magnitude >= 320 && magnitude <= 349) - return ::runanywhere::v1::ERROR_CATEGORY_AUTH; - if (magnitude >= 350 && magnitude <= 369) - return ::runanywhere::v1::ERROR_CATEGORY_IO; - if (magnitude >= 370 && magnitude <= 379) - return ::runanywhere::v1::ERROR_CATEGORY_VALIDATION; - if (magnitude >= 380 && magnitude <= 389) - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; - if (magnitude >= 400 && magnitude <= 499) - return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - if (magnitude >= 500 && magnitude <= 599) - return ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION; - if (magnitude >= 600 && magnitude <= 699) - return ::runanywhere::v1::ERROR_CATEGORY_COMPONENT; - if (magnitude >= 700 && magnitude <= 999) - return ::runanywhere::v1::ERROR_CATEGORY_INTERNAL; +#define RAC_ERROR_CATEGORY_RANGE(first, last, category) \ + if (magnitude >= first && magnitude <= last) \ + return ::runanywhere::v1::ERROR_CATEGORY_##category; +#include "rac/core/rac_error_categories.def" +#undef RAC_ERROR_CATEGORY_RANGE return ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED; } diff --git a/core/tests/test_result_to_proto_error.cpp b/core/tests/test_result_to_proto_error.cpp index c679092b03..a3ac72efbc 100644 --- a/core/tests/test_result_to_proto_error.cpp +++ b/core/tests/test_result_to_proto_error.cpp @@ -275,24 +275,63 @@ void test_canonical_category_ranges() { }; const Case cases[] = { {static_cast(-100), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-109), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-110), ::runanywhere::v1::ERROR_CATEGORY_MODEL}, + {static_cast(-129), ::runanywhere::v1::ERROR_CATEGORY_MODEL}, {static_cast(-130), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-149), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-150), ::runanywhere::v1::ERROR_CATEGORY_NETWORK}, + {static_cast(-179), ::runanywhere::v1::ERROR_CATEGORY_NETWORK}, + {static_cast(-180), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-219), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-220), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-229), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, {static_cast(-230), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, - {static_cast(-330), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, - {static_cast(-351), ::runanywhere::v1::ERROR_CATEGORY_IO}, - {static_cast(-371), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, - {static_cast(-401), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, - {static_cast(-501), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, - {static_cast(-601), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, - {static_cast(-701), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, - {static_cast(-801), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, - {static_cast(-901), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-249), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-250), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-279), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-280), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-299), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-300), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-319), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-320), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, + {static_cast(-349), ::runanywhere::v1::ERROR_CATEGORY_AUTH}, + {static_cast(-350), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-369), ::runanywhere::v1::ERROR_CATEGORY_IO}, + {static_cast(-370), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-379), ::runanywhere::v1::ERROR_CATEGORY_VALIDATION}, + {static_cast(-380), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-389), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-400), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-499), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-500), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-599), ::runanywhere::v1::ERROR_CATEGORY_CONFIGURATION}, + {static_cast(-600), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-699), ::runanywhere::v1::ERROR_CATEGORY_COMPONENT}, + {static_cast(-700), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, + {static_cast(-999), ::runanywhere::v1::ERROR_CATEGORY_INTERNAL}, }; - for (const Case& test_case : cases) { + for (const auto& test_case : cases) { CHECK(rac::foundation::rac_result_to_proto_category(test_case.code) == test_case.category, - "canonical category range"); + "canonical negative range boundary"); CHECK(rac::foundation::rac_result_to_proto_category(-test_case.code) == ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, - "positive code is unspecified"); + "positive result is not an error"); + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + CHECK(rac_result_to_proto_error(test_case.code, &buffer) == RAC_SUCCESS, + "range boundary serializes"); + ::runanywhere::v1::SDKError parsed; + CHECK(parsed.ParseFromArray(buffer.data, static_cast(buffer.size)), + "range boundary proto parses"); + CHECK(parsed.category() == test_case.category, "serialized category matches mapper"); + CHECK(parsed.c_abi_code() == test_case.code, "serialized ABI code preserved"); + rac_proto_buffer_free(&buffer); + } + for (const rac_result_t code : {0, -1, -99, -390, -399, -1000, -12345, INT32_MIN}) { + CHECK(rac::foundation::rac_result_to_proto_category(code) == + ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, + "unmapped and minimum integer codes remain unspecified"); } } @@ -320,22 +359,24 @@ void test_unknown_negative_code_is_handled() { // A code outside any known range still serializes; category falls back to // UNSPECIFIED, which IS allowed for genuinely unknown codes (not in the // canonical -100..-999 ranges). - rac_proto_buffer_t buffer; - rac_proto_buffer_init(&buffer); - - const rac_result_t kBogus = static_cast(-12345); - const rac_result_t rc = rac_result_to_proto_error(kBogus, &buffer); - CHECK(rc == RAC_SUCCESS, "bogus code still serializes"); - - ::runanywhere::v1::SDKError parsed; - const bool parsed_ok = parsed.ParseFromArray(buffer.data, static_cast(buffer.size)); - CHECK(parsed_ok, "bogus code parses"); - CHECK(parsed.c_abi_code() == -12345, "bogus c_abi_code preserved"); + for (const rac_result_t code : {-12345, INT32_MIN}) { + rac_proto_buffer_t buffer; + rac_proto_buffer_init(&buffer); + const rac_result_t rc = rac_result_to_proto_error(code, &buffer); + CHECK(rc == RAC_SUCCESS, "unknown code still serializes"); + ::runanywhere::v1::SDKError parsed; + CHECK(parsed.ParseFromArray(buffer.data, static_cast(buffer.size)), + "unknown code parses"); + CHECK(parsed.c_abi_code() == code, "unknown ABI code preserved"); CHECK(parsed.category() == ::runanywhere::v1::ERROR_CATEGORY_UNSPECIFIED, - "bogus category is unspecified"); - CHECK(!parsed.message().empty(), "bogus code message not empty"); - - rac_proto_buffer_free(&buffer); + "unknown category is unspecified"); + if (code == INT32_MIN) { + CHECK(parsed.code() == ::runanywhere::v1::ERROR_CODE_UNKNOWN, + "unrepresentable magnitude maps to UNKNOWN"); + } + CHECK(!parsed.message().empty(), "unknown code message not empty"); + rac_proto_buffer_free(&buffer); + } } #endif // RAC_HAVE_PROTOBUF diff --git a/idl/codegen/generate_python_errors.py b/idl/codegen/generate_python_errors.py index abb53b26e9..7f4f94e5f8 100755 --- a/idl/codegen/generate_python_errors.py +++ b/idl/codegen/generate_python_errors.py @@ -10,8 +10,8 @@ # reader to "keep in sync" — the kind of instruction nothing enforces. # # This generates both enums instead. errors.py keeps its SDKException, its -# category_for_code range table, and everything else that is real logic; only -# the transcription moves here. +# helpers and factories. Category ranges come from the same manifest commons +# compiles, so Python never maintains a second table. # # Output: # bindings/python/runanywhere/_generated_errors.py @@ -22,6 +22,7 @@ from __future__ import annotations +import re import sys from pathlib import Path @@ -82,7 +83,28 @@ def collect( return found +def category_ranges() -> list[tuple[int, int, str]]: + """Read the same declarative category ranges compiled by commons.""" + manifest = Path(__file__).resolve().parents[2] / "core/include/rac/core/rac_error_categories.def" + ranges: list[tuple[int, int, str]] = [] + for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("//"): + continue + match = re.fullmatch(r"RAC_ERROR_CATEGORY_RANGE\((\d+), (\d+), ([A-Z]+)\)", line) + if match is None: + raise ValueError(f"Invalid error-category range: {line}") + first, last, category = match.groups() + start, end = int(first), int(last) + if start <= 0 or start > end or (ranges and start <= ranges[-1][1]): + raise ValueError(f"Unordered or overlapping error-category range: {line}") + ranges.append((start, end, category)) + if not ranges: + raise ValueError("Error-category manifest is empty") + return ranges + + def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: + """Emit error enums and the category helper from shared canonical inputs.""" lines: list[str] = [] # One-line docstring then the future import, per the Python SDK's module # convention; everything else is a comment below them. @@ -92,6 +114,7 @@ def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: lines.append("") lines.append("# GENERATED FILE - DO NOT EDIT.") lines.append("# Regenerate with: idl/codegen/generate_python_errors.py") + lines.append("# Category ranges: core/include/rac/core/rac_error_categories.def") lines.append("#") lines.append("# Values are the positive canonical numbers from the IDL. The C ABI returns") lines.append("# them negated as rac_result_t; negate once at the boundary rather than") @@ -115,8 +138,25 @@ def render(enums: dict[str, list[tuple[str, int, str]]]) -> str: lines.append(f" {py_name} = {number}") lines.append("") + lines.extend([ + "", + "def category_for_code(code: int) -> ErrorCategory:", + ' """Map code magnitudes using the canonical commons category ranges."""', + " magnitude = abs(int(code))", + ]) + categories = {name for name, _, _ in enums["ErrorCategory"]} + for first, last, category in category_ranges(): + if category not in categories: + raise ValueError(f"Unknown error category in manifest: {category}") + lines.extend([ + f" if {first} <= magnitude <= {last}:", + f" return ErrorCategory.{category}", + ]) + lines.extend([" return ErrorCategory.UNSPECIFIED", ""]) + # Sorted so RUF022 stays quiet on every regeneration. - names = ", ".join(f'"{n}"' for n in sorted(n for n in WANTED if enums.get(n))) + exported = sorted([n for n in WANTED if enums.get(n)] + ["category_for_code"]) + names = ", ".join(f'"{n}"' for n in exported) lines.append("") lines.append(f"__all__ = [{names}]") lines.append("") From 72433b29e6e28b0f0d28dedfc5874673df6b2267 Mon Sep 17 00:00:00 2001 From: Sreesanth R <192179721+Shreesh-Sree@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:04:00 +0530 Subject: [PATCH 12/15] fix(errors): address follow-up review comments --- bindings/electron/native/addon.cpp | 51 ++++++++++++++++++---- bindings/electron/src/errors.ts | 18 +++++--- bindings/electron/test/unit/errors.test.ts | 2 + bindings/python/tests/test_errors.py | 7 ++- idl/codegen/generate_python_errors.py | 8 +++- 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index 73e343101c..a31bf36865 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -268,14 +268,23 @@ int64_t rac_code_abs(rac_result_t code) { return value < 0 ? -value : value; } +std::string current_rac_error_details() { + const char* details = rac_error_get_details(); + return details ? details : ""; +} + /** Build a native error with canonical proto details and the operation message. */ -Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& message) { +Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& message, + const std::string* worker_details = nullptr) { Napi::Error error = Napi::Error::New(env, message); Napi::Object value = error.Value(); value.Set("code", Napi::Number::New(env, rac_code_abs(code))); // canonical positive ErrorCode value.Set("cAbiCode", Napi::Number::New(env, static_cast(code))); // raw rac_result_t rac_proto_buffer_t buffer; rac_proto_buffer_init(&buffer); + const std::string previous_details = worker_details ? current_rac_error_details() : ""; + if (worker_details) + rac_error_set_details(worker_details->empty() ? nullptr : worker_details->c_str()); // Only attach sdkError for genuine failures; rac_result_to_proto_error serializes a // success proto when code == RAC_SUCCESS (no skip-on-success branch in commons), which // would make asSDKException() see a success payload and misreport the error category. @@ -284,6 +293,8 @@ Napi::Error make_rac_error(Napi::Env env, rac_result_t code, const std::string& buffer.size > 0) { value.Set("sdkError", Napi::Buffer::Copy(env, buffer.data, buffer.size)); } + if (worker_details) + rac_error_set_details(previous_details.empty() ? nullptr : previous_details.c_str()); rac_proto_buffer_free(&buffer); return error; } @@ -859,6 +870,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { const rac_result_t transport_rc = rac_desktop_http_transport_register(); if (transport_rc != RAC_SUCCESS) { code_ = transport_rc; + details_ = current_rac_error_details(); err_ = "desktop HTTP/device bootstrap failed: " + std::to_string(transport_rc); return; } @@ -916,6 +928,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { rac_proto_buffer_free(&p1out); if (rc != RAC_SUCCESS) { code_ = rc; + details_ = current_rac_error_details(); err_ = "sdk_init_phase1 failed: " + std::to_string(rc); return; } @@ -926,6 +939,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { if (rc != RAC_SUCCESS) { rac_proto_buffer_free(&p2out); code_ = rc; + details_ = current_rac_error_details(); err_ = "sdk_init_phase2 failed: " + std::to_string(rc); return; } @@ -941,7 +955,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { if (ok_) { deferred_.Resolve(Napi::Buffer::Copy(Env(), result_.data(), result_.size())); } else { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); } } @@ -957,6 +971,7 @@ class ControlPlaneWorker : public Napi::AsyncWorker { std::string app_identifier_, app_name_, app_version_; std::vector phase1_, phase2_, result_; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -1079,6 +1094,7 @@ struct StreamCtx { Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; std::string error_msg; + std::string error_details; std::function run; // performs the rac streaming call int32_t lease_id = 0; // begin_op id; end_op when the worker finishes bool leased = false; @@ -1189,7 +1205,7 @@ Napi::Promise start_stream(Napi::Env env, Napi::Function on_token, std::string msg = c->error_msg.empty() ? ("stream failed: " + std::to_string(c->result)) : c->error_msg; - c->deferred.Reject(make_rac_error(env, c->result, msg).Value()); + c->deferred.Reject(make_rac_error(env, c->result, msg, &c->error_details).Value()); } delete c; }, @@ -1205,6 +1221,8 @@ Napi::Promise start_stream(Napi::Env env, Napi::Function on_token, } if (rc != RAC_SUCCESS && ctx->result == RAC_SUCCESS) ctx->result = rc; + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); if (ctx->leased) { end_op(ctx->lease_id); ctx->leased = false; @@ -2226,6 +2244,7 @@ struct SttStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; std::shared_ptr gate; @@ -2290,7 +2309,8 @@ Napi::Value TranscribeStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "transcribe stream failed: " + std::to_string(c->result)) + "transcribe stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -2302,6 +2322,8 @@ Napi::Value TranscribeStream(const Napi::CallbackInfo& info) { ctx->result = rac_stt_component_transcribe_stream( ctx->handle, ctx->audio.data(), ctx->audio.size(), &ctx->opts.opts, stt_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); } if (ctx->leased) { end_op(ctx->lease_id); @@ -2508,6 +2530,7 @@ struct TtsStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; std::shared_ptr gate; @@ -2573,7 +2596,8 @@ Napi::Value SynthesizeStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "synthesize stream failed: " + std::to_string(c->result)) + "synthesize stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -2584,6 +2608,8 @@ Napi::Value SynthesizeStream(const Napi::CallbackInfo& info) { std::lock_guard serialize(*ctx->gate); ctx->result = rac_tts_component_synthesize_stream( ctx->handle, ctx->text.c_str(), &ctx->opts.opts, tts_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); } if (ctx->leased) { end_op(ctx->lease_id); @@ -3672,6 +3698,8 @@ class RagProtoWorker : public Napi::AsyncWorker { rac_proto_buffer_t out; rac_proto_buffer_init(&out); rac_result_t rc = op_(session_, input_.data(), input_.size(), &out); + if (rc != RAC_SUCCESS || out.status != RAC_SUCCESS) + details_ = current_rac_error_details(); if (rc != RAC_SUCCESS && out.status == RAC_SUCCESS) out.status = rc; if (out.status == RAC_SUCCESS && out.data != nullptr) { @@ -3694,7 +3722,7 @@ class RagProtoWorker : public Napi::AsyncWorker { if (ok_) { deferred_.Resolve(Napi::Buffer::Copy(Env(), result_.data(), result_.size())); } else { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); } } @@ -3722,6 +3750,7 @@ class RagProtoWorker : public Napi::AsyncWorker { std::string what_; std::vector result_; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -3821,6 +3850,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { rac_result_t rc = rac_rag_session_create_proto(config_.data(), config_.size(), &session_); code_ = rc; if (rc != RAC_SUCCESS || session_ == nullptr) { + details_ = current_rac_error_details(); err_ = "rag session create failed: " + std::to_string(rc); session_ = nullptr; ok_ = false; @@ -3832,7 +3862,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { void OnOK() override { Napi::HandleScope scope(Env()); if (!ok_ || session_ == nullptr) { - deferred_.Reject(make_rac_error(Env(), code_, err_).Value()); + deferred_.Reject(make_rac_error(Env(), code_, err_, &details_).Value()); return; } int32_t hid; @@ -3874,6 +3904,7 @@ class RagCreateSessionWorker : public Napi::AsyncWorker { std::vector config_; rac_handle_t session_ = nullptr; std::string err_; + std::string details_; bool ok_ = false; rac_result_t code_ = RAC_SUCCESS; }; @@ -3918,6 +3949,7 @@ struct RagStreamCtx { std::thread worker; Napi::Promise::Deferred deferred; rac_result_t result = RAC_SUCCESS; + std::string error_details; int32_t lease_id = 0; bool leased = false; rac_handle_t session = nullptr; @@ -3969,7 +4001,8 @@ Napi::Value RagQueryStream(const Napi::CallbackInfo& info) { } else { c->deferred.Reject( make_rac_error(env, c->result, - "rag query stream failed: " + std::to_string(c->result)) + "rag query stream failed: " + std::to_string(c->result), + &c->error_details) .Value()); } delete c; @@ -3978,6 +4011,8 @@ Napi::Value RagQueryStream(const Napi::CallbackInfo& info) { ctx->worker = std::thread([ctx]() { ctx->result = rac_rag_query_stream_proto(ctx->session, ctx->query.data(), ctx->query.size(), rag_stream_cb, ctx); + if (ctx->result != RAC_SUCCESS) + ctx->error_details = current_rac_error_details(); if (ctx->leased) { end_op(ctx->lease_id); ctx->leased = false; diff --git a/bindings/electron/src/errors.ts b/bindings/electron/src/errors.ts index 5b8805a68a..a1e2df46b8 100644 --- a/bindings/electron/src/errors.ts +++ b/bindings/electron/src/errors.ts @@ -153,7 +153,7 @@ export class SDKException extends Error { } /** Build from a decoded proto `SDKError` payload (the D5 structured-error field). */ - static fromProto(error: SDKError): SDKException { + static fromProto(error: SDKError, sdkError?: Uint8Array): SDKException { return fromStructuredError({ code: error.code, message: error.message, @@ -166,7 +166,7 @@ export class SDKException extends Error { requestId: error.requestId, severity: error.severity, timestampMs: error.timestampMs, - sdkError: SDKError.encode(error).finish(), + sdkError: sdkError ?? SDKError.encode(error).finish(), }, true); } @@ -278,13 +278,19 @@ function fromSerializedSdkError(value: unknown, contextualMessage?: string): SDK if (!(value instanceof Uint8Array) || value.length === 0) return null; try { const proto = SDKError.decode(value); - // Keep operation/backend context alongside the canonical code-level message. - if (contextualMessage && proto.message !== contextualMessage) { - proto.message = proto.message + // Keep the native bytes untouched: they may contain fields this SDK does + // not know yet. Context belongs on the display exception, not in the wire + // payload. If it already contains the proto message, this is a second decode + // of an RPC error and adding the suffix again would duplicate it. + const exception = SDKException.fromProto(proto, value); + if (contextualMessage && !contextualMessage.includes(proto.message)) { + exception.message = proto.message ? `${contextualMessage} (${proto.message})` : contextualMessage; + } else if (contextualMessage) { + exception.message = contextualMessage; } - return SDKException.fromProto(proto); + return exception; } catch { return null; } diff --git a/bindings/electron/test/unit/errors.test.ts b/bindings/electron/test/unit/errors.test.ts index 9ec0105da4..8f24540bd9 100644 --- a/bindings/electron/test/unit/errors.test.ts +++ b/bindings/electron/test/unit/errors.test.ts @@ -184,6 +184,8 @@ test('asSDKException prefers structured native fields over UNKNOWN', () => { assert.equal(structured.cAbiCode, -111); assert.equal(structured.nestedMessage, 'backend cause'); assert.equal(structured.fieldPath, 'model'); + assert.deepEqual(structured.sdkError, sdkError); + assert.equal(asSDKException(structured).message, expected); } }); diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index 87fe5b641c..88cc268220 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -79,10 +79,9 @@ def test_error_category_values(): def test_category_for_code_matches_canonical_range_boundaries( first, last, category ): - assert category_for_code(first) == category - assert category_for_code(last) == category - assert category_for_code(-first) == category - assert category_for_code(-last) == category + for code in range(first, last + 1): + assert category_for_code(code) == category + assert category_for_code(-code) == category def test_category_for_code_preserves_zero_and_out_of_range_cases(): diff --git a/idl/codegen/generate_python_errors.py b/idl/codegen/generate_python_errors.py index 7f4f94e5f8..e89dd1c416 100755 --- a/idl/codegen/generate_python_errors.py +++ b/idl/codegen/generate_python_errors.py @@ -90,7 +90,13 @@ def category_ranges() -> list[tuple[int, int, str]]: for line in manifest.read_text(encoding="utf-8").splitlines(): if not line.strip() or line.lstrip().startswith("//"): continue - match = re.fullmatch(r"RAC_ERROR_CATEGORY_RANGE\((\d+), (\d+), ([A-Z]+)\)", line) + macro = line.split("//", 1)[0].strip() + if not macro: + continue + match = re.fullmatch( + r"RAC_ERROR_CATEGORY_RANGE\(\s*(\d+)\s*,\s*(\d+)\s*,\s*([A-Z]+)\s*\)", + macro, + ) if match is None: raise ValueError(f"Invalid error-category range: {line}") first, last, category = match.groups() From 3614f68b1711c5f425df0e6700795bb32f6b63a2 Mon Sep 17 00:00:00 2001 From: Sreesanth R <192179721+Shreesh-Sree@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:12:42 +0530 Subject: [PATCH 13/15] fix(errors): align unknown code fallbacks across TS SDKs --- bindings/electron/test/unit/errors.test.ts | 2 +- bindings/proto-ts/AGENTS.md | 2 +- .../core/src/Foundation/Errors/SDKException.ts | 2 +- .../packages/core/tests/unit/SDKException.test.ts | 9 +++++++++ .../core/tests/unit/Foundation/SDKException.test.ts | 13 +++++++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts diff --git a/bindings/electron/test/unit/errors.test.ts b/bindings/electron/test/unit/errors.test.ts index 8f24540bd9..73f48a629b 100644 --- a/bindings/electron/test/unit/errors.test.ts +++ b/bindings/electron/test/unit/errors.test.ts @@ -190,7 +190,7 @@ test('asSDKException prefers structured native fields over UNKNOWN', () => { }); test('asSDKException maps trailing rac codes in native strings', () => { - for (const code of [1, 99, 390, 399, 1000, 9999]) { + for (const code of [1, 99, 390, 399, 1000, 9999, 12345]) { const sdkError = SDKError.encode(SDKError.fromPartial({ code, category: ErrorCategories.UNSPECIFIED, cAbiCode: -code, message: 'unmapped native code', diff --git a/bindings/proto-ts/AGENTS.md b/bindings/proto-ts/AGENTS.md index 88a25f6d3b..e66f4ecfd3 100644 --- a/bindings/proto-ts/AGENTS.md +++ b/bindings/proto-ts/AGENTS.md @@ -84,7 +84,7 @@ adding the matching `exports` entry, or `moduleResolution: node16` consumers get | File | Why it's hand-written | |---|---| | `convenience/_errors.ts` | Defines `ValidationError`, the typed exception the *generated* `validate()` helpers throw. Its shape (`code`/`category`/`fieldPath`/`message`) is byte-isomorphic with the proto-backed `SDKException` Swift/Kotlin/Dart throw — see `idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md §9.1`. | -| `convenience/errors_category.ts` | The `ErrorCode` → `ErrorCategory` numeric-range table. The fold exists only as C++ control flow (`rac::foundation::rac_result_to_proto_category` in `core/src/foundation/rac_proto_adapters.cpp`), not as proto annotations, so there is nothing for codegen to read. This TS table intentionally covers more ranges (up to 999) than commons currently maps (100–329, falls through to INTERNAL) — do not "fix" it down to match commons; that's a known, deliberately deferred commons gap. | +| `convenience/errors_category.ts` | The shared `ErrorCode` → `ErrorCategory` numeric-range table. It mirrors the 18 canonical ranges declared in `core/include/rac/core/rac_error_categories.def`, which commons compiles into `rac::foundation::rac_result_to_proto_category`; non-negative and unmapped codes return `UNSPECIFIED`. Update the manifest and regenerate the language-specific consumers together when the mapping changes. | | `events/public_events.ts` | The v4 public streaming-event discriminated unions (`GenerationEvent`, `TranscriptionEvent`, `VoiceEvent`, `DownloadEvent`, etc.) shared by Web/RN/Electron. Payload slots are generic type parameters so each SDK binds its own local result/match/segment types without forking the discriminant arms. Deprecated RN-only arms live in separate `*WithDeprecated` aliases — new SDKs must not re-export those. | | `streams/_streamFactory.ts` | `streamFactory()`: adapts a subscribe-based transport into a lazy `AsyncIterable`. Generated `streams/*_service_stream.ts` wrappers import this; `idl/codegen/generate_streams.sh` only ever writes the `*_service_stream.ts` files, never this one. | | `streams/push.ts` | Push→`AsyncIterable` adapters (`pushStream`, `iterableFromSubscription`, `mapStream`, `deferStream`, `forEachStream`, `AsyncQueue`, ...) used across Web/RN/Electron. Three constraints are load-bearing and apply to any addition here: lazy start (producer runs on first `next()`, not construction); cancel-on-`return()`; and Electron `contextBridge`-safety — iterators must be plain object literals with `next`/`return`/`throw`/`Symbol.asyncIterator` as **own** properties, because `contextBridge` strips prototype methods (`AsyncQueue`'s `[Symbol.asyncIterator]` is a class field for the same reason). Also Hermes-safe: nothing here requires `for await...of` (RN/Hermes can't do that over a Nitro-backed iterable — consumers use `forEachStream`/manual `iterator.next()` loops instead). | diff --git a/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts b/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts index dd3be58303..98048a086e 100644 --- a/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts +++ b/bindings/react-native/packages/core/src/Foundation/Errors/SDKException.ts @@ -488,7 +488,7 @@ export async function sdkExceptionFromRcResult( ? (absCode as ErrorCodeProto) : ErrorCodeProto.ERROR_CODE_UNKNOWN, `Error code: ${rcResult}`, - { cAbiCode: rcResult } + { cAbiCode: rcResult, category: categoryForCode(rcResult) } ); } diff --git a/bindings/react-native/packages/core/tests/unit/SDKException.test.ts b/bindings/react-native/packages/core/tests/unit/SDKException.test.ts index 14636f9fcc..7c5c02f8ac 100644 --- a/bindings/react-native/packages/core/tests/unit/SDKException.test.ts +++ b/bindings/react-native/packages/core/tests/unit/SDKException.test.ts @@ -14,6 +14,7 @@ import { SDKException, isSDKException, asSDKException, + sdkExceptionFromRcResult, } from '../../src/Foundation/Errors/SDKException'; describe('SDKException', () => { @@ -131,4 +132,12 @@ describe('SDKException', () => { expect(exception.category).toBe(ErrorCategory.ERROR_CATEGORY_AUTH); }); }); + + it('keeps an unmapped raw rac code UNSPECIFIED when the proto code is unknown', async () => { + const exception = await sdkExceptionFromRcResult(-12345); + + expect(exception?.code).toBe(ErrorCode.ERROR_CODE_UNKNOWN); + expect(exception?.cAbiCode).toBe(-12345); + expect(exception?.category).toBe(ErrorCategory.ERROR_CATEGORY_UNSPECIFIED); + }); }); diff --git a/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts b/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts new file mode 100644 index 0000000000..6d4c79cad5 --- /dev/null +++ b/bindings/web/packages/core/tests/unit/Foundation/SDKException.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorCategory } from '@runanywhere/proto-ts/errors'; +import { SDKException } from '../../../src/Foundation/SDKException.js'; + +describe('SDKException category fallback', () => { + it('keeps an unmapped raw rac code UNSPECIFIED when proto code is unknown', () => { + const exception = SDKException.fromRACResult(-12345); + + expect(exception.code).toBe(0); + expect(exception.cAbiCode).toBe(-12345); + expect(exception.proto.category).toBe(ErrorCategory.ERROR_CATEGORY_UNSPECIFIED); + }); +}); From ea1d1e141b4ccd18a26c7b4f4f6350438dad1bbe Mon Sep 17 00:00:00 2001 From: Sreesanth R <192179721+Shreesh-Sree@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:13:07 +0530 Subject: [PATCH 14/15] fix(electron): retain RAG proto worker error detail --- bindings/electron/native/addon.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bindings/electron/native/addon.cpp b/bindings/electron/native/addon.cpp index a31bf36865..d8dc9bb49c 100644 --- a/bindings/electron/native/addon.cpp +++ b/bindings/electron/native/addon.cpp @@ -3698,10 +3698,15 @@ class RagProtoWorker : public Napi::AsyncWorker { rac_proto_buffer_t out; rac_proto_buffer_init(&out); rac_result_t rc = op_(session_, input_.data(), input_.size(), &out); - if (rc != RAC_SUCCESS || out.status != RAC_SUCCESS) - details_ = current_rac_error_details(); if (rc != RAC_SUCCESS && out.status == RAC_SUCCESS) out.status = rc; + if (out.status != RAC_SUCCESS) { + if (out.error_message && out.error_message[0] != '\0') { + details_ = out.error_message; + } else { + details_ = current_rac_error_details(); + } + } if (out.status == RAC_SUCCESS && out.data != nullptr) { result_.assign(out.data, out.data + out.size); ok_ = true; From 14724d27a117db1e983c91e073583fbf1219ca51 Mon Sep 17 00:00:00 2001 From: Sreesanth R <192179721+Shreesh-Sree@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:20:53 +0530 Subject: [PATCH 15/15] docs(proto-ts): clarify manual category table sync --- bindings/proto-ts/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/proto-ts/AGENTS.md b/bindings/proto-ts/AGENTS.md index e66f4ecfd3..3186e55efd 100644 --- a/bindings/proto-ts/AGENTS.md +++ b/bindings/proto-ts/AGENTS.md @@ -84,7 +84,7 @@ adding the matching `exports` entry, or `moduleResolution: node16` consumers get | File | Why it's hand-written | |---|---| | `convenience/_errors.ts` | Defines `ValidationError`, the typed exception the *generated* `validate()` helpers throw. Its shape (`code`/`category`/`fieldPath`/`message`) is byte-isomorphic with the proto-backed `SDKException` Swift/Kotlin/Dart throw — see `idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md §9.1`. | -| `convenience/errors_category.ts` | The shared `ErrorCode` → `ErrorCategory` numeric-range table. It mirrors the 18 canonical ranges declared in `core/include/rac/core/rac_error_categories.def`, which commons compiles into `rac::foundation::rac_result_to_proto_category`; non-negative and unmapped codes return `UNSPECIFIED`. Update the manifest and regenerate the language-specific consumers together when the mapping changes. | +| `convenience/errors_category.ts` | The shared `ErrorCode` → `ErrorCategory` numeric-range table. It mirrors the 18 canonical ranges declared in `core/include/rac/core/rac_error_categories.def`, which commons compiles into `rac::foundation::rac_result_to_proto_category`; non-negative and unmapped codes return `UNSPECIFIED`. This TypeScript file is hand-written and no TS generator consumes the manifest: update this table manually alongside the manifest whenever ranges change, then regenerate generated consumers such as Python's category helper. Web, React Native, and Electron all consume this table. | | `events/public_events.ts` | The v4 public streaming-event discriminated unions (`GenerationEvent`, `TranscriptionEvent`, `VoiceEvent`, `DownloadEvent`, etc.) shared by Web/RN/Electron. Payload slots are generic type parameters so each SDK binds its own local result/match/segment types without forking the discriminant arms. Deprecated RN-only arms live in separate `*WithDeprecated` aliases — new SDKs must not re-export those. | | `streams/_streamFactory.ts` | `streamFactory()`: adapts a subscribe-based transport into a lazy `AsyncIterable`. Generated `streams/*_service_stream.ts` wrappers import this; `idl/codegen/generate_streams.sh` only ever writes the `*_service_stream.ts` files, never this one. | | `streams/push.ts` | Push→`AsyncIterable` adapters (`pushStream`, `iterableFromSubscription`, `mapStream`, `deferStream`, `forEachStream`, `AsyncQueue`, ...) used across Web/RN/Electron. Three constraints are load-bearing and apply to any addition here: lazy start (producer runs on first `next()`, not construction); cancel-on-`return()`; and Electron `contextBridge`-safety — iterators must be plain object literals with `next`/`return`/`throw`/`Symbol.asyncIterator` as **own** properties, because `contextBridge` strips prototype methods (`AsyncQueue`'s `[Symbol.asyncIterator]` is a class field for the same reason). Also Hermes-safe: nothing here requires `for await...of` (RN/Hermes can't do that over a Nitro-backed iterable — consumers use `forEachStream`/manual `iterator.next()` loops instead). |