Skip to content

feat(agents): hosted agent runtimes — stateless execution, Entra ID auth modes, discovery, and tools as workflow tasks - #1582

Open
v1r3n wants to merge 10 commits into
mainfrom
feat/hosted-agent-tool-execution
Open

feat(agents): hosted agent runtimes — stateless execution, Entra ID auth modes, discovery, and tools as workflow tasks#1582
v1r3n wants to merge 10 commits into
mainfrom
feat/hosted-agent-tool-execution

Conversation

@v1r3n

@v1r3n v1r3n commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Pull Request type

  • Bugfix
  • Feature
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • WHOSUSING.md
  • Other (please describe):

Changes in this PR

Makes the hosted-platform agent runtimes correct across replicas, implements the OpenAI
Assistants runtime, and lets Conductor run the tools a hosted agent asks for as real workflow
tasks.

Supersedes #1446 and #1447. #1446 added an AzureAgentRunStore SPI to make the status poll
stateless; this removes the need for a store at all, so that SPI is gone. #1447's auth modes,
agent discovery, AssumeRole, on-behalf-of hooks and UI are all folded in here — ported onto the
stateless client structure rather than the stateful one they were written against.

Hosted agents no longer keep per-run state

AzureFoundryAgentClient kept endpoint, runId and the token provider in an in-process
ConcurrentHashMap. A status poll routed to another replica hit "No execution found", so every
execution after the first failed in a multi-replica deployment.

The fix is that there is nothing to remember. The executionId is the Azure thread id, and the
thread is the conversation: the run to act on is always the newest one on it, which the provider
names on request. Everything else — endpoint, assistant, api version, credential, scope — is
re-derived from the task input Conductor already persists. A poll, a respond, or a cancel now
resolves identically on any replica, with no store to configure.

A status poll still costs one HTTP call: listing the newest run returns its full status, so
asking which run is current is no more expensive than having remembered its id.

OpenAI Assistants implemented; the protocol is now shared

Azure Foundry and OpenAI speak the same thread-and-run API, so that protocol moved into
AssistantsRunApi and both clients became thin layers over it — Azure adds Entra ID client
credentials and api-version, OpenAI adds an API key and the OpenAI-Beta header.
OpenAiAssistantsAgentClient was a stub throwing UnsupportedOperationException; it is now a
working runtime.

Bedrock is stateless too, and no longer leaks

Bedrock has no status API — InvokeAgent streams the whole turn, so the agent has finished or
blocked on a tool before the call returns. It buffered that result in a map, which meant a poll on
another replica reported a terminal failure for a run that had actually succeeded.

It now reports the outcome directly: startAgent through ConductorAgentStartResponse.state, and
respond through the new default respondWithStatus, so the result lands in the task output
instead of a map. Runtimes with a status API return null there and are polled exactly as before.

It also built one BedrockAgentRuntimeAsyncClient per execution and never removed a finished
one, leaking Netty event loops and a connection pool per agent invocation. One client is now shared
per credential and region, released on shutdown.

Parallel tool calls no longer produce wrong answers

A model may ask for several independent tools in one turn. Only the first was reported, and the
reply was then written as the result for every outstanding call — a reply the provider accepts
and the model reasons from, so a second tool asking for headcount was told the revenue figure. Two
200s, a completed workflow, no error anywhere.

pendingTools now carries every call end to end, each is answered by its own tool_call_id, and a
reply that does not cover them all is rejected rather than padded. Single-tool turns are unchanged.

autoRunTools: the agent's tools become workflow tasks

A function tool is one the platform cannot run — you registered only its schema. Previously the
AGENT task completed with waiting: true and the workflow author hand-wired a dispatch branch
and a resume task for every agent.

With autoRunTools: true the AGENT task stays IN_PROGRESS while each requested tool is
scheduled as an ordinary task named after the tool, so a worker already registered for
get_revenue serves it with no configuration. Tools fan out in parallel under
FORK_JOIN_DYNAMIC, each with its own retries, timeout and execution history, and the agent is
resumed with their results keyed by call id. Another turn asking for tools is simply another batch.
toolTaskNames overrides the naming convention.

