Skip to content

sample: add ticket-triage-agent (System 1/System 2 pattern with Jev) - #1907

Merged
akshaylive merged 5 commits into
mainfrom
akshaya/system_one_model_sample
Sep 21, 2026
Merged

akshaylive merged 5 commits into
mainfrom
akshaya/system_one_model_sample

Conversation

@akshaylive

@akshaylive akshaylive commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds packages/uipath/samples/ticket-triage-agent, a coded agent sample demonstrating a two-tier "System 1 / System 2" pattern: TypeSafe AI's real typesafe-sdk client 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.
  • Includes an eval set (evaluations/) covering routing and escalation decisions across all three departments, with uipath-multiclass-classification and uipath-binary-classification evaluators.
  • Before adding typesafe-sdk as 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, no eval/exec/subprocess, no env-var exfiltration, single documented API host. Pinned to >=0.7.0 rather than left unbounded.
  • escalate_to_human passes folder_path to create_quickform (Action Center requires an Orchestrator folder), fails fast with a clear error when UIPATH_FOLDER_PATH is unset or when Action Center returns no task id.
  • main's synchronous, network-bound calls (triage_ticket, escalate_to_human) now run via asyncio.to_thread so they don't block the event loop.

Test plan

  • ruff check / ruff format --check pass on the new files
  • Ran uipath run main --input-file against 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)
  • Ran uipath eval against a real tenant; escalation-path evaluations scored correctly (1.0); auto-reply-path evaluations blocked by tenant license, not a code issue
  • Verified escalate_to_human raises a clear error when UIPATH_FOLDER_PATH is unset, and when Action Center returns no task id

Copilot AI lite review requested due to automatic review settings September 21, 2026 19:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity · 1 Low severity

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.

Comment thread packages/uipath/samples/ticket-triage-agent/main.py
Comment thread packages/uipath/samples/ticket-triage-agent/README.md Outdated
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>
@akshaylive
akshaylive force-pushed the akshaya/system_one_model_sample branch from 66f553b to 0ae4594 Compare September 21, 2026 20:02
akshaylive and others added 2 commits September 21, 2026 13:13
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity · 1 Low severity

Open (2)
Resolved since last review (2)

Comment thread packages/uipath/samples/ticket-triage-agent/main.py Outdated
Comment thread packages/uipath/samples/ticket-triage-agent/README.md
@uipreliga
uipreliga self-requested a review September 21, 2026 20:31

@uipreliga uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. [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's urgency_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 is URGENCY_THRESHOLD = 0.6 and main.py:119 tests triage.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 set URGENCY_THRESHOLD = 0.5 (which makes any single urgency keyword escalate, matching the doc), or raise the single-word weights in urgency_keywords to 0.6+, or amend README.md:98-100 to say that escalation requires a compound urgency signal. Also note the shipped technical-outage-urgent eval 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.
  2. [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. Add import re and 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:

  1. Credentials are mandatory for grading that needs none. UiPath() raises BaseUrlMissingError / SecretMissingError when UIPATH_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 grade needs_escalation or _keyword_score offline.

  2. Persistent side effects. Replaying the deterministic jev_client stub over the eval inputs, 3 of the 11 cases escalate (technical-outage-urgent, billing-angry-customer, ambiguous-low-confidence), so each uipath eval run creates 3 real Action Center QuickForm tasks via client.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-79 likewise 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.

  1. Move the client construction out of main() and into the two Tier-2 functions — drop the client: UiPath parameter from escalate_to_human and draft_auto_reply, construct UiPath() inside each body, and delete line 208. (This also keeps the mocked signatures pydantic-modellable, which mockable's input-schema extraction wants.)
  2. 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_idconfig.id wins over the reader's own UIPATH_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 no id at all (only simulate-component-agent does, with a visibly fake a1b2c3d4-e5f6-7890-abcd-ef1234567890), so the house convention is to let uipath init mint it. Fix: delete the id key from uipath.json and let Step 4 mint it. Cross-axis: also a convention divergence (axis 6).

Non-blocking, but please consider before merge

  1. [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 the question parameter that _answer_choice and _answer_score take, and hardcodes urgency_keywords. Noul.instructions and Noul.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_choice keys off the OPTION NAME, not the criteria text: jev_client.py:158 is option: _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 to billing at conf=0.9791 while legal gets 0.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 the criteria in triage_ticket (main.py:90-102) gets silently wrong answers. Fix: fail loudly rather than fabricate — raise a ValueError from _answer_choice when an option is absent from department_keywords, give _answer_noul the question argument 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 questions main.py asks.
  2. [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>

@akshaylive akshaylive changed the title sample: add ticket-triage-agent (System 1/System 2 pattern with mocked Jev) sample: add ticket-triage-agent (System 1/System 2 pattern with Jev) Sep 21, 2026
akshaylive and others added 2 commits September 21, 2026 13:39
…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>
@akshaylive
akshaylive enabled auto-merge (squash) September 21, 2026 20:59
@sonarqubecloud

Copy link
Copy Markdown

@akshaylive
akshaylive merged commit 6aab98e into main Sep 21, 2026
97 of 98 checks passed
@akshaylive
akshaylive deleted the akshaya/system_one_model_sample branch September 21, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants