sample: add ticket-triage-agent (System 1/System 2 pattern with Jev) - #1907
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The Action Center escalation path should fail fast with a clear message when UIPATH_FOLDER_PATH is missing and should not return an optional task.id where an int is promised.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
Adds a new ticket-triage-agent coded-agent sample under packages/uipath/samples/ demonstrating a two-tier “System 1 / System 2” support-ticket triage pattern: deterministic structured triage (mocked Jev) feeding either Action Center escalation or an LLM Gateway auto-reply, plus an accompanying evaluation set.
Changes:
- Introduces a new sample agent implementation (
main.py) and a local offline mock of the Jev “System One” client (jev_client.py). - Adds evaluation configuration (eval set + multiclass/binary evaluators) for routing and escalation decisions.
- Adds sample packaging/config files (
pyproject.toml,uipath.json, bindings, env example, input) and documentation (README.md).
| File | Description |
|---|---|
| packages/uipath/samples/ticket-triage-agent/uipath.json | Declares the agent entry point for uipath run. |
| packages/uipath/samples/ticket-triage-agent/README.md | Documents the System 1/System 2 pattern, setup, and eval usage. |
| packages/uipath/samples/ticket-triage-agent/pyproject.toml | Sample package metadata and dependencies. |
| packages/uipath/samples/ticket-triage-agent/main.py | Implements triage, escalation via Action Center, and LLM-drafted auto-reply. |
| packages/uipath/samples/ticket-triage-agent/jev_client.py | Provides a deterministic local mock for Jev-style typed Q/A triage. |
| packages/uipath/samples/ticket-triage-agent/input.json | Example input ticket for local runs. |
| packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/escalation-decision.json | Binary evaluator config for whether escalation occurred. |
| packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json | Multiclass evaluator config for department routing. |
| packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json | Eval set inputs and per-evaluation criteria overrides. |
| packages/uipath/samples/ticket-triage-agent/bindings.json | Sample bindings stub (no resources). |
| packages/uipath/samples/ticket-triage-agent/.env.example | Documents required env vars for Orchestrator + folder context. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two-tier support ticket triage: a mocked "System One" model (Jev, stubbed locally rather than pulled from the unverified same-day typesafe-sdk package) does fast structured routing/urgency/frustration scoring, then either escalates to an Action Center HITL task or drafts an auto-reply via the LLM Gateway. Includes an eval set covering both branches across all three departments. escalate_to_human passes folder_path to create_quickform, which requires an Orchestrator folder (RequireOrganizationUnit); verified end-to-end against a real tenant, including real Action Center task creation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
66f553b to
0ae4594
Compare
Statically inspected the typesafe-sdk wheel (no install/execution) before wiring it in: an apparently auto-generated OpenAPI client with no eval/exec/ subprocess calls, no env-var exfiltration, and a single documented API host. Its httpx2 dependency is real and unrelated to TypeSafe AI (also used by the mcp SDK). Pins typesafe-sdk>=0.7.0 and drops the local jev_client.py stub; verified end-to-end against the real Jev API for both the auto-reply and escalation paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rding Addresses Copilot PR review: escalate_to_human now raises a clear RuntimeError when UIPATH_FOLDER_PATH is unset (previously silently omitted the folder, causing a hard-to-diagnose Orchestrator 400) and when Action Center returns no task id (Task.id is Optional[int], but this function promises -> int). Also clarifies the README: Jev's urgency answer is a calibrated 0-1 probability, not a boolean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The implementation/docs in the added sample conflict with the PR title/description about using an offline Jev mock, and the async entrypoint currently performs blocking sync network calls without mitigation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
Resolved since last review (2)
uipreliga
left a comment
There was a problem hiding this comment.
Review: uipath-python (UiPath Python SDK monorepo) — pr:1907 (UiPath/uipath-python) at PR HEAD 66f553b, base main
PR #1907 — "sample: add ticket-triage-agent (System 1/System 2 pattern with mocked Jev)" by akshaylive; base main; 11 files, +799/-0, all newly ADDED.
Change class: complex — introduces a new decision algorithm (keyword-scored mock classifier + threshold-based escalation branching) plus a public-facing sample contract and an eval set whose ground truth encodes that algorithm's behavior
This PR adds one self-contained sample and touches no shipped SDK code — all 22 confirmed findings live under packages/uipath/samples/ticket-triage-agent/, and Security, Types, and Architecture are clean — but because samples/** is excluded from both ruff and mypy, nothing in CI guards it, so a committed project id that hijacks every reader's uipath pack projectId, an eval set that demands live credentials and leaves three uncleaned Action Center tasks per run, a sha256 tie-break salt that decides ~70% of zero-signal escalations, an urgency threshold the README's own "urgent"/"ASAP" example can never reach, and a content or "" that reports an empty auto-reply as a resolved ticket all ship unblocked; bottom line: no risk to the SDK, but roughly a day of sample-local fixes before this is fit to be the pattern users copy.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Correctness & Logic | 7.5 / 10 | 0 | 2 | 1 | 0 | URGENCY_THRESHOLD (0.6) exceeds every urgency keyword weight (max 0.5), so the urgency branch is unreachable and the README's "urgent"/"ASAP" escalation example does not escalate |
| 2. Code Quality & Simplicity | 8.8 / 10 | 0 | 1 | 0 | 2 | No-keyword tickets are routed by a sha256 tie-break salt that is normalized into department_confidence, so ~70% of zero-signal tickets clear the 0.45 low-confidence escalation gate |
| 3. Types & Contracts | 9.5 / 10 | 0 | 0 | 1 | 0 | A 0-1 continuous "Noul" score is named and documented as a boolean (is_urgent, "boolean" in three README places), and TriageDecision's numeric fields encode no range or scale |
| 4. Test & Validation Health | 6.9 / 10 | 0 | 3 | 0 | 1 | ambiguous-low-confidence, the only eval case covering the routing-confidence escalation branch, asserts a sha256 digest rather than triage behavior |
| 5. Security | 9.9 / 10 | 0 | 0 | 0 | 1 | README Step 3's credential setup is incomplete: it omits uipath auth in favor of hand-pasting a long-lived bearer token and omits the UIPATH_FOLDER_PATH the escalation branch needs |
| 6. Architecture & Design | 9.4 / 10 | 0 | 0 | 1 | 1 | Sample vendors a 213-line replica of an unvetted third-party SDK's API shape — half its Python — where sibling classification_agent does the same keyword classification inline in 53 lines |
| 7. Error Handling & Resilience | 8.9 / 10 | 0 | 1 | 0 | 1 | An empty or missing LLM completion is silently turned into an empty auto-reply and returned as a resolved ticket |
| 8. Interface, Docs & Compatibility | 6.4 / 10 | 0 | 2 | 3 | 1 | Following pyproject.toml's own "uncomment this line" instruction produces invalid TOML — the commented dependency sits after the dependencies array's closing bracket |
Overall Score: 8.4 / 10 · Weakest Axis: Interface, Docs & Compatibility at 6.4 / 10
Totals: 🔴 0 · 🟠 9 · 🟡 6 · 🔵 7 across 8 axes reviewed.
Blockers (0 🔴 Critical · 9 🟠 High)
- [Axis 1] URGENCY_THRESHOLD (0.6) exceeds every urgency keyword weight (max 0.5), so the urgency branch is unreachable and the README's "urgent"/"ASAP" escalation example does not escalate (
packages/uipath/samples/ticket-triage-agent/main.py:30) — README.md:98-100 tells the user:* An urgent or angry-sounding message (e.g. mentioning "urgent", "ASAP", or "furious") -> the agent creates an Action Center task instead and returns its task id.This is false for two of the three words it names. Every weight in_answer_noul'surgency_keywords(jev_client.py:176-185) is <= 0.5 ("urgent": 0.5,"asap": 0.5,"critical": 0.5,"immediately": 0.4,"down": 0.3, ...), while main.py:30 isURGENCY_THRESHOLD = 0.6and main.py:119 teststriage.is_urgent >= URGENCY_THRESHOLD. The urgency branch therefore can never fire on a ticket containing ONE urgency word — it needs at least two. Executed against the shipped mock:("Urgent request", "This is urgent, can you look at it?")->urg=0.5 frus=0.0 conf=0.5696 ESC=False;("Need help ASAP", "Please take a look ASAP.")->urg=0.5 frus=0.0 conf=0.7185 ESC=False. Only "furious" escalates, and it does so via the frustration branch (frus=2.0), not the urgency branch. A user following the README's "Try editing input.json to see both branches" gets the auto-reply branch and concludes the sample is broken. Fix one side: either setURGENCY_THRESHOLD = 0.5(which makes any single urgency keyword escalate, matching the doc), or raise the single-word weights inurgency_keywordsto 0.6+, or amend README.md:98-100 to say that escalation requires a compound urgency signal. Also note the shippedtechnical-outage-urgenteval case passes only because it stacks five urgency keywords (urgent+immediately+critical+down+for 3 days= 2.0, clamped to 1.0), so the eval set does not cover the single-keyword case the README advertises. - [Axis 1] _keyword_score at jev_client.py:91 does unanchored substring matching, so "breakdown" scores as "down" and "planning" scores as "plan" (note: the naive \b...\b fix regresses the billing-angry-customer eval) (
packages/uipath/samples/ticket-triage-agent/jev_client.py:91) — Match at word boundaries instead of bare substring, but allow a short inflectional suffix so the existing stem keywords keep working. Addimport reand change jev_client.py:91 to:
score = sum(
weight
for keyword, weight in keywords.items()
if re.search(rf"\b{re.escape(keyword)}\w{{0,3}}\b", lowered)
)The leading \b is what kills the false positives ("breakdown" and "download" have no word boundary before "down"; "planning" is not reached by \bplan\w{0,3}\b because "ning" is 4 characters), while the bounded \w{0,3} suffix preserves the inflected hits the current tables rely on ("charged" for "charge", "crashes" for "crash"). The multi-word phrases ("not working", "for 3 days", "right now") continue to match.
Do NOT use plain \b{keyword}\b: run against the shipped eval set it regresses billing-angry-customer from billing conf=0.9811 to technical conf=0.7029, missing its expectedClass: "billing", because "charged" stops matching "charge". With the \w{0,3} form every eval case except the two DELIBERATELY WRONG ones still matches its expected class, and billing-invoice-question improves to billing conf=0.9959, urg=0.0.
If exact-word semantics are preferred over the suffix heuristic, use \b{keyword}\b AND add the inflected forms ("charged", "charges", "crashes", ...) to the keyword tables so the eval set stays green. This is a sample users copy, so whichever pattern ships propagates.
3. [Axis 2] No-keyword tickets are routed by a sha256 tie-break salt that is normalized into department_confidence, so ~70% of zero-signal tickets clear the 0.45 low-confidence escalation gate (packages/uipath/samples/ticket-triage-agent/jev_client.py:161) — Delete the salt block (jev_client.py:161-166) and let zero-signal tickets produce confidence 0.0, so the ROUTING_CONFIDENCE_THRESHOLD = 0.45 check in needs_escalation (main.py:116-122) fires on every ticket with no keyword evidence instead of on ~30% of them. Also drop the now-unused import hashlib (jev_client.py:25) — line 164 is its only use in the file. With the block gone, total = sum(raw_scores.values()) or 1.0 already yields confidence = 0.0 and max() returns billing on question.criteria insertion order, which is exactly the billing / true pair the ambiguous-low-confidence eval case asserts.
4. [Axis 4] ambiguous-low-confidence, the only eval case covering the routing-confidence escalation branch, asserts a sha256 digest rather than triage behavior (packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json:107) — Make the low-confidence case deterministic by design rather than by luck. Note that a two-way keyword conflict will NOT work: confidence is max(probabilities), so a two-way split cannot fall below 0.5 (verified: a billing+sales equal-weight conflict gives conf 0.4995, above the 0.45 threshold, so it would not escalate and the EscalationDecisionEvaluator: "true" assertion would fail). A genuine three-way spread is required.
Verified working replacement for the case's inputs (lines 110-111) — one keyword from each of the three tables, with the intended winner weighted higher so the department is decided by the scoring logic instead of the salt:
"subject": "Refund, broken feature and a demo",
"message": "I want a refund because the export is not working, and can we also book a demo?"
This scores billing 0.6 ("refund"), technical 0.5 ("not working"), sales 0.5 ("demo") → dept=billing, confidence=0.3764, keeping both existing assertions (billing / true) valid. The 0.1 raw margin over the runners-up dwarfs the salt's ±0.0099 range, so it is stable: across the same seven trivial rewordings the department stays billing and confidence stays in 0.3719-0.3764, always below 0.45. All seven pass, versus one of seven today.
Alternatively, drop the DepartmentRoutingEvaluator entry for this case and keep only the escalation assertion, since the mock has no defined answer for a zero-signal ticket. This is directly supported: packages/uipath/src/uipath/eval/runtime/runtime.py:781-783 skips any evaluator whose id is absent from a case's evaluationCriterias, so omitting the key disables it cleanly rather than falling back to the evaluator's defaultEvaluationCriteria. This alone does not fix the escalation assertion, though — that still needs the deterministic ticket text above, since with zero keyword signal the 0.3813 confidence is a hash artifact that any reword can push past 0.45.
5. [Axis 4] The eval set's assertions are insensitive to the sample's scoring logic — deleting the entire URGENCY_THRESHOLD clause, or 31 of the 32 keyword-table entries, changes no case outcome (packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json:48) — Add a case that escalates on urgency ALONE — urgency at or above 0.6 with frustration below 1.0 and confidence above 0.45. Verified by executing the real jev_client code: subject "Integration down", message "Our integration is down, we need this fixed immediately. Please advise." produces dept=technical, conf=0.9921, urg=0.70, frus=0.00 (urgency = down 0.3 + immediately 0.4 = 0.7 >= 0.6; anger = please -0.1 clamped to 0.0 -> frustration 0.0), so it escalates on the urgency clause and on nothing else. Assert DepartmentRoutingEvaluator: "technical" and EscalationDecisionEvaluator: expectedClass "true".
Also add the mirror case just below the line so the 0.6 boundary is pinned from both sides. Do NOT reuse the same wording with immediately dropped — "Our integration is down. Please advise when you get a chance." lands at urg=0.30, which leaves the whole 0.31-0.60 band unguarded. Use a case that sits at exactly 0.5, verified by execution: subject "Integration issue", message "Our integration is critical to us. Could you please take a look when you get a chance? Thanks." produces dept=technical, conf=0.9829, urg=0.50, frus=0.00 -> expectedClass "false". Together these two pin URGENCY_THRESHOLD into (0.5, 0.7].
Consider also renaming technical-outage-urgent, since its current name — and README.md lines 119-121, which claim the set covers the urgent branch — overstate what is actually graded: that case escalates on frustration (frus=1.4 >= 1.0) and would still pass with the urgency clause deleted entirely.
6. [Axis 4] Tier 2 runs on every eval but is graded by nothing, so the eval set requires live credentials and leaves 3 uncleaned Action Center tasks per run — undocumented in the README (packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json:5) — The two evaluators grade only triage.department (evaluations/evaluators/department-routing.json:9) and escalated (evaluations/evaluators/escalation-decision.json:9). Both values are fully determined by triage_ticket (main.py:84) and needs_escalation (main.py:116), which run BEFORE client = UiPath() at main.py:208. Nothing asserts auto_reply or action_task_id — the two fields TicketOutput declares at main.py:75 and main.py:78 — so draft_auto_reply (main.py:182) and escalate_to_human (main.py:126), the entire Tier 2 the README foregrounds, ship with zero assertions.
Yet Tier 2 still runs on all 11 cases, with two consequences:
-
Credentials are mandatory for grading that needs none.
UiPath()raisesBaseUrlMissingError/SecretMissingErrorwhenUIPATH_URL/ token are unset (packages/uipath-platform/src/uipath/platform/_uipath.py:79-81), so every case — including the 9 whose graded outputs are computable fully offline — fails without a tenant. There is currently no way to gradeneeds_escalationor_keyword_scoreoffline. -
Persistent side effects. Replaying the deterministic
jev_clientstub over the eval inputs, 3 of the 11 cases escalate (technical-outage-urgent,billing-angry-customer,ambiguous-low-confidence), so eachuipath evalrun creates 3 real Action Center QuickForm tasks viaclient.tasks.create_quickform(main.py:164) that nothing cleans up, plus 8 paid LLM Gateway completions. (Note: this is not unprecedented in the repo —samples/attachment_evaluation_test/main.py:75-79likewise uploads a real job attachment per eval case with no cleanup — but the Action Center tasks are heavier and land in a shared work queue.)
The repo already ships the fix as a first-class, tested facility: EvaluationItem.mocking_strategy (packages/uipath/src/uipath/eval/models/evaluation_set.py:121, alias mockingStrategy) accepts the deterministic MockitoMockingStrategy (packages/uipath/src/uipath/eval/mocks/_types.py:94, type: "mockito", per-function then: [{"type": "return", "value": ...}]), exercised end-to-end in packages/uipath/tests/cli/eval/mocks/test_mocks.py:302 (test_mockito_mockable_sync) and :435 (test_mockito_mockable_async) against @mockable()-decorated functions. packages/uipath/samples/calculator/evaluations/eval-sets/default.json is the in-repo precedent for the eval-set JSON, and packages/uipath/samples/simulate-component-agent/main.py:20,60 for the sample-tree @mockable() usage. Omitting arguments from a behavior is supported and matches any call signature (packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py:77-81).
Fix — TWO changes are required, not one. Decorating the Tier-2 functions alone is NOT sufficient: client = UiPath() lives in main() at line 208, unconditionally and outside both mocked functions, so it would still raise BaseUrlMissingError on all 11 cases.
- Move the client construction out of
main()and into the two Tier-2 functions — drop theclient: UiPathparameter fromescalate_to_humananddraft_auto_reply, constructUiPath()inside each body, and delete line 208. (This also keeps the mocked signatures pydantic-modellable, whichmockable's input-schema extraction wants.) - Decorate both with
@mockable()alongside the existing@traced(), and give each eval case a"mockingStrategy": {"type": "mockito", "behaviors": [{"function": "escalate_to_human", "then": [{"type": "return", "value": 12345}]}, {"function": "draft_auto_reply", "then": [{"type": "return", "value": "..."}]}]}.
Together those make the whole 11-case set runnable offline with no credentials and no tenant side effects, and free you to add a third evaluator asserting auto_reply / action_task_id. Whatever you choose, the README should be extended: lines 136-142 already state that the eval set requires valid credentials because Tier 2 calls real services, but they do not say that 3 of the 11 cases create real Action Center tasks that nobody cleans up.
7. [Axis 7] An empty or missing LLM completion is silently turned into an empty auto-reply and returned as a resolved ticket (packages/uipath/samples/ticket-triage-agent/main.py:201) — ```python
201: return result.choices[0].message.content or ""
`ChatMessage.content` is explicitly optional in the SDK — `packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py:102: content: Optional[str] = None` (the comment at lines 98-100 records that some models return non-standard choices) — and `ChatCompletion.choices` (llm_gateway.py:128) is a plain `List`, so an empty list validates fine. The `or ""` converts a refused, filtered or content-less completion into an empty string, which main.py:215 then returns as `TicketOutput(triage=triage, escalated=False, auto_reply="")`: the run reports the ticket auto-resolved and a downstream consumer sends the customer an empty reply. Nothing in the output distinguishes it from a successful draft. The `.choices[0]` on the same line is the opposite failure: an empty `choices` list raises a bare `IndexError` with no context.
The repo does not handle this loosely anywhere else. Its own code raises: `packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py:374-380` (`if not response.choices or len(response.choices) == 0: raise UiPathEvaluationError(... "No choices in LLM response" ...)`) and `packages/uipath/src/uipath/eval/mocks/_structured_output.py:116-118` (`if not choices: raise ValueError("LLM response contained no choices")`). The sibling sample uses a visible sentinel: `packages/uipath/samples/llm_chat_agent/agent.py:408: response = result.choices[0].message.content or "No response generated"`.
Fix: guard both shapes and fail loud rather than resolve the ticket with nothing — e.g.
```python
if not result.choices or not (content := result.choices[0].message.content):
raise RuntimeError("LLM Gateway returned no reply text; not auto-resolving this ticket")
return content
or, better for a triage sample, fall through to escalate_to_human so a ticket the LLM would not answer reaches a person.
8. [Axis 8] Following pyproject.toml's own "uncomment this line" instruction produces invalid TOML — the commented dependency sits after the dependencies array's closing bracket (packages/uipath/samples/ticket-triage-agent/pyproject.toml:14) — Move the commented entry inside the dependencies array so that uncommenting it produces valid TOML, and drop the stray comma-outside-list form:
dependencies = [
"uipath",
# Uncomment once you have independently vetted TypeSafe AI's real SDK,
# then delete jev_client.py and swap the import in main.py:22.
# "typesafe-sdk",
](Verified: comments inside a TOML array are legal, and uncommenting the entry in this position parses to dependencies = ['uipath', 'typesafe-sdk'].)
Then make the three copies of this swap instruction agree — pyproject.toml lines 10-14, jev_client.py lines 16-20, and README.md lines 40-44 (see the related finding on jev_client.py:16-20).
9. [Axis 8] uipath.json ships a hardcoded project/agent id, so every copy of this sample publishes under the same projectId (packages/uipath/samples/ticket-triage-agent/uipath.json:5) — uipath.json line 5 is "id": "a68ad7da-71fa-4470-bbea-92b6204ad519". That field is documented in packages/uipath/src/uipath/_cli/models/uipath_json_schema.py:79-84 as "Stable unique identifier for the agent. Minted once at project creation (by 'uipath init' or Studio Web) and preserved for the lifetime of the project. Used as the package 'projectId' at pack time." Two consuming sites make the shipped value sticky and authoritative:
- packages/uipath/src/uipath/_cli/cli_init.py:444 —
if not raw_config.get("id"):— README Step 4 (uv run uipath init) will NOT mint a per-user id, because one is already present. - packages/uipath/src/uipath/_cli/cli_pack.py:81-82 and :90 —
project_id = (config.id or resolve_existing_project_id(directory) or str(uuid.uuid4()))then"projectId": project_id—config.idwins over the reader's ownUIPATH_PROJECT_ID/.uipath/.telemetry.json.
So a reader following README Steps 4 and the Publish section (uipath pack,uipath publish) ships a package whose projectId is this fixed UUID, silently overriding their real project identity and colliding with every other reader of this sample. 21 of the 23 samples under packages/uipath/samples/ ship noidat all (only simulate-component-agent does, with a visibly fakea1b2c3d4-e5f6-7890-abcd-ef1234567890), so the house convention is to letuipath initmint it. Fix: delete theidkey from uipath.json and let Step 4 mint it. Cross-axis: also a convention divergence (axis 6).
Non-blocking, but please consider before merge
- [Axis 1] The mock ignores the semantics of the questions it is handed: every Noul returns the urgency score and any Choice option outside the hardcoded three-department table silently scores 0.0 instead of erroring (
packages/uipath/samples/ticket-triage-agent/jev_client.py:175) — Two related holes in the mock's contract. (1)def _answer_noul(self, text: str) -> NoulAnswer:(jev_client.py:175) does not even take thequestionparameter that_answer_choiceand_answer_scoretake, and hardcodesurgency_keywords.Noul.instructionsandNoul.criteria(declared at jev_client.py:41-45) are never read. Executed: asking three different Nouls in one call —Noul(instructions="Does this convey urgency?"),Noul(instructions="Is the customer asking for a refund?"),Noul(instructions="Is this spam?")— over "The system is down and I need a refund." returns{'is_urgent': 0.3, 'is_refund_request': 0.3, 'is_spam': 0.3}: three different questions, one answer. (2)_answer_choicekeys off the OPTION NAME, not the criteria text: jev_client.py:158 isoption: _keyword_score(text, department_keywords.get(option, {})), so an unrecognised option scores 0.0 and only receives the tie-break salt. Executed with a 4th option added —criteria={'billing':..., 'technical':..., 'sales':..., 'legal': 'Contracts, DPA, GDPR, privacy'}— the ticket("Legal review of our contract", "Our legal team needs to review the DPA before we can be charged for the next term.")routes tobillingatconf=0.9791whilelegalgets0.0095; the correct class is unselectable and the wrong one is reported at 98% confidence with no escalation. This matters because README.md:41-44 sells the module as a drop-in:the public shape used here (TypeSafeClient, Choice/Noul/Score, .system_one(...)) is designed to match the real SDK, so no other code should need to change— a reader who changes only thecriteriaintriage_ticket(main.py:90-102) gets silently wrong answers. Fix: fail loudly rather than fabricate — raise aValueErrorfrom_answer_choicewhen an option is absent fromdepartment_keywords, give_answer_noulthequestionargument and key its table off the question (raising on an unrecognised one), and state in the module docstring that the stub answers only the three questionsmain.pyasks. - [Axis 3] A 0-1 continuous "Noul" score is named and documented as a boolean (
is_urgent, "boolean" in three README places), and TriageDecision's numeric fields encode no range or scale (packages/uipath/samples/ticket-triage-agent/main.py:66) — ```python
class TriageDecision(BaseModel):
department: str
department_confidence: float # main.py:65 0..1
is_urgent: float # main.py:66 0..1 probability, despite the boolean name
frustration_score: float # main.py:67 0..(len(criteria)-1)
Two problems with this contract, neither of them expressible in a bare `float`:
1. `is_urgent: float` is a boolean-sounding name on a 0..1 probability, and `TicketOutput` is the agent's published output schema (it is what `uipath run` prints and what `uipath eval` resolves `triage.department` out of). A consumer reading `"is_urgent": 0.3` has to guess — especially next to the genuinely boolean `escalated`. The file already calls this quantity `urgency` elsewhere: the QuickForm field is `{"id": "urgency", "label": "Urgency score"}` (main.py:139-144), populated from `triage.is_urgent` (main.py:172). Rename the model field to `urgency` (or `urgency_probability`) and constrain it: `Field(ge=0.0, le=1.0)`; same for `department_confidence`. The rename is safe — neither evaluator targets this key (they target `triage.department` and `escalated`), and the mock's question key `"is_urgent"` (main.py:98, :106) is independent of the pydantic field name.
2. `FRUSTRATION_THRESHOLD = 1.0 # out of 2 ("Calm" / "Frustrated" / "Very angry")` (main.py:31) hardcodes a scale that is actually computed in the other file from the length of the label list passed at main.py:101: `max_level = len(question.criteria) - 1` / `score = round(intensity * max_level, 4)` (jev_client.py:200-201). Edit the label list in main.py and the threshold silently changes meaning. Running the shipped mock on the text "I am frustrated": with `["Calm","Frustrated","Very angry"]` it scores 1.0 (exactly at the threshold), with `["Calm","Annoyed","Frustrated","Very angry"]` the same text scores 1.5 — the same input, a strictly more granular scale, and the unchanged 1.0 threshold now sits at 33% of the scale instead of 50%, so escalation fires further from "very angry".
Fix it on the main.py side, not in the stub: `jev_client.py` deliberately mirrors the real `typesafe-sdk` public shape so swapping in the real package is a one-line change (jev_client.py:16-20, README), so do **not** add a `max_level` field to `ScoreAnswer`. It is unnecessary anyway — `ScoreAnswer.probabilities` already carries one key per level (jev_client.py:204), so `max_level = len(frustration.probabilities) - 1` recovers the scale from the shipped shape. Express the threshold as a fraction of the scale (e.g. `FRUSTRATION_THRESHOLD_FRACTION = 0.5` compared against `frustration_score / max_level`), and add `Field(ge=0.0)` plus a docstring stating the range on `frustration_score`.
3. **[Axis 6] Sample vendors a 213-line replica of an unvetted third-party SDK's API shape — half its Python — where sibling classification_agent does the same keyword classification inline in 53 lines** (`packages/uipath/samples/ticket-triage-agent/jev_client.py:1`) — Fold the Tier-1 scorer into main.py as a single traced tool function and delete jev_client.py, following the sibling this sample's own README already cites: packages/uipath/samples/classification_agent/main.py:30-46 does keyword-based text classification in a 53-line file with `@traced(name=..., span_type="tool")` and no vendored client. Keep the three keyword tables and the confidence math (jev_client.py:88-92 `_keyword_score`, :132-173 `_answer_choice`, :175-186 `_answer_noul`, :188-213 `_answer_score` — about 85 lines) so the shipped eval set at evaluations/eval-sets/default.json still discriminates between its 11 tickets. Drop the ~56 lines of mirrored vendor dataclasses (jev_client.py:30-85: Choice/Noul/Score/ChoiceAnswer/NoulAnswer/ScoreAnswer/SystemOneResponse), the `system_one` question-dispatch loop (:107-130), and the fabricated `model`/`usage` envelope (:83, :85, :125-129) that main.py never reads. Realistic reduction is ~120-145 lines, not ~170; reaching ~170 would also require discarding the probability distributions and the sha256 tie-break salt, which exist only to satisfy the mirrored vendor shape. Optionally add `@mockable()` beneath `@traced`, as simulate-component-agent/main.py:59-60 does, so the tier can be intercepted with `--simulation` like the other samples — `@mockable()` with no `example_calls` still runs the real body outside a simulated run, so it does not disturb `uipath eval`. Keep the "how to plug in a real provider" note in README.md, which does not require shipping a replica of the provider's API; while editing it, fix the jev_client.py:18-19 claim that the swap is "a one-line change ... Nothing else in `main.py` needs to change," which the README itself contradicts by also requiring a pyproject.toml entry and deleting the file.
4. **[Axis 8] README claims the sample "runs fully offline" and needs "No API key", contradicting its own Prerequisites and both code branches** (`packages/uipath/samples/ticket-triage-agent/README.md:39`) — Scope both sentences in the "About the Jev model" section to the mock rather than the whole sample.
- README.md line 38-39, replace "The mock scores keyword signals in the ticket text instead of calling any external model, so this sample runs fully offline." with something like: "The mock scores keyword signals in the ticket text instead of calling any external model, so **Tier 1 runs fully offline** - the Tier 2 branches still call UiPath services (see Prerequisites)."
- README.md line 46, replace "**No API key is needed to run this sample.**" with "**No TypeSafe API key is needed to run this sample.**" (the rest of the paragraph at lines 47-49 already scopes the explanation correctly, and `.env.example` line 8 already uses this narrower framing).
Both edits bring the section in line with what the same README already states at lines 51-56 (Prerequisites: Orchestrator tenant with LLM Gateway and Action Center), 73-76 (Step 3: configure credentials), 110-115 (How it works, items 3 and 4) and, most directly, 136-138 ("Since `draft_auto_reply` and `escalate_to_human` call real UiPath services ... requires valid credentials in `.env`").
5. **[Axis 8] The sample's third-party `typesafe-sdk` references are inaccurate or unverifiable — the httpx2 supply-chain rationale is backwards, the package postdates nothing, and jev_client.py cites an implausible Cloudflare docs URL** (`packages/uipath/samples/ticket-triage-agent/README.md:24`) — Delete the dated/insinuating assertions about `typesafe-sdk` from README.md lines 24-26 and from the `jev_client.py` module docstring (lines 5-7), keeping only the actionable part: "`jev_client.py` is a local, offline mock; if you want a real System One provider, vet it independently first and swap the import in main.py:22." Specifically drop: (a) "was published the same day this sample was written" — PyPI shows `typesafe-sdk`'s first release `0.0.1a0` on 2026-09-09, twelve days before the sample commit 6e3882ae (2026-09-21), so the claim is already inaccurate (only the 0.7.1 release happened to land on 2026-09-21); (b) "lists an unusual dependency (`httpx2` instead of `httpx`)" — `httpx2` 2.13.0 is the pydantic org's next-generation httpx (github.com/pydantic/httpx2, "The next generation HTTP client", 17 releases since 2026-05-11), so citing it as a red flag is a misleading supply-chain insinuation about a named third party; and (c) "already has several releases" — accurate today (5) but a point-in-time count that goes stale, with no process in this repo to re-check it. If the provenance reasoning matters to reviewers, put it in the PR description where it does not ship. Note that the "unverified" (line 27) vs "documented public shape" (line 29) wording is NOT a contradiction — reading a package's published docs is not the same as vetting it, and lines 43-44 are hedged ("should", "designed to match") rather than a guarantee — so no change is needed on that account.
6. **[Axis 8] jev_client.py's module-docstring swap instruction points at an import the file does not contain and is the third divergent copy of the same procedure** (`packages/uipath/samples/ticket-triage-agent/jev_client.py:16`) — State the swap procedure once — the README is the natural home, since it already carries the vetting caveats — and have jev_client.py's docstring and the pyproject.toml comment point at it instead of restating it. The canonical procedure has three steps, and each of the three current copies omits at least one: (1) delete `jev_client.py`; (2) replace `from jev_client import Choice, Noul, Score, TypeSafeClient` at `main.py:22` with `from typesafe_sdk import Choice, Noul, Score, TypeSafeClient`; (3) add `"typesafe-sdk"` to the `dependencies` array in `pyproject.toml`. Note step 3 is not a plain uncomment as the file stands: `# "typesafe-sdk",` sits at pyproject.toml:14, outside the `dependencies = [...]` array that closes at line 8, so the entry has to be moved inside the array (see the separate pyproject finding). At minimum, fix jev_client.py:17-18 so it no longer says "replace the import below" — there is no such import in that file (the imports below are `hashlib`, `dataclasses.dataclass`, `typing.Any` at lines 25-27); the import to replace is at main.py:22.
## Nits
- **[Axis 2]** 34-line static QuickForm schema literal is rebuilt inside escalate_to_human on every call (`packages/uipath/samples/ticket-triage-agent/main.py:130`)
- **[Axis 2]** Static-analysis violations that CI structurally cannot catch because samples/** is excluded from both ruff and mypy (3 ruff findings; `max(..., key=probabilities.get)` keyed on `float | None`) (`packages/uipath/samples/ticket-triage-agent/jev_client.py:104`)
- **[Axis 4]** `defaultEvaluationCriteria` would silently grade a criteria-less case against `technical` / `false` instead of erroring (`packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json:13`)
- **[Axis 5]** README Step 3's credential setup is incomplete: it omits `uipath auth` in favor of hand-pasting a long-lived bearer token and omits the UIPATH_FOLDER_PATH the escalation branch needs (`packages/uipath/samples/ticket-triage-agent/README.md:75`)
- **[Axis 6]** jev_client.py models use dataclasses while main.py uses pydantic in the same sample, against CLAUDE.md's stated rule (`packages/uipath/samples/ticket-triage-agent/jev_client.py:30`)
- **[Axis 7]** _answer_score returns a confident nonsense answer for a degenerate criteria list instead of rejecting it, unlike the explicit TypeError guard 60 lines above (`packages/uipath/samples/ticket-triage-agent/jev_client.py:200`)
- **[Axis 8]** README says the eval set covers "11 tickets"; it is 11 cases over 9 distinct tickets (`packages/uipath/samples/ticket-triage-agent/README.md:120`)
## What's Missing
**Parallel paths & mirrors:**
- 🔵 `.env.example` and the README are the two files that jointly define setup, and only one was kept complete. `.env.example:4-6` declares `UIPATH_FOLDER_PATH=Shared` with the comment "Required for the escalation path: Action Center tasks must be created in an Orchestrator folder" — and `main.py:176` does read it (`folder_path=os.environ.get("UIPATH_FOLDER_PATH")`, added by the branch's most recent commit 66f553b9). But README Step 3 (`README.md:73-80`), the only credential step, says only "fill in your Orchestrator URL and access token", and Prerequisites (`README.md:51-56`) lists LLM Gateway and Action Center access but no folder. A reader who follows the README prose rather than reading the `.env.example` comments gets `folder_path=None` on the escalation path. Fold the folder variable (and `uipath auth`, per Axis 5) into Step 3 so the two files agree. _(trigger: packages/uipath/samples/ticket-triage-agent/.env.example)_ _(restates: Axis 5: README Step 3's credential setup is incomplete)_
- 🟡 The "swap in the real `typesafe-sdk`" procedure is written out three times, in three files, and no copy was updated to match the others — each omits a different one of its three steps. `README.md:38-44` says swap the import in `main.py` and add it to `pyproject.toml`, but never says to delete `jev_client.py`. `pyproject.toml:10-14` says uncomment the dependency and delete `jev_client.py`, but never mentions the `main.py` import (and the entry it tells you to uncomment sits outside the `dependencies` array, so following it produces invalid TOML). `jev_client.py:16-20` says replace "the import below" and delete this file, but never mentions `pyproject.toml` — and there is no such import below it (`jev_client.py:23-27` are `__future__`, `hashlib`, `dataclass`, `Any`; the import to change is `main.py:22`). Any one of the three, followed literally, leaves the sample broken. State the procedure once, in the README, and have the other two point at it. _(trigger: packages/uipath/samples/ticket-triage-agent/pyproject.toml)_ _(restates: Axis 8: jev_client.py's module-docstring swap instruction points at an import the file does not contain)_
**Tests:**
- 🟡 The part of this PR that is pure, offline and deterministic — `_keyword_score`, `_answer_choice`, `_answer_noul`, `_answer_score` (`jev_client.py:88-213`) and `needs_escalation` (`main.py:116-122`) — has no automated test anywhere in the repo. No `tests/` directory ships with the sample, and `packages/uipath/pyproject.toml:146` sets `testpaths = ["tests"]`, so pytest never sees `samples/`. The repo already has the precedent for closing this without a live tenant: `packages/uipath/tests/resource_overrides/test_resource_overrides.py:18-28` loads `samples/resource-overrides/main.py` from inside the collected test tree and drives it through the CLI. A handful of table-driven asserts over the scorer (one per keyword table, one per threshold boundary) would run in CI for free and would have caught both High correctness findings — the unreachable `URGENCY_THRESHOLD` (Axis 1) and the sha256 salt leaking into `department_confidence` (Axis 2). Kept Medium rather than High only because no sibling sample ships unit tests either, so this is a convention to start, not one that was broken. _(trigger: packages/uipath/samples/ticket-triage-agent/jev_client.py)_
- 🔵 No `packages/uipath/testcases/<name>-evals/` entry accompanies the new 11-case eval set, so nothing in CI ever executes `uipath eval` against it. The repo's mechanism for that is `.github/workflows/integration_tests.yml`, whose `discover-testcases` step globs `packages/<pkg>/testcases/*-*` and runs each one's `run.sh` against three live tenants; five samples are wired in this way (calculator via `calculator-evals` and `calculator-crash-evals`, csv-processor, weather_tools via `tools-evals`, list_target_output_key_test, multi-output-agent), each `run.sh` being three lines that point at `../../samples/<name>/evaluations/eval-sets/*.json`. Five of the ten pre-existing eval-bearing samples are likewise unwired, so this is a weakly held convention — hence Low. Note it cannot be wired as the sample currently stands: per Axis 4, every case constructs `UiPath()` and three of eleven create real Action Center tasks, so a naive testcase would leave 9 uncleaned tasks per PR across alpha/cloud/staging. The offline-mocking fix in that finding is a prerequisite. _(trigger: packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json)_
**Downstream consumers:**
- 🟡 A second hardcoded shared identifier ships in this sample, with a different consumer than the `uipath.json` one. `TRIAGE_TASK_SCHEMA_KEY = "5b6f7e2a-3c9d-4e11-9a2b-6d1f0c9a2e77"` (`main.py:35`) is passed as `task_schema_key` alongside the inline 34-line `schema` on every `create_quickform` call (`main.py:164-177`), and the platform layer's own docstrings state the consequence twice — `_tasks_service.py:286` and `:713`: "Orchestrator upserts the schema (keyed by taskSchemaKey) and then creates the task in the same call." The consumer is therefore a tenant-level TaskSchemas row, shared by every copy of this sample running in that tenant: two readers who each adapt the `fields` list silently overwrite each other's QuickForm definition, and the losing agent's reviewers see the wrong form. Nothing in `main.py` or the README tells a reader to mint their own key. Same class of fix as the `uipath.json` id — either generate it per project or document loudly that it must be changed before adapting the sample. _(trigger: packages/uipath/samples/ticket-triage-agent/main.py)_ _(restates: Axis 8: uipath.json ships a hardcoded project/agent `id`)_
**Docs & config:**
- 🔵 The README's Evaluations section (`README.md:117-142`) documents `uipath eval` and correctly warns that it "requires valid credentials in `.env`", but stops one step short of the consequence: it never says that running it writes to the reader's tenant. Replaying the deterministic stub over the 11 inputs, three cases escalate and each run therefore creates three real Action Center QuickForm tasks via `main.py:164` that nothing cleans up, plus eight billed LLM Gateway completions from `main.py:189` — none of which any evaluator grades, since the two evaluator configs target only `triage.department` and `escalated`. Either add the one-sentence warning, or take the mocking fix from Axis 4 and remove the side effect. _(trigger: packages/uipath/samples/ticket-triage-agent/README.md)_ _(restates: Axis 4: Tier 2 runs on every eval but is graded by nothing)_
**Rollout impact:**
- 🔵 The PR states no CI blast radius, and the one it has is inverted: it spends the full `uipath` pipeline while adding zero coverage of itself. `.github/scripts/detect_changed_packages.py:61-69` marks a package changed on ANY path under `packages/<pkg>/`, with no `samples/` exemption, so these 11 sample files mark `uipath` changed and fan out `lint-uipath`, `test-uipath` (3 Python versions x 2 OSes), SonarCloud, and `integration_tests.yml`, whose matrix is the 15 hyphenated dirs under `packages/uipath/testcases/` crossed with `[alpha, cloud, staging]` — 45 jobs against live tenants. Not one of them touches the new code: ruff excludes `samples/**` and mypy `samples/.*` (`packages/uipath/pyproject.toml:96,128`), pytest collects only `tests`, the hatch wheel packages only `src/uipath` (`:89-91`), and no testcase references this sample. Two contrasting paths I checked and confirmed are NOT affected, so no action is needed there: `.github/workflows/cd.yml:11` explicitly excludes `packages/*/samples/**/pyproject.toml` from release triggers, and `.github/scripts/check_version_uniqueness.py:74` only counts `packages/<pkg>/src/**`, so no version bump is forced. _(trigger: packages/uipath/samples/ticket-triage-agent/main.py)_
## Guardrails & Automation
**Static checks:**
- **Stop excluding `samples/**` from ruff; downgrade instead of disabling.** In `/Users/religa/src/uipath-python/packages/uipath/pyproject.toml`, change `[tool.ruff] exclude = ["samples/**", "testcases/**"]` to `exclude = ["testcases/**"]`, and add to the existing `[tool.ruff.lint.per-file-ignores]` … _Prevents:_ The 2 confirmed ruff violations (`D107` jev_client.py:104, `D102` jev_client.py:107 — the mock's only public method, which CLAUDE.md's …
- **Type-check samples.** In `packages/uipath/pyproject.toml`, drop `"samples/.*"` from `[tool.mypy] exclude`. CI already runs `uv run mypy --config-file pyproject.toml .` from `packages/uipath` (`lint-packages.yml`, step *Check static types*), and the `.` argument overrides `files = ["src", … _Prevents:_ `max(probabilities, key=probabilities.get)` at jev_client.py:170 — the one defect in this PR that a typechecker catches outright and that, …
- **Make the sample's own contracts machine-checkable instead of documentary.** In `packages/uipath/samples/ticket-triage-agent/main.py:64-67`, replace the four bare fields with types that encode their domain, so that once the mypy exclude is lifted the compiler (and pydantic at runtime) enforces … _Prevents:_ Axis-3 medium (boolean-named continuous score; numeric fields encoding no range or scale) directly, and the axis-1 medium *unrecognised …
- **New rule: no committed project `id` in a sample or testcase `uipath.json`.** Add it to a new `packages/uipath/scripts/lint_sample_manifests.py`, built on the repo's one existing custom-linter precedent, `packages/uipath/scripts/lint_httpx_client.py` — same shape: paths from `argv` with a sensible … _Prevents:_ The high-severity `uipath.json:5` finding (every copy of the sample publishes under projectId `a68ad7da-…`, overriding the reader's real …
- **New rule `UP002` in the same `lint_sample_manifests.py`: a commented dependency entry must still parse when uncommented.** For each `packages/uipath/{samples,testcases}/*/pyproject.toml`, for every line matching `^(\s*)#\s*("[^"]+",?)\s*$`, strip the comment marker and re-parse the document with … _Prevents:_ The high-severity `pyproject.toml:14` finding — following the file's own "uncomment this line" instruction produces invalid TOML, breaking …
**Guardrail improvements:**
- **An offline pytest regression test that executes the sample's Tier-1 scoring over its own eval set.** Put it at `packages/uipath/tests/samples/test_ticket_triage.py`; the precedent for a test reaching into the sample tree already exists at … _Prevents:_ `URGENCY_THRESHOLD = 0.6` exceeding every urgency keyword weight (high); unanchored substring matching turning "breakdown" into "down" …
- **Mutation and boundary assertions in that same test file — grade the grader.** Three concrete assertions, each of which reproduces a check the reviewers ran by hand and CI can then run forever:
1. *Clause mutation*: for each of the three clauses in `needs_escalation` (main.py:119-121), … _Prevents:_ "The eval set's assertions are insensitive to the sample's scoring logic" (high) — the finding that deleting the whole urgency clause, or …
- **A perturbation/determinism guard.** For each eval ticket, re-score it under a small set of semantically-null rewordings (leading-capital flip, trailing whitespace, terminal punctuation, a filler word) and assert `(department, escalated)` is invariant. Today `ambiguous-low-confidence` fails this: … _Prevents:_ `ambiguous-low-confidence` asserting a sha256 digest rather than triage behavior (high), and the underlying salt-in-the-normalizer defect …
- **Make the sample's eval set runnable offline, then let CI run it.** Two changes, both required — decorating alone is not enough, because `client = UiPath()` sits unconditionally in `main()` at main.py:208, outside both functions being mocked, and would still raise `BaseUrlMissingError` on all 11 … _Prevents:_ "Tier 2 runs on every eval but is graded by nothing" (high) — including the 3 real, uncleaned Action Center QuickForm tasks and 8 paid LLM …
- **A CI job that executes each changed sample's documented setup sequence.** Model it on the mechanism the repo already uses for its other lint/type-excluded directory: `.github/workflows/integration_tests.yml`'s `discover-testcases` step enumerates `packages/$pkg/testcases/*/` into a matrix and … _Prevents:_ The unverified README setup sequence generally; it would independently have caught the `pyproject.toml:14` TOML break at `uv sync`, and …
- **A test that feeds `draft_auto_reply` the degenerate LLM responses the SDK's own types permit.** Stub `client.llm.chat_completions` to return (a) `choices=[]` and (b) a choice whose `message.content is None` — both valid per `packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py:102` … _Prevents:_ "An empty or missing LLM completion is silently turned into an empty auto-reply and returned as a resolved ticket" (high, main.py:201).
- **Convert each README behavioral promise into an executable case, and record the rest as a reviewer checklist.** The README's escalation example (lines 98-100: "urgent", "ASAP", "furious" → Action Center task) is false for two of the three words it names; make those three words three eval cases so … _Prevents:_ The README urgency example (high, grouped with main.py:30), "runs fully offline" / "No API key" (medium), the "11 tickets" count (low), and …
## Top 5 Priority Actions
1. Delete the hardcoded `"id": "a68ad7da-71fa-4470-bbea-92b6204ad519"` at packages/uipath/samples/ticket-triage-agent/uipath.json:5 and let README Step 4's `uipath init` mint one, because `uipath pack` prefers `config.id` over the reader's own `UIPATH_PROJECT_ID` (packages/uipath/src/uipath/_cli/cli_pack.py:81-90) and `uipath init` will not backfill when an id already exists (cli_init.py:444), so every copy of this sample publishes a .nupkg whose `projectId` silently overrides the user's real project identity and collides with every other reader.
2. Stop the ungraded Tier 2 from writing to a live tenant on every eval run: `client = UiPath()` sits unconditionally in `main()` at packages/uipath/samples/ticket-triage-agent/main.py:208, so all 11 cases require credentials that the two evaluators (which grade only `triage.department` and `escalated`) never need, and 3 of 11 create real, uncleaned Action Center QuickForm tasks plus 8 paid LLM completions per run — move the client construction into `escalate_to_human`/`draft_auto_reply`, decorate both with `@mockable()` and add a `mockingStrategy` to each eval case, or at minimum document the uncleaned tasks at README.md:136-142.
3. Remove the sha256 tie-break salt at packages/uipath/samples/ticket-triage-agent/jev_client.py:161-166 (and the then-dead `import hashlib` at :25), because it is normalized into `department_confidence` and therefore fabricates the number `needs_escalation` keys on — exhaustively, 70.26% of zero-signal tickets clear the 0.45 gate on hash residue alone, and the `ambiguous-low-confidence` eval case passes only by digest (changing "Quick" to "quick" flips it to no-escalation and fails both evaluators), whereas without the salt confidence is 0.0 and the case's asserted `billing`/`true` pair holds deterministically.
4. Fix the two Tier-1 scoring defects in one pass: lower `URGENCY_THRESHOLD` at main.py:30 from 0.6 to 0.5 so the single-keyword "urgent"/"ASAP" escalation promised at README.md:98-100 can actually fire (no urgency weight exceeds 0.5, and the change perturbs none of the 11 eval outcomes), and replace the bare substring test at jev_client.py:91 with a bounded word-boundary match (`re.search(rf"\b{re.escape(keyword)}\w{{0,3}}\b", lowered)`) so "breakdown" stops scoring as "down" and "planning" as "plan" — note the naive `\b…\b` form regresses `billing-angry-customer` by dropping "charged".
5. Repair the sample's own instructions, which currently break or mislead anyone who follows them: move the commented `# "typesafe-sdk",` at pyproject.toml:14 inside the `dependencies` array that closes at line 8 (uncommenting it as instructed raises `TOMLDecodeError` and breaks `uv sync`), state the three-step swap procedure once instead of in three divergent copies (pyproject.toml:10-14, jev_client.py:16-20 which points at an import that file does not contain, README.md:40-44), scope README.md:39/46's "runs fully offline"/"No API key" to Tier 1 — the same README contradicts them at 136-138 — and drop the inaccurate `typesafe-sdk`/`httpx2` supply-chain assertions at README.md:24-26.
---
**Change class:** complex — introduces a new decision algorithm (keyword-scored mock classifier + threshold-based escalation branching) plus a public-facing sample contract and an eval set whose ground truth encodes that algorithm's behavior
<!-- machine-parseable; see "Change Classification" in the rubric. -->
**Stats:** 0 🔴 · 9 🟠 · 6 🟡 · 7 🔵 across 8 axes reviewed.
**Verification:** 38 medium+ finding(s) adversarially re-checked · 11 dropped as false positives (29%) · 26 corrected in place · 17 low passed through unverified.
<sub>⚠️ This comment was abridged to fit the 65,536-character comment limit: nits and guardrail entries are condensed to one line each. The unabridged per-axis reports were generated alongside it — ask the reviewer for the full report if you want the detail behind any item.</sub>
…t loop main is async but was calling the synchronous, network-bound triage_ticket and escalate_to_human directly, blocking the event loop during those calls. Both now run via asyncio.to_thread. Also reclassifies uipath.json's entry point from "functions" to "agents", matching this sample's LLM/HITL nature rather than a deterministic function. Addresses Copilot PR review feedback; verified end-to-end that the Jev call and Action Center escalation still complete correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fail loud instead of silently auto-resolving a ticket with an empty reply: draft_auto_reply now raises when the LLM Gateway returns no choices or empty content, instead of returning "". - Remove uipath.json's hardcoded project id: 21 of 23 samples ship none, and a committed id makes cli_pack.py use it as every reader's projectId (collision across every copy of this sample). uipath init mints a per-user id on step 4 instead. Keeps the "agents" entry-point key. - Rename TriageDecision.is_urgent -> urgency (the field held a 0-1 probability, not a boolean; the Jev question key "is_urgent" is unaffected) and derive the frustration scale from the response instead of hardcoding "out of 2", so escalation logic stays correct if the Score criteria list is ever edited. Added Field bounds. - Hoist the QuickForm task schema to a module-level constant instead of rebuilding it on every escalate_to_human call. - Correct the README's due-diligence claims about typesafe-sdk: PyPI shows its first release on 2026-09-09, not "the same day this sample was written", and httpx2 is an unrelated, independent package (the pydantic org's next-gen httpx), not a red flag specific to TypeSafe AI. - Document that every uipath eval case now needs TYPESAFE_API_KEY too, and that escalating cases create real, uncleaned Action Center tasks. Verified locally: the real Jev call and triage still succeed end-to-end with the renamed/validated fields (blocked further downstream only by an expired UIPATH_ACCESS_TOKEN, unrelated to these changes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|





Summary
packages/uipath/samples/ticket-triage-agent, a coded agent sample demonstrating a two-tier "System 1 / System 2" pattern: TypeSafe AI's realtypesafe-sdkclient and its "Jev" System One model triages a support ticket (department/urgency/frustration), then either escalates to a human via an Action Center QuickForm task or drafts an auto-reply via the UiPath LLM Gateway.evaluations/) covering routing and escalation decisions across all three departments, withuipath-multiclass-classificationanduipath-binary-classificationevaluators.typesafe-sdkas a real dependency (a very recently published package, several releases in one day), statically inspected the wheel's source with no install/execution: an apparently auto-generated OpenAPI client, noeval/exec/subprocess, no env-var exfiltration, single documented API host. Pinned to>=0.7.0rather than left unbounded.escalate_to_humanpassesfolder_pathtocreate_quickform(Action Center requires an Orchestrator folder), fails fast with a clear error whenUIPATH_FOLDER_PATHis unset or when Action Center returns no task id.main's synchronous, network-bound calls (triage_ticket,escalate_to_human) now run viaasyncio.to_threadso they don't block the event loop.Test plan
ruff check/ruff format --checkpass on the new filesuipath run main --input-fileagainst a real tenant and the real Jev API for both the escalation path (creates a real Action Center task, HTTP 201, Jev responds in ~150-250ms) and the auto-reply path (Jev call succeeds; blocked further downstream only by tenant LLM licensing/token expiry, not code)uipath evalagainst a real tenant; escalation-path evaluations scored correctly (1.0); auto-reply-path evaluations blocked by tenant license, not a code issueescalate_to_humanraises a clear error whenUIPATH_FOLDER_PATHis unset, and when Action Center returns no task id