Off by default, so existing hand-wired workflows are untouched, and it degrades cleanly: a
remotely-polled SDK worker has no engine to schedule on, so the tool request is handed back as
before. When a tool exhausts its retries the remaining tools are stopped, the agent run is
cancelled, and the task fails with that tool's reason.

Token exchange per poll

OAuthTokenProvider caches and refreshes a token, but a new provider was built on every call, so
each 5-second poll paid a full Entra ID round trip plus three secret-store reads. Providers are now
cached per credential and scope with a 10-minute TTL; a 401/403 evicts immediately so a rotated
credential is picked up on the next poll. A steady-state poll now performs no secret read at all.

Folded in from #1447

Four Azure auth modes, first match wins — API key (api-key header, no SDK), service principal,
user-assigned managed identity, and the default Azure credential chain. A deployment running on
managed identity now needs no credentialRef at all. Resolved auth is cached per credential and
scope, so a poll performs no secret-store read; a 401/403 evicts it immediately.

Running as the caller. useCallerIdentity: true exchanges the triggering user's Entra ID token,
via the OAuth 2.0 on-behalf-of grant, for one scoped to Foundry — so the agent sees only what that
person can. Their own token never reaches Foundry, and the exchanged token is never cached, since it
belongs to a person rather than the deployment. Without an SSO-supplied assertion or a service
principal to perform the exchange, it falls back to credential auth rather than failing.

All three Foundry surfaces. Foundry is three APIs behind one agentType: classic Assistants
(threads and runs, pollable), a project's Responses API, and model inference. The latter two answer
inside the start call, so they report a terminal state through ConductorAgentStartResponse instead
of being polled — the same mechanism Bedrock uses, which makes them stateless with nothing to
remember. A project agent's own instructions and tools are forwarded so its web search, code
interpreter and file search run.

rawConfig.surface overrides the hostname classification. #1447 classified purely by hostname, which
silently misroutes sovereign clouds (.azure.us, .azure.cn), private endpoints and proxies to the
classic path.

Agent discovery. A secret with an endpoint key lists Azure agents; one with a region key
lists Bedrock agents. They appear in the agent list beside agents defined in Conductor, with no
separate registration. Best effort — a credential that cannot list contributes nothing rather than
breaking the listing.

Bedrock AssumeRole (roleArn, roleSessionName, externalId) alongside static keys and the
default chain.

agentUrl as a top-level field for both providers, so every agent type names its location the
way A2A does: Azure splits a trailing /assistants/asst_x or /agents/NAME off as the agent,
Bedrock parses bedrock://AGENTID/ALIASID?region=.

UI — provider logos and filter chips on the agent list, and a caller-identity toggle on the task
form, on top of the five-runtime typing already here.

Also in here

  • Docs were wrong: a2a-integration.md said a2a was "the only runtime in OSS today" and that any
    other agentType "is rejected today", while three provider runtimes were registered and working.
    Corrected, and a new Hosted Platform Agents page documents each runtime's rawConfig keys,
    credential shape, and the tool loop — written from the source.
  • The UI could not represent these runtimes: AgentRuntimeType was "a2a" | "conductor", so a
    Foundry task was labelled an unresolved A2A agent with no name and triggered a doomed
    /a2a/agent-card discovery call on save. All five runtimes are now typed and badged, hosted
    agents resolve without remote discovery, and the editor offers them with the right fields.
    The execution view lists the tools an agent is waiting on and links to the run executing them.
  • Ported main's 52ab3da (content[0].text is empty for assistants with code interpreter) into the
    shared AssistantsRunApi.extractText, with a regression test, so the rewrite does not lose it.
  • Two things kept deliberately against feat(azure-foundry,bedrock): Entra ID auth modes, agent tools forwarding, and AssumeRole support #1447: main's agent_capabilitiestags mapping, which
    feat(azure-foundry,bedrock): Entra ID auth modes, agent tools forwarding, and AssumeRole support #1447 dropped incidentally, and this branch's stateless execution model. Two things taken from
    feat(azure-foundry,bedrock): Entra ID auth modes, agent tools forwarding, and AssumeRole support #1447 in preference to what was here: dropping unresolved from the task card (a workflow
    registered outside the editor never has a snapshot, so it was never a real signal) and treating
    the live input's agentType as authoritative over a snapshot that lags a live edit.

