|
11 | 11 | clear ``AgentConfigError`` at ``start()`` (Node.js, ``npm install |
12 | 12 | @uipath/delegate-sdk``, UiPath auth). Deliberate scope reductions versus the |
13 | 13 | UiPath-internal sibling agent's more hardened adapter (no multi-generation |
14 | | -transcript splitting, no WAF/SSE/session-conflict/stall-resend recovery, |
15 | | -best-effort token-bucket field names marked ``# UNVERIFIED``) and every |
16 | | -``# UNVERIFIED`` spot's rationale live in one place, not scattered: |
| 14 | +transcript splitting, no WAF/SSE/session-conflict/stall-resend recovery) and |
| 15 | +every remaining ``# UNVERIFIED`` spot's rationale live in one place, not |
| 16 | +scattered: |
17 | 17 |
|
18 | 18 | Rationale: .claude/notes/agents.md § Delegate agent |
19 | 19 | """ |
@@ -206,39 +206,46 @@ def _resolve_bundled_skills_path(plugins: list[dict[str, Any]] | None) -> str | |
206 | 206 |
|
207 | 207 |
|
208 | 208 | def _parse_usage(raw: Any) -> TokenUsage | None: |
209 | | - """Best-effort parse of an event/result's ``usage`` payload into ``TokenUsage``. |
210 | | -
|
211 | | - UNVERIFIED: the SDK confirms an ``usage`` field exists on at least some |
212 | | - events, but not its internal bucket key names. Tries several plausible |
213 | | - spellings (snake_case, as the internal sibling agent's protocol used; and |
214 | | - camelCase, in case this layer differs) and falls back to 0 for anything it |
215 | | - cannot read, mirroring the project's "warn on drift, never raise" contract. |
| 209 | + """Parse the SDK's per-turn usage payload (from ``getLastTurnUsage()``) into ``TokenUsage``. |
| 210 | +
|
| 211 | + CONFIRMED (reading the installed ``@uipath/delegate-sdk@0.1.12``'s bundled |
| 212 | + ``dist/index.mjs``): no event this host forwards ever carries a ``usage`` |
| 213 | + field -- the SDK's per-turn token accounting lives only in its internal |
| 214 | + store, reachable through ``DelegateAgent.getLastTurnUsage()``, which |
| 215 | + ``delegate_host.mjs`` calls after ``sendMessage()`` resolves and attaches |
| 216 | + to the ``send_ok`` message as ``usage``. That getter's shape, from the |
| 217 | + SDK's own ``setUsage`` store action: ``{promptTokens, completionTokens, |
| 218 | + promptTokensCached, cacheCreationTokens, turnTokenUnits, |
| 219 | + contextBreakdown}``. ``promptTokens`` is the TOTAL input token count |
| 220 | + (cached + uncached, OpenAI-style); ``promptTokensCached`` is the |
| 221 | + cache-READ subset of it, so ``uncached = promptTokens - promptTokensCached``. |
| 222 | + Falls back to 0 for anything absent (e.g. before the backend's first |
| 223 | + internal usage report), mirroring the project's "warn on drift, never |
| 224 | + raise" contract -- a future SDK release renaming one of these fields |
| 225 | + degrades to zero tokens for that bucket, not a crash. |
216 | 226 | """ |
217 | 227 | if not isinstance(raw, dict): |
218 | 228 | return None |
219 | 229 |
|
220 | | - def _int(*keys: str) -> int: |
221 | | - for key in keys: |
222 | | - value = raw.get(key) |
223 | | - if isinstance(value, bool): |
224 | | - continue |
225 | | - if isinstance(value, int) and value >= 0: |
226 | | - return value |
227 | | - return 0 |
228 | | - |
229 | | - input_tokens = _int("input_tokens", "inputTokens", "uncached_input_tokens") |
230 | | - output_tokens = _int("output_tokens", "outputTokens") |
231 | | - cache_creation = _int("cache_creation_input_tokens", "cacheCreationInputTokens", "cache_write", "cacheWrite") |
232 | | - cache_read = _int("cache_read_input_tokens", "cacheReadInputTokens", "cache_read", "cacheRead") |
233 | | - if input_tokens == 0 and output_tokens == 0 and cache_creation == 0 and cache_read == 0: |
| 230 | + def _int(key: str) -> int: |
| 231 | + value = raw.get(key) |
| 232 | + if isinstance(value, bool): |
| 233 | + return 0 |
| 234 | + return value if isinstance(value, int) and value >= 0 else 0 |
| 235 | + |
| 236 | + prompt_total = _int("promptTokens") |
| 237 | + prompt_cached = _int("promptTokensCached") |
| 238 | + output_tokens = _int("completionTokens") |
| 239 | + cache_creation = _int("cacheCreationTokens") |
| 240 | + if prompt_total == 0 and output_tokens == 0 and prompt_cached == 0 and cache_creation == 0: |
234 | 241 | if raw: |
235 | 242 | logger.warning("delegate: usage payload matched none of the known bucket spellings: %r", sorted(raw)) |
236 | 243 | return None |
237 | 244 | return TokenUsage( |
238 | | - uncached_input_tokens=input_tokens, |
| 245 | + uncached_input_tokens=max(prompt_total - prompt_cached, 0), |
239 | 246 | output_tokens=output_tokens, |
240 | 247 | cache_creation_input_tokens=cache_creation, |
241 | | - cache_read_input_tokens=cache_read, |
| 248 | + cache_read_input_tokens=prompt_cached, |
242 | 249 | ) |
243 | 250 |
|
244 | 251 |
|
@@ -795,22 +802,21 @@ def _handle_tool_result(self, msg: dict[str, Any], state: _TurnState, emit: Call |
795 | 802 | emit(ToolEndEvent(task_id=self.task_id, turn_id=state.turn_id, tool=telemetry, status=status)) |
796 | 803 |
|
797 | 804 | def _handle_send_ok(self, msg: dict[str, Any], state: _TurnState) -> None: |
| 805 | + # CONFIRMED (reading the installed SDK's bundled source): |
| 806 | + # sendMessage()'s resolved value is always a plain string, never an |
| 807 | + # object -- `usage`/`sessionId` are NOT nested under it. delegate_host.mjs |
| 808 | + # instead reads them off `getLastTurnUsage()`/`getSessionId()` after |
| 809 | + # sendMessage() resolves and attaches them to this message's own |
| 810 | + # top level (see delegate_host.mjs's wire-protocol header comment). |
798 | 811 | result = msg.get("result") |
799 | 812 | if isinstance(result, str): |
800 | 813 | state.final_response = result |
801 | | - elif isinstance(result, dict): |
802 | | - response = result.get("response") or result.get("content") |
803 | | - if isinstance(response, str): |
804 | | - state.final_response = response |
805 | | - session_id = result.get("sessionId") |
806 | | - if isinstance(session_id, str) and session_id: |
807 | | - self._session_id = session_id |
808 | | - usage = _parse_usage(result.get("usage")) |
809 | | - if usage is not None: |
810 | | - state.usage = usage |
811 | | - model = result.get("model") |
812 | | - if isinstance(model, str) and model: |
813 | | - state.model_used = model |
| 814 | + session_id = msg.get("sessionId") |
| 815 | + if isinstance(session_id, str) and session_id: |
| 816 | + self._session_id = session_id |
| 817 | + usage = _parse_usage(msg.get("usage")) |
| 818 | + if usage is not None: |
| 819 | + state.usage = usage |
814 | 820 |
|
815 | 821 | def _close_open_tools(self, state: _TurnState, emit: Callable[[StreamEvent], None]) -> None: |
816 | 822 | for tool_id, telemetry in list(state.open_tools.items()): |
|
0 commit comments