- Extract shared buildContextInjectionTask/injectContextIntoUserMessage on
AgentCompiler and reuse across both compilers (3 duplicated sites); the
swarm loop now honors the configured context size limits instead of
hardcoded defaults, and the user-message rewrite uses a ListIterator
instead of index bookkeeping.
- Extract parentHandsOffOnToolOf predicate for the swarm turn-skip check.
- Join: move the agent output-shaping explanation onto prepareAgentOutput's
javadoc and return unmodifiable maps for the constructed shapes
(LinkedHashMap kept over Map.of — tool outputs may contain nulls; the
pass-through branch stays live to preserve default JOIN behavior).
agentspan:
- Only suppress an agent's post-tool-call turn when a parent on_tool_result
handoff actually watches a tool that agent owns, instead of suppressing it
for every sub-agent in the swarm whenever any handoff targets any tool.
Otherwise a sub-agent's own tool result was never summarized in words
because its loop ended before the LLM saw the output.
- Add a ctx_inject task to the resumed per-turn loop so a turn that does
keep looping (per the above fix) can see prior state/tool-results instead
of just the bare prompt, mirroring AgentCompiler's existing wiring.
ui-next:
- Guard AgentExecutionDiagram's response sublabel against undefined text so
a tool-call-only turn renders an empty sublabel instead of the literal
string "undefined".
- In agentExecutionUtils, render handoffs (explicit transfer, condition-based
via handoff_check, or HANDOFF-strategy routing) for real sub-agent turns,
which previously showed nothing between agents handing off work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Keys read from environment variables (e.g. via export KEY=$(cat keyfile) or
.env parsers) can carry a trailing newline, producing a cryptic Java HTTP
client error: "Unexpected char 0x0a in Authorization value".
AgentspanAIModelProvider.getSystemEnv() now strips surrounding whitespace
from System.getenv() results. A new package-private readRawEnv() hook makes
the trimming testable without PowerMock; existing tests that override
getSystemEnv() are unaffected.
The direct System.getenv() call inside getModel() is also routed through
getSystemEnv() for consistency.
Assistants with code interpreter (e.g. analyst agents) return an image_file
part before the text part. Taking content[0].text was always empty for these
assistants. Now iterates parts and returns the first one with type=text.
SkillRegistryService took SkillPackageStore and SkillMetadataDAO as mandatory
constructor arguments, so a host with no implementation for its configured backend
could not start the agent runtime at all — the context failed with
NoSuchBeanDefinitionException on SkillPackageStore. Skills are a capability, not a
precondition for running an agent.
Both are now ObjectProvider. list() degrades to an empty list, since "which skills
exist" has a correct answer without storage; everything that needs the bytes fails
with a clear UnsupportedOperationException rather than an NPE. isSkillStorageAvailable()
lets callers branch.
Concretely: a host that supplies Postgres skill stores and none for MySQL currently
has to choose between writing a second backend-specific DAO pair or disabling the AI
integration entirely on that backend — and disabling it also removes unrelated AI
surface such as the prompt API. Neither is a reasonable requirement for running
agents without skills.
Adds OptionalSkillStorageTest: the registry starts with no storage, list() is empty,
and lookup fails clearly.
Verified: :conductor-agentspan:test and :conductor-ai:test — 0 failures. spotless clean.
Closes the two loose ends left in this PR so the release does not need a
follow-up.
A2AWorkers indexed clients with agentClients.put(agentType, client), so two
clients claiming one type last-one-wins on discovery order and one disappears
silently. Before #1358 the single-client injection raised
NoUniqueBeanDefinitionException, so this traded a loud failure for an invisible
one — and defaulting agentType() to "conductor" earlier in this PR makes the
collision easier to hit, since an implementation that forgets to override now
matches the built-in client. Keep the first registration and log which client was
ignored, on both the Spring and SDK paths.
Adds ExternalAgentClientGatingTest over the two external clients, which had no
coverage at all: absent when the AI integration is off, registered together with
CredentialResolutionService when on, reporting their own agent types with no
credentials configured anywhere, and — the regression that motivated the
qualifier — Azure Foundry still resolving its OkHttpClient when a second
OkHttpClient bean exists. Verified the last case fails without the qualifier
(NoUniqueBeanDefinitionException: found 2) and passes with it.
The dropped "AGENT (conductor) requires 'name'" validation from #1358 is
deliberate and stays dropped: the delegate now also serves bedrock and
azure-foundry, whose identity comes from rawConfig/assistantId rather than a
name, which is why the remaining messages were genericised to "AGENT requires
'prompt'".
Verified: :conductor-agentspan:test 560 tests, :conductor-ai:test 750 tests, 0
failures; :conductor-test-harness:compileTestJava clean; spotless clean.
Both external agent clients stay on conductor.integrations.ai.enabled, so every
combination is reachable without extra flags: bedrock only, azure-foundry only,
both, or neither — which runtime a workflow uses is decided by its agentType, not
by configuration. Registration is deliberately independent of whether either
service is configured, because neither resolves credentials at construction:
Bedrock reads credentialRef inside buildRuntimeClient (falling back to the
default AWS credential chain) and Azure Foundry inside its token exchange, both
per request. A missing key therefore cannot break startup; it surfaces only if a
workflow actually routes to that runtime.
The one thing that could break the both-registered case is the OkHttpClient
injection, which was unqualified. Only one such bean exists on a stock server
(conductorAiHttpClient), so it resolves today, but any host that defines a second
OkHttpClient bean would fail AzureFoundryAgentClient with
NoUniqueBeanDefinitionException — taking the whole context down, not just that
runtime. Bind it to conductorAiHttpClient explicitly, which is the client the AI
module publishes for exactly this purpose.
Also records the per-request credential behaviour on both clients, since "why is
this bean registered when I never configured Bedrock" is the obvious question.
Verified: ./gradlew :conductor-agentspan:test :conductor-ai:test — 0 failures;
:conductor-test-harness:compileTestJava clean; spotless clean.
Two bugs found during end-to-end local testing against ai-orkes-tests:
1. DEFAULT_SCOPE was management.azure.com — Azure OpenAI Assistants requires
cognitiveservices.azure.com, causing 401 Unauthorized on every call.
2. API_VERSION was hardcoded to 2025-05-01 which is not yet available on all
Azure OpenAI resources. Changed default to 2025-01-01-preview (broadly
supported), configurable via rawConfig.apiVersion for resources that have
a specific version requirement.
Also stores apiVersion in ExecutionContext so all status/respond/cancel calls
use the same version that was negotiated at start time.
Tested against ai-orkes-tests: greeter (2-4s), summarizer (4-8s), and analyst
with code_interpreter (8-15s) all complete successfully.
#1358 added BedrockAgentClient and AzureFoundryAgentClient as plain @Component
with no condition, unlike their sibling ServiceConductorAgentClient which is
gated on conductor.integrations.ai.enabled.
All three hard-require CredentialResolutionService, which carries that same
flag. Leaving two of them unconditional means a client can register while the
service it needs does not. On a stock server that is masked, because
AgentSpanAutoConfiguration's scan is gated on the same flag, so the clients are
only ever discovered when the service also exists. It stops being masked for any
host that component-scans the runtime package itself: the clients register, the
service does not, and startup fails with NoSuchBeanDefinitionException.
Give both the same condition as ServiceConductorAgentClient so a client and its
dependency can never register apart. Behaviour on a stock server is unchanged —
they activate exactly when they do today.
BedrockAgentClient's javadoc claimed activation by
conductor.ai.bedrock-agent.enabled, a property that appears nowhere else in the
repo and was never wired; its javadoc now names the flag actually used.
Also drop the dead "agentspan.embedded=true" property from
ConductorAgentEndToEndTest — #1413 folded that flag into
conductor.integrations.ai.enabled (which the test already sets), so this was the
last reference in the repo to a property nothing reads.
Verified: ./gradlew :conductor-agentspan:test — 0 failures;
:conductor-test-harness:compileTestJava clean; spotless clean.
A2AAgentServerResource is now a thin HTTP adapter — jsonRpc() delegates
entirely to facade.dispatch(). The ExecutorService, switch dispatch, SSE
stream logic, and JSON-RPC envelope builders all live in the facade bean
so Orkes can inject a custom implementation without touching the controller.
Dispatch behavior tests move to A2ANativeAgentFacadeTest; resource test
covers HTTP routing and delegation only.
Adds A2ANativeAgentFacade and A2AAgentServerResource in the agentspan module
to mirror the workflow-side A2A server at /api/a2a/agent/{name}. Native agents
are listed via AgentService, started via AgentService.start(), and execution
tracking reuses the same workflow-state mapping as the workflow side.
Closes#1407
The cutoff was computed as Instant.now().minus(olderThanDays, DAYS)
with no bounds check. Very large values push the epoch negative, and
the search backend matched recent executions against the negative
bound, hard-deleting them. Non-positive values put the cutoff in the
future, matching every terminal execution. Reject olderThanDays < 1
and clamp the cutoff to epoch start, where it correctly matches
nothing. The cutoff computation is extracted into a VisibleForTesting
helper with an injectable clock for deterministic boundary tests.
Fixes#1331
Context: #1352 binds a toolCalls short-circuit into the same output-guardrail
scripts this branch's escalation fix touches
- neither test suite covered the combination: tool-call turn +
iteration already >= max_retries
Adds 2 tests to GuardrailEscalationScriptTest:
- tool-call turn (non-empty toolCalls) + iteration >= max_retries ->
must still pass
- short-circuit must win over escalate()
- model is mid-tool-use, never a raise candidate
- empty toolCalls array (real final turn) -> must NOT short-circuit,
escalation still fires
Matches the script ordering: toolCalls check runs before escalate().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug: compileToolGuardrailTasks fed every tool guardrail type
(custom/regex/llm) a constant iteration of "1", not the live DoWhile
counter.
- SDK worker / JS scripts check iteration >= maxRetries
- constant "1" -> onFail=RETRY never escalates
- loop runs to maxTurns, workflow completes instead of failing
Fix: pass ${<agent>_loop.output.iteration} instead of "1"
- same live-counter expression as the agent-level path (previous commit)
- one line fixes all guardrail types (fans out to custom SIMPLE task,
regex/llm scripts, normalizer)
Tests added:
- GuardrailCompilerTest: compiler wiring, live ref reaches all 3
guardrail types
- GuardrailEscalationScriptTest (new): executes
customGuardrailNormalizeScript() via real GraalJS
- same evaluator as production
- proves escalation logic itself (retry->raise, fix->raise only if
no fixed_output, tripwire-style raw output)
- no LLM, no workflow executor needed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes:
- live loop counter must be ${<loop>.output.iteration}, not bare ${<loop>.iteration}
- bare form resolves to null mid-loop
- touches: GuardrailCompiler (agent-level output guardrails), TerminationCompiler
(4 sites), MultiAgentCompiler round-robin selector
- customGuardrailNormalizeScript never escalated retry/fix -> raise
- wires iteration + max_retries into the normalize INLINE
- adds escalate() coercion (same as regex/llm scripts already had)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both classes are intentionally not annotated with @Component so they do not
auto-register until implemented. Each documents the expected rawConfig keys,
auth approach, and API mapping for the eventual implementor.
Replace the placeholder A2A implementation with the real Azure AI Foundry
Agents REST API (OpenAI Assistants-compatible). The client now:
- POST /threads → POST /threads/{id}/messages → POST /threads/{id}/runs
to start an agent and return thread_id as executionId
- GET /threads/{id}/runs/{runId} to poll status
- Maps Azure states: completed→COMPLETED, requires_action→WAITING,
failed/expired→FAILED, queued/in_progress→RUNNING
- GET /threads/{id}/messages on completion to extract the response text
- POST /threads/{id}/runs/{runId}/submit_tool_outputs for tool results
- POST /threads/{id}/runs/{runId}/cancel on cancellation
- rawConfig.assistantId (or agentId) selects which assistant to use
SdkBytes.bytes() does not exist; use asUtf8String() directly on the
SdkBytes returned by PayloadPart.bytes(). Also removed the leftover
ConditionalOnProperty imports from BedrockAgentClient and
AzureFoundryAgentClient (annotations were removed earlier).
Add agentType() to ConductorAgentClient interface so each implementation
self-declares its routing key. A2AWorkers now takes List<ConductorAgentClient>,
builds a map at startup, and routes by agentType on the request — no
conditional logic, no @ConditionalOnProperty flags.
- conductor → ServiceConductorAgentClient (existing)
- bedrock → BedrockAgentClient (new)
- azure-foundry → AzureFoundryAgentClient (new)
- anything else → A2A remote path (existing fallback)
Adding a new platform is now: implement ConductorAgentClient, declare
agentType(), register as a Spring bean — nothing else changes.