Alternatives considered

Compound executionId (the approach in #1446's first commit) base64-encoded the run context
into the id. Genuinely stateless, but the id is workflow-visible — the delegate mirrors it to
subWorkflowId — so endpoint and assistant leaked into the UI. Encoding only the thread id gets the
same property without that.

A durable AzureAgentRunStore (#1446's second commit) added an SPI for hosts to implement with
Redis or a DB. It shipped an in-process default, so OSS multi-replica stayed broken and every
embedder had infrastructure to supply. Since the provider is the source of truth for run state,
there was nothing that needed storing.

Tool tasks as inline children of the AGENT task, rather than a nested workflow, was
investigated and deferred. It needs no decider surgery, but the only thing it adds over this is
avoiding one extra workflow execution per tool turn — at the cost of new engine surface for
scheduling arbitrary runtime tasks, first use of the dormant TaskModel.parentTaskId, and a UI
concept for a task owning children. Worth revisiting if per-turn executions prove costly.

Testing

  • :conductor-ai:test and :conductor-agentspan:test green, spotless clean
  • Whole project compileJava + compileTestJava clean; mkdocs build clean
  • ui-next: tsc --noEmit and eslint --quiet clean, 882 tests pass
  • New coverage: Azure client, Azure auth (mode selection + the on-behalf-of exchange against a mock
    Entra endpoint), Foundry surfaces and routing, OpenAI, Bedrock, the agent delegate, the tool
    dispatcher, plus UI tests for runtime typing, the task form and the execution view
  • The Azure suite runs offline on the API-key mode: the Azure Identity SDK uses its own HTTP stack,
    so an OkHttp interceptor no longer intercepts its token calls. Mode selection is asserted without
    resolving a token

Not yet exercised against a live provider. The path worth a manual check before merge is an
autoRunTools agent with two function tools: confirm both tool tasks are picked up in parallel,
then terminate the parent mid-flight and confirm the tool workflow terminates with it.

shaileshpadave and others added 9 commits July 31, 2026 18:06
…eployments

AzureFoundryAgentClient stored per-run state (endpoint, runId, token provider)
in an in-memory ConcurrentHashMap keyed by threadId. In multi-replica server
deployments the 5-second status-poll callback could arrive on a different pod
than the one that ran startAgent, causing "No execution found" failures on every
execution after the first.

Fix: startAgent now returns a compound executionId that base64-encodes the
non-sensitive run context {threadId, runId, endpoint, assistantId, apiVersion}.
getAgentStatus decodes this to reconstruct the Azure API call on any pod and
re-authenticates using the original task credentialRef, which is available in
the ConductorAgentRequest passed by the updated ConductorAgentDelegate.

Interface change: ConductorAgentClient.getAgentStatus(String) gains a second
ConductorAgentRequest parameter. All five implementations and both test fakes
are updated. ServiceConductorAgentClient ignores the new parameter (AgentService
has its own persistent state). BedrockAgentClient signature is updated; its
in-memory model is unchanged pending a follow-up — the stateless fix for Bedrock
requires a similar compound-executionId approach.

The respondContexts map is retained for respond() and cancelAgent(), which
receive no credentialRef and therefore cannot re-authenticate on a different
replica; single-turn agents (the common case) no longer use it.
…RunStore SPI

Following the same pattern as ServiceConductorAgentClient -> AgentService,
run context (threadId, runId, endpoint, credentialRef, scope) is now persisted
in an injected AzureAgentRunStore rather than encoded in the executionId.

The default store is in-memory (InMemoryAzureAgentRunStore, @ConditionalOnMissingBean);
HA deployments can substitute a Redis or DB-backed implementation, mirroring how
orkes-conductor overrides SkillMetadataDAO and SkillPackageStore.

This also removes the previous same-pod limitation on respond() and cancelAgent():
by storing credentialRef in the run context, any replica can re-authenticate and
handle multi-turn and cancellation requests without in-process state.
Brings the branch up to date with main (96 commits) and resolves the overlap:

- AzureFoundryAgentClient: kept the stateless rewrite, and ported main's
  52ab3da fix into AssistantsRunApi.extractText, where that logic now lives.
  Assistants with code interpreter return an image_file part ahead of the text
  part, so content[0].text was empty for them. Covered by a new test.
- Test fixtures now type their content parts, as the real Assistants API does.
- a2a-integration.md: kept main's wording, corrected the runtime list.
- conductor-agents.md, mkdocs.yml, .gitignore: took main's versions; the docs
  nav entry was re-added under main's restructured Agents > Build section.
…ssumeRole, OBO

Combines PR #1447 into this branch. Where the two overlapped, #1447's auth and
discovery work is ported onto the stateless client structure here rather than
the stateful one it was written against.

Azure auth (new AzureFoundryAuth):
- Four credential modes, first match wins: api-key header, service principal,
  user-assigned managed identity, default credential chain. A deployment on
  managed identity now needs no credentialRef at all.
- Caller identity (OBO): exchanges the caller's Entra token for a Foundry-scoped
  one. Never cached — it belongs to a person, not the deployment — and falls back
  to credential auth when the service principal to exchange it is absent.
- Scope follows the endpoint (ai.azure.com / ml.azure.com / cognitiveservices),
  overridable, and resolved only on a cache miss so a poll reads no secrets.
- AssistantsRunApi now takes an AssistantsAuth rather than a bearer string, since
  Azure may authenticate by api-key header. OpenAI supplies a bearer key.

Discovery: agents visible to a credential appear in the agent list — a secret with
an 'endpoint' key lists Azure agents, one with 'region' lists Bedrock. Both clients
gained listExternalAgents/getExternalAgentDef; AgentService scans secrets for them.

Bedrock: AssumeRole (roleArn, roleSessionName, externalId) alongside static keys and
the default chain, and the SDK client cache key no longer resolves secrets to compute.

agentUrl as a top-level field for both: Azure splits a trailing /assistants/asst_x
or /agents/NAME off as the agent; Bedrock parses bedrock://AGENTID/ALIASID?region=.

Kept from this branch where they conflicted: the stateless execution model, the
parallel-tool contract, and autoRunTools. Kept from main: the agent_capabilities
to tags mapping, which #1447 dropped incidentally.

Tests: the Azure suite moved to api-key auth so it runs offline — the Identity SDK
uses its own HTTP stack, so an OkHttp interceptor no longer catches token calls.
New AzureFoundryAuthTest covers mode selection and the OBO exchange against a mock
Entra endpoint. Their ITs updated for the two-arg getAgentStatus.

UI: took #1447's two genuine fixes — dropped 'unresolved' from the task card, since a
workflow registered outside the editor never has a snapshot, and made the live input's
agentType authoritative over a lagging snapshot. Provider logos, filter chips, and the
caller-identity toggle come in on top of the five-runtime typing already here.
Completes the #1447 fold-in. Foundry serves three APIs behind one agentType and
they do not share a protocol; only the classic Assistants surface was handled.

- Model inference (chat completions) and a project's Responses API both answer
  inside the start call, so they report a terminal state through
  ConductorAgentStartResponse rather than being polled — the same mechanism
  Bedrock uses, which makes them stateless without a run to remember.
- A project agent's own instructions and tools are read from its definition and
  forwarded, so web search, code interpreter and file search actually run.
  code_interpreter is wrapped in the container object the Responses API requires.
- getAgentStatus reports terminal for both, and respond rejects them with a clear
  message rather than quietly issuing thread operations against an endpoint that
  has no threads.

rawConfig.surface (assistants | responses | inference) overrides the hostname
inference. #1447 classified purely by hostname, which silently misroutes sovereign
clouds (.azure.us, .azure.cn), private endpoints and proxies to the classic path —
and made the behaviour untestable against a local server.
@v1r3n v1r3n changed the title feat(agents): stateless hosted agent runtimes, OpenAI Assistants, and tools as workflow tasks feat(agents): hosted agent runtimes — stateless execution, Entra ID auth modes, discovery, and tools as workflow tasks Aug 28, 2026
The suite runs on api-key auth, so nothing reaches an Entra token endpoint through
OkHttp any more — the Identity SDK uses its own HTTP stack. The interceptor was
still installed but no longer asserted on, and its comment described an exchange
the client no longer performs.
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.

2 participants