Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2025 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.conductoross.conductor.ai.examples;

import java.util.LinkedHashMap;
import java.util.Map;

import org.conductoross.conductor.ai.Agent;
import org.conductoross.conductor.ai.AgentRuntime;
import org.conductoross.conductor.ai.enums.Strategy;
import org.conductoross.conductor.ai.model.AgentHandle;
import org.conductoross.conductor.ai.model.AgentResult;
import org.conductoross.conductor.ai.model.ToolDef;

/** Use a decision tool to choose one of two chat agents, without an SDK worker. */
public class ExampleDecisionRouter {
public static Agent supportRouter() {
String chatModel = System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini");
Agent billing = Agent.builder().name("billing_java").model(chatModel)
.instructions("Help the customer with billing and invoice questions.").build();
Agent technical = Agent.builder().name("technical_java").model(chatModel)
.instructions("Help the customer troubleshoot product issues.").build();
Map<String, Object> config = new LinkedHashMap<>();
config.put("model", "jev-1.13");
config.put("questions", Map.of("agent", Map.of(
"type", "choice", "instructions", "Choose the right support team.",
"choices", Map.of("billing_java", "Payments and invoices",
"technical_java", "Product errors and troubleshooting"))));
String provider = System.getenv("CONDUCTOR_DECISION_PROVIDER");
if (provider != null) config.put("provider", provider);
ToolDef selector = ToolDef.builder().name("support_selector_java")
.description("Choose the support agent that should handle the request.")
.toolType("decision").config(config).build();
return Agent.builder().name("decision_support_router_java").strategy(Strategy.ROUTER)
.router(selector).agents(billing, technical).maxTurns(1).synthesize(false).build();
}

public static void main(String[] args) {
try (AgentRuntime runtime = new AgentRuntime()) {
Agent agent = supportRouter();
if (args.length == 0 || !"--run".equals(args[0])) {
System.out.println(runtime.plan(agent));
return;
}
runtime.deploy(agent);
AgentHandle handle = runtime.start(agent, "The customer reports a duplicate charge on the latest invoice.");
System.out.println("Execution: " + handle.getExecutionId());
AgentResult result = handle.waitForResult(120_000, 500);
if (!result.isSuccess()) throw new IllegalStateException(result.getError());
result.printResult();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2025 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.conductoross.conductor.ai.examples;

import java.util.Map;
import java.util.concurrent.TimeUnit;

import com.netflix.conductor.common.run.Workflow;
import com.netflix.conductor.sdk.workflow.def.ConductorWorkflow;
import com.netflix.conductor.sdk.workflow.def.tasks.Javascript;
import com.netflix.conductor.sdk.workflow.def.tasks.Switch;
import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor;

import io.orkes.conductor.client.ApiClient;

/** Route a request through one synchronous decision SWITCH and the selected INLINE branch. */
public class ExampleDecisionRouting {
public static ConductorWorkflow<Map<String, Object>> createWorkflow(WorkflowExecutor executor) {
ConductorWorkflow<Map<String, Object>> workflow = new ConductorWorkflow<>(executor);
workflow.setName("ai_decision_routing_java");
workflow.setVersion(1);
Switch decision = new Switch("route_request", "route")
.evaluatorType(Switch.DECISION_NAME)
.input("model", "jev-1.13")
.input("state", "${workflow.input.request}")
.input("questions", Map.of("route", Map.of(
"type", "choice",
"instructions", "Choose the team best suited to handle this request.",
"choices", Map.of(
"billing", "Payments, invoices, refunds, or subscriptions.",
"technical", "Errors, outages, or product troubleshooting."))));
String provider = System.getenv("CONDUCTOR_DECISION_PROVIDER");
if (provider != null) decision.input("provider", provider);
Javascript billing = new Javascript("handle_billing",
"({team: 'billing', nextAction: 'review_invoice', "
+ "message: 'Check invoice line items and payment records.', request: $.request})")
.input("request", "${workflow.input.request}");
Javascript technical = new Javascript("handle_technical",
"({team: 'technical', nextAction: 'collect_diagnostics', "
+ "message: 'Collect error logs and steps to reproduce.', request: $.request})")
.input("request", "${workflow.input.request}");
decision.switchCase("billing", billing).switchCase("technical", technical);
Javascript selected = new Javascript("selected_result", "$.billing || $.technical")
.input("billing", "${handle_billing.output.result}")
.input("technical", "${handle_technical.output.result}");
workflow.add(decision);
workflow.add(selected);
workflow.setWorkflowOutput(Map.of(
"selectedCase", "${route_request.output.selectedCase}",
"answers", "${route_request.output.answers}",
"usage", "${route_request.output.usage}",
"cost", "${route_request.output.usage.cost}",
"latencyMs", "${route_request.output.latencyMs}",
"result", "${selected_result.output.result}"));
return workflow;
}

public static void main(String[] args) throws Exception {
String request = args.length == 0 ? "I was charged twice on my latest invoice." : args[0];
ApiClient client = new ApiClient();
WorkflowExecutor executor = new WorkflowExecutor(client, 100);
try {
ConductorWorkflow<Map<String, Object>> workflow = createWorkflow(executor);
workflow.registerWorkflow(true);
Workflow result = executor.executeWorkflow(workflow, Map.of("request", request))
.get(120, TimeUnit.SECONDS);
System.out.println("Execution: " + result.getWorkflowId());
if (result.getStatus() != Workflow.WorkflowStatus.COMPLETED) {
throw new IllegalStateException(result.getStatus() + ": " + result.getReasonForIncompletion());
}
System.out.println(result.getOutput());
} finally {
executor.shutdown();
client.shutdown();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2025 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.conductoross.conductor.ai.examples;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.conductoross.conductor.ai.Agent;
import org.conductoross.conductor.ai.AgentRuntime;
import org.conductoross.conductor.ai.model.AgentHandle;
import org.conductoross.conductor.ai.model.AgentResult;
import org.conductoross.conductor.ai.model.ToolDef;

/** A normal chat agent calling a server-side, branchless decision SWITCH tool. */
public class ExampleDecisionTool {
public static Agent supportAgent() {
Map<String, Object> config = new LinkedHashMap<>();
config.put("model", "jev-1.13");
config.put("questions", Map.of("department", Map.of(
"type", "choice", "instructions", "Choose the correct support department.",
"choices", Map.of("billing", "Payments and invoices",
"technical", "Product errors and troubleshooting"))));
String provider = System.getenv("CONDUCTOR_DECISION_PROVIDER");
if (provider != null) config.put("provider", provider);
ToolDef classify = ToolDef.builder().name("classify_request_java")
.description("Classify the customer request before answering.").toolType("decision")
.inputSchema(Map.of("type", "object", "properties", Map.of("state", Map.of("type", "string")),
"required", List.of("state")))
.config(config).build();
return Agent.builder().name("decision_tool_support_java")
.model(System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini"))
.instructions("Call classify_request_java before answering the customer.")
.tools(classify).build();
}

public static void main(String[] args) {
try (AgentRuntime runtime = new AgentRuntime()) {
AgentHandle handle = runtime.start(supportAgent(), "I was charged twice on my latest invoice.");
System.out.println("Execution: " + handle.getExecutionId());
AgentResult result = handle.waitForResult(120_000, 500);
if (!result.isSuccess()) throw new IllegalStateException(result.getError());
result.printResult();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public class Agent {
private final List<Agent> agents;
private final Strategy strategy;
private final Agent router;
private final ToolDef decisionRouter;
private final List<GuardrailDef> guardrails;
private final int maxTurns;
private final Integer maxTokens;
Expand Down Expand Up @@ -137,6 +138,7 @@ private Agent(Builder builder) {
this.agents = builder.agents != null ? new ArrayList<>(builder.agents) : new ArrayList<>();
this.strategy = builder.strategy != null ? builder.strategy : Strategy.HANDOFF;
this.router = builder.router;
this.decisionRouter = builder.decisionRouter;
this.guardrails = builder.guardrails != null ? new ArrayList<>(builder.guardrails) : new ArrayList<>();
this.maxTurns = builder.maxTurns;
this.maxTokens = builder.maxTokens;
Expand Down Expand Up @@ -198,10 +200,12 @@ private Agent(Builder builder) {
}

/**
* Returns true if this agent is external (no local model — references a deployed workflow).
* Returns true if this agent references a deployed workflow (no model or decision router).
*/
public boolean isExternal() {
return model == null || model.isEmpty();
return (model == null || model.isEmpty())
&& !(strategy == Strategy.ROUTER && decisionRouter != null
&& "decision".equals(decisionRouter.getToolType()));
}

/**
Expand Down Expand Up @@ -264,6 +268,11 @@ public Agent getRouter() {
return router;
}

/** Decision selector configuration; it is evaluated by the server without a worker. */
public ToolDef getDecisionRouter() {
return decisionRouter;
}

public List<GuardrailDef> getGuardrails() {
return guardrails;
}
Expand Down Expand Up @@ -498,6 +507,7 @@ public static class Builder {
private List<Agent> agents;
private Strategy strategy = Strategy.HANDOFF;
private Agent router;
private ToolDef decisionRouter;
private List<GuardrailDef> guardrails;
private int maxTurns = 25;
private Integer maxTokens;
Expand Down Expand Up @@ -608,6 +618,14 @@ public Builder strategy(Strategy strategy) {
/** Set the router agent for ROUTER strategy. */
public Builder router(Agent router) {
this.router = router;
this.decisionRouter = null;
return this;
}

/** Use a decision tool to select a sub-agent without a router LLM or SDK worker. */
public Builder router(ToolDef router) {
this.decisionRouter = router;
this.router = null;
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,28 @@ private Map<String, Object> serializeAgent(Agent agent) {
}

// Router agent (for ROUTER strategy)
if (agent.getRouter() != null) {
if (agent.getDecisionRouter() != null) {
ToolDef router = agent.getDecisionRouter();
if (!"decision".equals(router.getToolType())) {
throw new IllegalArgumentException("Only a decision ToolDef can be used as a router");
}
Map<String, Object> config = router.getConfig();
if (config == null || !(config.get("model") instanceof String model) || model.isBlank()) {
throw new IllegalArgumentException("Decision router tool config requires 'model'");
}
if (!(config.get("questions") instanceof Map<?, ?> questions) || questions.isEmpty()) {
throw new IllegalArgumentException("Decision router tool config requires 'questions'");
}
Map<String, Object> selector = new LinkedHashMap<>();
selector.put("name", router.getName());
selector.put("kind", "decision");
selector.put("model", config.get("model"));
selector.put("questions", config.get("questions"));
if (config.get("provider") != null) {
selector.put("provider", config.get("provider"));
}
agentMap.put("router", selector);
} else if (agent.getRouter() != null) {
agentMap.put("router", serializeAgent(agent.getRouter()));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright 2025 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.conductoross.conductor.ai;

import java.util.Map;

import org.conductoross.conductor.ai.enums.Strategy;
import org.conductoross.conductor.ai.internal.AgentConfigSerializer;
import org.conductoross.conductor.ai.model.ToolDef;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class DecisionRouterTest {
private static final Map<String, Object> QUESTIONS = Map.of("agent", Map.of("type", "choice",
"choices", Map.of("billing", "Payments", "technical", "Troubleshooting")));

private Agent router(ToolDef tool) {
return Agent.builder().name("support").strategy(Strategy.ROUTER).router(tool)
.agents(Agent.builder().name("billing").model("openai/gpt-4o-mini").build())
.synthesize(false).build();
}

@Test
void serializesDecisionSelectorAndKeepsNoModelParentLocal() {
ToolDef tool = ToolDef.builder().name("select").toolType("decision")
.config(Map.of("model", "jev-1.13", "provider", "openrouter", "questions", QUESTIONS)).build();
Agent agent = router(tool);
Map<String, Object> config = new AgentConfigSerializer().serialize(agent);
assertFalse(agent.isExternal());
assertNull(agent.getRouter());
assertSame(tool, agent.getDecisionRouter());
assertEquals(false, config.get("external"));
assertEquals(false, config.get("synthesize"));
assertEquals(Map.of("name", "select", "kind", "decision", "model", "jev-1.13",
"provider", "openrouter", "questions", QUESTIONS), config.get("router"));
}

@Test
void omitsUnsetProviderAndPreservesDecisionTool() {
ToolDef tool = ToolDef.builder().name("classify").toolType("decision")
.config(Map.of("model", "jev-1.13", "questions", QUESTIONS)).build();
Map<String, Object> config = new AgentConfigSerializer().serialize(router(tool));
assertEquals(Map.of("name", "classify", "kind", "decision", "model", "jev-1.13",
"questions", QUESTIONS), config.get("router"));
Agent normal = Agent.builder().name("assistant").model("openai/gpt-4o-mini").tools(tool).build();
Map<?, ?> serializedTool = (Map<?, ?>) ((java.util.List<?>)
new AgentConfigSerializer().serialize(normal).get("tools")).get(0);
assertEquals("decision", serializedTool.get("toolType"));
assertEquals(tool.getConfig(), serializedTool.get("config"));
}

@Test
void rejectsInvalidDecisionSelectors() {
for (Map<String, Object> config : java.util.List.of(Map.<String, Object>of(),
Map.<String, Object>of("model", "", "questions", QUESTIONS),
Map.<String, Object>of("model", "jev-1.13", "questions", Map.of()))) {
ToolDef tool = ToolDef.builder().name("select").toolType("decision").config(config).build();
assertThrows(IllegalArgumentException.class, () -> new AgentConfigSerializer().serialize(router(tool)));
}
ToolDef worker = ToolDef.builder().name("worker").build();
assertThrows(IllegalArgumentException.class, () -> new AgentConfigSerializer().serialize(router(worker)));
}

@Test
void preservesAgentRouterAndExternalReferences() {
Agent selector = Agent.builder().name("selector").model("openai/gpt-4o-mini").build();
Agent agent = Agent.builder().name("support").strategy(Strategy.ROUTER).router(selector)
.agents(selector).build();
assertSame(selector, agent.getRouter());
assertNull(agent.getDecisionRouter());
assertEquals(new AgentConfigSerializer().serialize(selector),
new AgentConfigSerializer().serialize(agent).get("router"));
assertTrue(Agent.builder().name("existing_workflow").build().isExternal());
}
}
Loading
Loading