Repository navigation
feat(business-rules): add BusinessRulesService with run() - #1912
ashishupadhyay88 wants to merge 29 commits into
Conversation
Adds sdk.business_rules, a client for the Business Rules service that evaluates a DMN business rule deployed to Orchestrator against one input. - evaluate / evaluate_async: single input in, decisions out; the service's batch contract stays internal, matching the .NET client - folder scoping by folder_key or folder_path (resolved to a key, as the service accepts keys only), falling back to UIPATH_FOLDER_KEY/PATH - client-side validation of rule name and input size, mirroring the service and the .NET client - overall status (Success / PartialSuccess / AllFailed) derived from decision- and input-level errors; 207 partial results are returned, error envelopes raise EnrichedException - auth, retry, tenant URL scoping and trace propagation are inherited from BaseService Bumps uipath-platform to 0.2.33. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Fix the folder key/path presence check and add coverage for the documented UIPATH_FOLDER_PATH fallback.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds Business Rules evaluation support to uipath-platform, including sync/async APIs, validation, folder resolution, and response handling.
Changes:
- Added Business Rules models and service client.
- Exposed
UiPath.business_rules. - Added tests, documentation, and version updates.
| File | Reviewed changes |
|---|---|
packages/uipath/uv.lock |
Dependency lock update |
packages/uipath-platform/uv.lock |
Platform version lock update |
packages/uipath-platform/tests/services/test_business_rules_service.py |
Service behavior tests |
packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py |
Evaluation models and statuses |
packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py |
Evaluation service, validation, folder resolution, and response mapping |
packages/uipath-platform/src/uipath/platform/business_rules/__init__.py |
Public API exports |
packages/uipath-platform/src/uipath/platform/_uipath.py |
business_rules client integration |
packages/uipath-platform/pyproject.toml |
Package version bump |
packages/uipath-platform/CLAUDE.md |
Service documentation |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Replaces the public evaluate()/evaluate_async() with run()/run_async(), modelled on the .NET client's RunAsync: the caller passes a run context (DeployedRunContext(rule_name, version)) and never picks an endpoint. The result is BusinessRuleRunResult, stamped with the RunMode that ran. The evaluate request builder stays private, so a debug run context can be added later without changing the public entry point. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tested against alphaThis tests Setup
What this confirms
Checks in the locked uv environment (Python 3.11)
|
- Add sync and async tests for the UIPATH_FOLDER_PATH env fallback, which resolves the path to a key before sending x-uipath-folderkey, and a test that UIPATH_FOLDER_KEY wins over UIPATH_FOLDER_PATH without a lookup (Copilot review). - Build DeployedRunContext outside pytest.raises so each block has a single call that can raise (Sonar python:S5778). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sync() Callers can now pass TraceContext(trace_id, parent_span_id), like the .NET client's TraceContext, to file the run's spans under a trace of their choosing. When it is omitted the header stays automatic: the trace from UIPATH_TRACE_ID and the current span, as for every service. - TraceContext validates at construction: 32-hex (or UUID) trace id, 16-hex parent span id, neither all zeros; ids are normalised. - BaseService always sets the ambient header, so a request hook on this service's own sync and async httpx clients replaces it with the explicit value just before sending. Shared code is unchanged. - The explicit value lives in a ContextVar for the duration of the call, so it never leaks into the next call or across concurrent async runs, and it survives retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NishankSiddharth
left a comment
There was a problem hiding this comment.
do we need to add get, list methods for business rules (listing BRs, getting details for BRs)?
Addresses review on #1912: - run()/run_async() take the rule name as the first parameter, like processes.invoke(name, input_arguments): run("Loan Pricing", {"age": 14}, version=..., folder_path=...). DeployedRunContext is removed (never released); version is a keyword. - @resource_override(resource_type="businessRule") on both, so a solution's bindings can remap the rule name and folder per environment. "businessRule" is the Studio resource kind. - Add "businessRule" to GenericResourceOverwrite so such bindings parse, as done for memorySpace (#1586) and remoteA2aAgent (#1581). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1912's review fix (name-first run() with resource overrides) into the debug PR and reshapes debug runs to match: - run(name, input, *, debug=DebugRunContext(...)): the rule name is the first parameter for both modes, so bindings can remap it for debug runs too. DebugRunContext drops rule_name and keeps project_id, file_name, job_key and organization_unit_id. - Without project_id, the run resolves the project from the job's lineage and needs job_key (or UIPATH_JOB_KEY) and organization_unit_id. - version applies to deployed rules only and is rejected with debug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review: describe the service in product terms in the package guide and docstrings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1912's wording change into the debug PR and rewords the debug docstrings and field descriptions the same way. Literal .dmn file names in examples and tests are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes Sonar python:S7503 on the async request hook, which had to be `async` for httpx but awaited nothing. The explicit trace_context is now carried in the headers passed to BaseService: a small dict that ignores BaseService's later write of the ambient trace header when an explicit one is set. This removes both httpx request hooks, the async hook function and the ContextVar. Each call gets its own headers, so nothing leaks between calls or across concurrent async runs, and retries reuse them (new test). Shared code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's renames and applies them to the debug path too: _run_spec(run_target, rule_input, ...), _debug_evaluate_spec(rule_name, rule_input, ...) with request_body, _RunTarget(rule_name, ..., debug_job_key), and debug_job_key in _resolve_debug_job_key. The debug tests use the renamed _service_response / _single_decision_result helpers, whose local is now response_body. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Internal parameters, locals, _RunTarget's field, the name validator (_validate_business_rule_name) and its constants now say business_rule_name, matching the wire's businessRuleName and BusinessRuleRunResult.business_rule_name. The public run(name, input) and its error messages are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule_name -> business_rule_name rename and applies it to the debug path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The internal helpers take it as input, the same name as run()'s parameter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule_input -> input rename and applies it to the debug path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers the review question of whether the name check matches what the
platform enforces:
- Orchestrator only bounds a name when the rule is created: [Required],
[MaxLength(100)] (LengthRestrictions.BusinessRuleName), no character rules.
- The business-rules service, on both the deployed and the debug path, runs
BusinessRuleNames.requireSafe: letters and numbers in any script (\p{L},
\p{N}), space and '._()[]{}+,&@!~=:;-, at most 256, and never "..".
The SDK used a blocklist (/ \ .. % and control characters), looser than the
service, so names like "Loan#1", "Rule?" or one with a non-breaking space
passed it and then failed at the service with a 400. It now applies the
service's allowlist, so both accept exactly the same names and a bad one
fails before anything is sent. The error names the characters it refused.
Checked against alpha: 24 names sent straight to the service, skipping the
SDK check, and the SDK agreed with the service on every one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule-name allowlist, which debug runs share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…valuate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in latest main through #1912. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f365f2f to
f61841f
Compare
Name-first run() with debug context. Brings #1912's review fix (name-first run() with resource overrides) into the debug PR and reshapes debug runs to match: - run(name, input, *, debug=DebugRunContext(...)): the rule name is the first parameter for both modes, so bindings can remap it for debug runs too. DebugRunContext drops rule_name and keeps project_id, file_name, job_key and organization_unit_id. - Without project_id, the run resolves the project from the job's lineage and needs job_key (or UIPATH_JOB_KEY) and organization_unit_id. - version applies to deployed rules only and is rejected with debug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Say business rules, not DMN. Brings #1912's wording change into the debug PR and rewords the debug docstrings and field descriptions the same way. Literal .dmn file names in examples and tests are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Organization_unit_id on run() for debug too. Brings #1912's organization_unit_id into the debug PR and applies the "require only what's needed, accept optional values" rule to debug runs: - organization_unit_id moves from DebugRunContext to run(), next to folder_key / folder_path. Job-lineage mode requires it (and a job key, explicit or UIPATH_JOB_KEY); project mode needs no folder at all. - Project mode no longer rejects a job key or folder id the caller passes: they're sent as given. UIPATH_JOB_KEY is still not added on its own, and businessRuleName is still never sent with projectId. - version is sent with debug runs when given, instead of being rejected. - explain=True needs a folder key in every mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1912 now takes 0.2.34 (main released 0.2.33), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in latest main through #1912. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1912 now takes 0.2.35 (main released 0.2.34), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the business rules error extractor from #1912. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the rule-name control-character fix from #1912. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's convention refactor and docs page. The debug path follows the same shape: the debug request builder is a method, self._debug_evaluate_spec(name, input, *, job_key, decision_names), and the routing helper is _resolve_debug_job_key. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's removal of the client span; debug runs open none either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's shared run()/run_async() steps. The debug path joins them: _RunTarget also carries the debug job key (and no folder), decided in _prepare_run() before the binding, and _run_spec() builds the request for the endpoint the target names. run() and run_async() stay three steps each. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's renames and applies them to the debug path too: _run_spec(run_target, rule_input, ...), _debug_evaluate_spec(rule_name, rule_input, ...) with request_body, _RunTarget(rule_name, ..., debug_job_key), and debug_job_key in _resolve_debug_job_key. The debug tests use the renamed _service_response / _single_decision_result helpers, whose local is now response_body. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule_name -> business_rule_name rename and applies it to the debug path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule_input -> input rename and applies it to the debug path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1912's rule-name allowlist, which debug runs share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in latest main through #1912. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lists the Business Rules models under Models, as Documents does, so the service page links BusinessRuleRunResult and its other types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|





Summary
Jira: MST-15365
Adds
sdk.business_rules, a client for the Business Rules service. It runs a business rule deployed to Orchestrator against one input.This is PR 1 of 2. #1917 adds debug runs of undeployed rules, with the SDK picking the endpoint.
Errors from the service come back as
EnrichedException, with the service's own code:run_async()has the same signature, for callers already running on an event loop, such as LangGraph or FastAPI.Flow
flowchart LR A["run(name, input)"] --> F{"Folder key?<br/>folder_key, folder_path<br/>or the environment"} F -- no --> X["ValueError<br/>nothing sent"] F -- yes --> E["POST /evaluate<br/>x-uipath-folderkey"] E -- "200 / 207" --> R["BusinessRuleRunResult"] E -- "4xx / 5xx" --> ER["EnrichedException"]Before sending, the SDK applies any
businessRulebinding and checks the rule name and input.BaseServiceadds the token, the org/tenant URL, the trace header and retries. The Design section below has the details.Design
The contract follows the service after UiPath/business-rules#124, #126 and #130, and the .NET client 2.0.0 (UiPath/business-rules#131). The call shape follows this SDK's resource conventions.
run(name, input, *, version=None, …)matchesprocesses.invoke(name, input_arguments). The endpoint and the request builder (_evaluate_spec) are private.inputand the response is read fromresult. The deprecatedinputsbatch is never sent, and a successful response withoutresultraisesValueError.folder_keyis sent as is;folder_pathis looked up throughFolderServiceand sent as its key. Onlyx-uipath-folderkeygoes on the wire, never the numeric folder id.UIPATH_FOLDER_KEY, thenUIPATH_FOLDER_PATH). A blank value counts as not given. Without a folder,run()raises before anything is sent.folder_keyandfolder_pathtogether are aValueError, as inheader_folder()across the SDK.explain. The service decides what it traces.calleris sent for the execution audit. EachBusinessRuleCallerfield defaults to the job's value:resource_keyfromUIPATH_PROCESS_UUID,run_keyfromUIPATH_JOB_KEY,folder_keyfromUIPATH_FOLDER_KEY. That last one is the caller's folder, not the rule's. Blank fields are left out, and so iscallerwhen every field is blank.businessRulebindings can remap the rule name and folder per environment, through a private helper decorated with@resource_override(resource_type="businessRule"). When a binding remaps the rule, its folder replaces the caller's, including afolder_key.BusinessRuleNames.requireSafe): letters and numbers in any script, space and'._()[]{}+,&@!~=:;-, at most 256 characters, never... The SDK accepts exactly the names the service accepts; the error names the characters it refusedAllFailed. Otherwise the status comes from how many decisions failed. A 207 partial result is returned normally, withtop_level_errorwhen the service reports one.ValueError, and nothing is sent;EnrichedExceptionwithstatus_code, the response body, anderror_info.error_code/error_info.messageread from the service's{"error": {"code", "message"}}envelope;decision.errororerrors, with the status set accordingly.run()/run_async()are deliberately not@traced. The service publishes the run's decision and rule spans to Trace View, so a client span would only wrap them and record the rule's input and outputs on the client. The .NET client opens no spans and never logs input data either.x-uipath-traceparent-idnames the caller's current span by default (trace id fromUIPATH_TRACE_IDwhen set), so the service's spans nest directly under the caller's. Outside any trace no header is sent, and the service starts a trace of its own.trace_context=TraceContext(trace_id, parent_span_id), the same shape as .NET's, with the ids checked when it's created.BaseService, as a small dict that ignoresBaseService's later write of the ambient header. There are no request hooks and no change toBaseService, and it survives retries.BaseService:UIPATH_SERVICE_URL_BUSINESSRULESlocal overrideFor reviewers
x-uipath-internal-accountid/-tenantid; the gateway adds them, confirmed on alpha."businessRule"inGenericResourceOverwrite(common/_bindings.py), as feat: add memorySpace to resource overwrite types #1586 / fix: accept remoteA2aAgent in GenericResourceOverwrite #1581 did for their kinds.businessrules_error extractor (errors/_extractors/_businessrules.py, registered in_router.py). The service nests its error as{"error": {"code", "message"}}; the generic extractor reads only the message from there, soEnrichedException.error_info.error_codewasNone. It now carries the service's code (RULE_NOT_FOUND,INVALID_REQUEST, …), like .NET'sBusinessRulesException.Code. Other shapes, such as gateway errors, still go through the generic extractor.uipath-platformgoes to 0.2.35 (main released 0.2.34).uv lock --checkpasses for both packages.ca881df3): the service's single-input contract.input/result, noexplain, folder key only (organization_unit_idremoved),calleradded,RunMode/result.moderemoved.a8bb4596): thebusinessrules_error extractor above.e29c9161): a rule name now refuses only UnicodeCc, the same set as .NET'schar.IsControl. Before,str.isprintable()also refused non-breaking and zero-width spaces, so a name pasted from a document could pass .NET and fail here.ce7fe087): the request builder is a method (self._evaluate_spec), folder resolution is_resolve_folder_key/_resolve_folder_key_asyncas in context grounding, memory and AgentHub, helpers have clearer names, and afolder_pathmatching no folder now says so. No behaviour change.ce7fe087):docs/core/business_rules.mdand a Business Rules entry under Services inmkdocs.yml, sosdk.business_rulesappears in the published SDK docs. Checked withmkdocs build.2c8bcb03):@tracedremoved fromrun()/run_async(), as described under Tracing.fa84d396):run()andrun_async()no longer repeat each other._prepare_run()applies the binding, validates and picks the folder; only the folder lookup and therequest()call differ.request()stays in the public methods, becauseBaseServicenamesx-uipath-user-agentafter the method that calls it; a test now pinsrun_async's user agent too.39b9ea67): locals and internal parameters renamed (run_target,request_spec,business_rule_name,request_body,wire_response, …). The publicrun(name, input)is unchanged.4a2c2ec5), answering the review question: Orchestrator only bounds a name at creation ([Required],[MaxLength(100)], no character rules), while the service refuses anything outside its allowlist. The SDK's blocklist let names likeLoan#1through to a 400; it now uses the service's allowlist. Verified on alpha: 24 names sent straight to the service, 0 disagreements with the SDK.main(05748b93).folder_key; non-dict input) were fixed in162cf34aand are resolved.Test plan
tests/services/test_business_rules_service.py, covering:input, noinputsorexplain)#,?,*,$,",|, emoji, non-breaking / zero-width space, control characters,/,\,%,.., over 256) and 8 it acceptsresult, error responsesbusinessRuleoverrides (including overfolder_key)run_asynctests/errors/test_enriched_exception.pyfor the error extractor: the service's envelope, a code without a message, and other shapes falling back to the generic extractor.uipath-platformsuite passes: 1855 passed, 7 skipped (live credentials).ruff check,ruff format --checkandmypy src testsare clean.bruleswe/DefaultTenant), checked on the wire:folder_path="Shared"→ looked up, onlyx-uipath-folderkeysent →Successrun_asyncwithfolder_keyanddecision_names→ only that decisionEnrichedException; no folder →ValueError, nothing senterror_info.error_code == "UNAUTHENTICATED"🤖 Generated with Claude Code