Skip to content

feat(delegate): drive the public @uipath/delegate-stdio host [PILOT-7854] - #207

Draft
Mihaiii wants to merge 11 commits into
mainfrom
chore/move-delegate-sdk-2
Draft

Mihaiii wants to merge 11 commits into
mainfrom
chore/move-delegate-sdk-2

Conversation

@Mihaiii

@Mihaiii Mihaiii commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Issue

PILOT-7854

Warning

Breaking changes. The environment variable names stay as on main. What changes:

Before (main) Now
npm install @uipath/delegate-sdk npm install @uipath/delegate-stdio, a release that accepts the auth init option (1.202.1 and older ignore it, and init fails with Auth required unless a saved login exists)
DELEGATE_SDK_PATH → @uipath/delegate-sdk/dist/index.mjs DELEGATE_SDK_PATH → @uipath/delegate-stdio/dist/delegate_stdio.mjs; any other file is an AgentConfigError
DELEGATE_SDK_NODE_MODULES → root holding @uipath/delegate-sdk same name → root holding @uipath/delegate-stdio
agent.effort (Delegate-only field) agent.sdk_options.effort, the key Claude Code already uses
Host env carried AUTH_TOKEN / TENANT_ID / ORG_ID / … Removed from the host env; auth reaches the host as its auth init option on stdin

Unchanged: DELEGATE_ENV, DELEGATE_BACKEND_URL, and DELEGATE_AUTH_TOKEN / DELEGATE_TENANT_ID / DELEGATE_ORG_ID / DELEGATE_ORG_SLUG / DELEGATE_TENANT_SLUG, each with its bare spelling as a fallback.

Summary

  • Replace the first-party agents/delegate/delegate_host.mjs with the @uipath/delegate-stdio host; it installs @uipath/delegate-sdk and the interop runtime itself
  • Port DelegateAgent to the host's protocol: event frames, per-call usage frames, terminal result / error, destroyed
  • Send auth, the org/tenant slugs, the backend URL and the env slug as init options (auth, backendUrl, env); remove the host's own names (AUTH_TOKEN, TENANT_ID, ORG_ID, ORG_LOGICAL_NAME, TENANT_NAME, BACKEND_URL) and DELEGATE_AUTH_TOKEN from its env, so agent shells cannot read the token and a stray BACKEND_URL cannot route the host
  • Keep token usage and cost for the finished calls of a turn cut at max_turns or by an early stop
  • Make crash retry independent of the host's stderr text (build 13599116 retried "terminated" on four tasks but not on one whose id contains "guardrail")

Changes

Adapter — src/coder_eval/agents/delegate_agent.py

  • _resolve_host_bundle replaces _resolve_sdk_entry: DELEGATE_SDK_PATH, DELEGATE_SDK_NODE_MODULES, then the cwd and its ancestors, this agent's own agents/delegate/, and home
  • _env (namespaced first, bare fallback) + _auth_option build the auth init option; _HOST_ENV_REMOVED lists what leaves the host env
  • Read toolArgs / toolResult / toolStatus, Anthropic-convention usage, result.model; num_turns = len(result.turnUsages)
  • isStepStart: false deltas extend one text block; enableSkills is sent explicitly (the host default is false)
  • Sum the per-call usage frames; the result's usage (the turn total) replaces the sum
  • A tool result with no open call keeps its name and args; toolStatus: "interrupted" is an error
  • Categorize WAF blocks (content filter, not retried), SSE connect timeouts (connection, retried) and session conflicts (retry in a new conversation)
  • Init errors: only auth / missing slugs / unknown env are non-retryable AgentConfigError (with a hint that names coder_eval's variables); other init errors and the 60 s init deadline are retryable
  • No crash reason carries the stderr tail; it is logged at WARNING
  • get_sdk_options() returns the init options with auth reduced to its field names and backendUrl to its host; a cancelled stdout drain no longer logs at ERROR; _force_kill_host clears the process handle
  • Remove LLMGW_* from the host env when a token file is set

Docs, CI, tests

  • docs/agents/DELEGATE.md, docs/USER_GUIDE.md, docs/agents/HARNESS_PARITY.md, .env.example, .claude/notes/agents.md
  • pr-checks.yml delegate-live-tests: installs @uipath/delegate-stdio, sets the DELEGATE_* names from the existing DELEGATE_* secrets
  • Unit-test frame builders replay shapes recorded from a live 1.202.1 transcript; an autouse fixture clears the developer's own DELEGATE_* values

Implementation Notes

  • The host gives each auth field priority over its env var; the SDK reads none of them. It takes the credentials from the TokenAuthProvider the host builds, and an explicit backendUrl replaces the BACKEND_URL default its bundle reads at load. Host side: UiPath/Autopilot#6656. A refresh source (token file, LLMGW_*, saved login) still writes its token into the host's own process.env.AUTH_TOKEN.
  • agents/delegate/package.json still says ^1.202.1. Raise it to the first release that carries the usage frame and the auth option (both in UiPath/Autopilot#6656); until then the live CI job installs a host that ignores auth.
  • Cut-turn usage relies on frame order: call N's usage frame precedes the tool results of call N. The max_turns boundary does not move.
  • max_turns stays client-side: live, maxSteps: 2 ran 7 steps.

Testing

  • test_delegate_agent*.py, test_delegate_agent_config.py, test_error_handling.py: 195 passed, 6 skipped. New tests: the auth option, namespaced-over-bare, removed host names, redacted sdk_options, the DELEGATE_SDK_PATH file check, the init-error hint, stderr-tail categorization, init retry, drain cancel, kill() handle
  • Each review-fix test failed on the previous adapter code and passes now
  • Live against alpha, host built from UiPath/Autopilot#6656, DELEGATE_STDIO_VERBOSE=1: PONG, 12,250 tokens, $0.00095; the host log shows Using init-option / env var auth (not the saved login); the token is in neither the host's stderr nor sdk_options
  • Full suite: 5840 passed, coverage 88.17 %; custom lint (735), ruff, prose budget, pyright on the adapter clean. The 20 other local failures (Windows symlink privilege, missing litellm, a node_modules in a parent of the temp dir) are environment-only

🤖 Generated with Claude Code

Replace the first-party delegate_host.mjs wrapper around @uipath/delegate-sdk
with the published @uipath/delegate-stdio host (^1.202.1), which pulls in the
SDK and interop runtime itself.

- Speak the host's protocol: `event` frames, terminal `result` / `error`,
  `destroyed`; read toolArgs / toolResult / toolStatus, isStepStart deltas,
  Anthropic-convention `usage`, and `turnUsages` as the authoritative call count.
- Export auth into the host's environment under the names it reads
  (ORG_SLUG -> ORG_LOGICAL_NAME, TENANT_SLUG -> TENANT_NAME) instead of an
  `auth` init option; DELEGATE_ENV becomes the `env` option.
- Send enableSkills explicitly (the host defaults it off).
- Rename DELEGATE_SDK_PATH / DELEGATE_SDK_NODE_MODULES to
  DELEGATE_STDIO_PATH / DELEGATE_STDIO_NODE_MODULES across code, docs, CI.
- Keep max_turns client-side: the host's maxSteps does not stop a turn.
- Live-test gate now honours DELEGATE_AUTH_TOKEN.

Verified live against alpha: all delegate live tests (saved login and
env-token paths) and tasks/delegate/fizzbuzz_delegate.yaml pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mihaiii and others added 8 commits September 29, 2026 23:46
The SDK's tool_result carries toolStatus "interrupted" for a tool that did not
complete. Only "failed" was treated as an error, so an interrupted tool was
recorded with result_status "success".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n conflicts

Port three failure signatures from the out-of-tree delegate-sdk adapter, which
drove this same delegate-stdio host against this same backend:

- A Cloudflare WAF block page (a 403 for shell-like text in the request body)
  is rewritten to a "content filter" reason, so it is not retried: the same
  payload is blocked again.
- An SSE connect timeout is rewritten to a "connection" reason with "timeout"
  defanged, so it is retried as AGENT_API_ERROR instead of ending the task as a
  non-retryable AGENT_TIMEOUT.
- A session conflict ("A reply is already being generated") drops the
  remembered session id, so the retry starts a new conversation instead of
  conflicting again.

A rewritten reason omits the host stderr tail, because a "timeout" in the tail
would undo the categorization; the tail is logged instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…en file is set

The agent's shell tools inherit the host env, so the gateway S2S client secret
there is readable by the code under test. The host refreshes its token from
that pair only when no token file is configured; with DELEGATE_AUTH_TOKEN_FILE
(or the older AUTH_TOKEN_FILE) set, the file wins and the pair is unused. Remove
LLMGW_CLIENT_ID / LLMGW_CLIENT_SECRET / LLMGW_URL from the host env in that case
only, mirroring the host's own lookup, so a long run without a token file still
refreshes its token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ute effort through sdk_options

BREAKING CHANGE: the Delegate agent now uses the names that the
@uipath/delegate-stdio host reads itself, and passes its environment to the
host unchanged. DELEGATE_ENV -> DELEGATE_SDK_ENV, DELEGATE_BACKEND_URL ->
BACKEND_URL, ORG_SLUG -> ORG_LOGICAL_NAME, TENANT_SLUG -> TENANT_NAME. The
DELEGATE_-prefixed auth spellings (DELEGATE_AUTH_TOKEN, ...) are no longer
read; set AUTH_TOKEN / TENANT_ID / ORG_ID. The Delegate-only `effort` field
is replaced by `sdk_options.effort`, the key Claude Code already uses, so
`-D agent.sdk_options.effort=high` now works for delegate tasks and the
reports' Effort row shows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Delegate turn cut at max_turns or by an early stop never gets the
host's result frame, so it lost all its token usage and cost, and the
max_usd and max_total_tokens gates had nothing to check. The other
harnesses keep the usage of the calls under the cap.

The delegate-stdio host now writes one `usage` frame per backend
round-trip, before the tool results of that round-trip. The adapter adds
up the frames. The result's `usage` is the turn total, so it replaces
the sum when it arrives. The max_turns call boundary does not change.

The adapter warns when finished model calls report no usage: a completed
turn with no usage, or a cut turn from a host that does not send the
frame. A zero payload in the known buckets no longer warns as a renamed
bucket.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Delegate SDK sends no tool_call for a tool name it cannot resolve,
only the failed tool_result. That result carries toolName and echoes the
args in toolResult.args, so the synthesized row now takes both from it
instead of recording "unknown" with no parameters (29 rows in nightly
13599116).

The SDK also starts a new tool while earlier results are still pending,
and those results arrive later. A new call no longer force-closes the
tools that are still open, so the late results match their own rows
instead of becoming "unknown" rows. The call counting is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fixes from the review of nightly 13599116:

- No crash reason carries the host's stderr tail now; it is logged at
  WARNING. The tail holds the sandbox path, so the task id decided the
  category: "Delegate backend error: terminated" was retried on four
  tasks and ended skill-review-agents-lowcode-guardrail-unknown-validator
  as a non-retryable AGENT_INVALID_OUTPUT, because "guardrail" matched.
- Only an init error that a retry cannot fix (missing or rejected auth,
  missing org/tenant slugs, an unknown env) raises AgentConfigError.
  Other init errors and the 60 s init deadline are retryable.
- A stdout drain cancelled at teardown no longer logs "stdout drain
  failed" at ERROR (396 times on the Linux slice).
- get_sdk_options() returns the init options sent to the host. The
  reports use it in place of agent_config, so the pass-through dict hid
  the Model row on every run that set an effort.
- The install search also looks in agents/delegate/, as the docs say.
- _force_kill_host drops the process handle itself, so kill() clears it
  too, also when the reap times out.

HARNESS_PARITY.md no longer describes the out-of-tree delegate-sdk agent
as a live agent; its untimed tool records are now a historical note.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…LEGATE_* names again

The adapter read the delegate-stdio host's own variable names and passed
its environment through unchanged. The bare names collide with other
tooling (a BACKEND_URL exported for another service routed the host
there), and every agent shell command inherited the token.

The adapter now reads coder_eval's names again, as main does:
DELEGATE_SDK_PATH, DELEGATE_SDK_NODE_MODULES, DELEGATE_ENV,
DELEGATE_BACKEND_URL, and DELEGATE_AUTH_TOKEN / DELEGATE_TENANT_ID /
DELEGATE_ORG_ID / DELEGATE_ORG_SLUG / DELEGATE_TENANT_SLUG, each with the
bare spelling as a fallback. It sends the auth and the slugs as the
host's new `auth` init option, DELEGATE_BACKEND_URL as `backendUrl`, and
DELEGATE_ENV as `env`. It removes AUTH_TOKEN, TENANT_ID, ORG_ID,
ORG_LOGICAL_NAME, TENANT_NAME, BACKEND_URL and DELEGATE_AUTH_TOKEN from
the host's environment.

- DELEGATE_SDK_PATH must name delegate-stdio's dist/delegate_stdio.mjs.
  An old value that names delegate-sdk's dist/index.mjs is an error.
- A config-class init error names coder_eval's variables beside the
  host's message, which names the host's.
- get_sdk_options() redacts the credentials: `auth` becomes its field
  names and `backendUrl` its host, because the run records it.

BREAKING CHANGE: needs a @uipath/delegate-stdio release that accepts the
`auth` init option; 1.202.1 and older ignore it and fail init with "Auth
required" unless a saved login exists. Verified live against alpha with
a host built from Autopilot's chore/move-delegate-sdk-2 branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Mihaiii Mihaiii changed the title feat(delegate): drive the public @uipath/delegate-stdio host feat(delegate): drive the public @uipath/delegate-stdio host [PILOT-7854] Oct 1, 2026
Mihaiii and others added 2 commits October 1, 2026 16:45
Keep each decision and its reason; drop the narration and the repeated detail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… call

Since UiPath/Autopilot#6477 the Delegate SDK loads a catalog skill with
LoadSkill {"name", "plugin"} instead of reading its SKILL.md, and the
delegate-stdio host passes the name through unaliased. skill_triggered
reads only `Skill` + `skill` or a `skills/<name>/` path, so it never saw a
Delegate skill load. DelegateAgent now maps LoadSkill {name} to
Skill {skill}.

The rename is keyed by the host's tool name: the host already reports
ExecuteSkillApi as `Skill`, and that call's `name` is an API call, not a
skill load. _tool_call's parameters are positional-only so a test can pass
a `name=` tool arg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant