From 23d81af76996b48103b1e52f45226718c1c26eb6 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Mon, 19 Oct 2026 15:49:52 -0700 Subject: [PATCH] Add JEV examples --- .../ai/examples/ExampleDecisionRouter.java | 63 ++++++++++++++ .../ai/examples/ExampleDecisionRouting.java | 87 +++++++++++++++++++ .../ai/examples/ExampleDecisionTool.java | 56 ++++++++++++ .../org/conductoross/conductor/ai/Agent.java | 22 ++++- .../ai/internal/AgentConfigSerializer.java | 23 ++++- .../conductor/ai/DecisionRouterTest.java | 86 ++++++++++++++++++ .../sdk/workflow/def/tasks/Switch.java | 17 +++- .../sdk/workflow/def/DecisionSwitchTest.java | 74 ++++++++++++++++ docs/agents/README.md | 19 ++++ docs/agents/reference/agent-definition.md | 2 +- docs/agents/reference/agent-schema.json | 17 +++- 11 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouter.java create mode 100644 agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouting.java create mode 100644 agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionTool.java create mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/DecisionRouterTest.java create mode 100644 conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/def/DecisionSwitchTest.java diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouter.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouter.java new file mode 100644 index 000000000..bc55380a8 --- /dev/null +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Conductor Authors. + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 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(); + } + } +} diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouting.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouting.java new file mode 100644 index 000000000..5cc672d40 --- /dev/null +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouting.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Conductor Authors. + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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> createWorkflow(WorkflowExecutor executor) { + ConductorWorkflow> 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> 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(); + } + } +} diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionTool.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionTool.java new file mode 100644 index 000000000..c47bf4a90 --- /dev/null +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionTool.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Conductor Authors. + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 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(); + } + } +} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/Agent.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/Agent.java index af2ceaf00..9f16b425f 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/Agent.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/Agent.java @@ -63,6 +63,7 @@ public class Agent { private final List agents; private final Strategy strategy; private final Agent router; + private final ToolDef decisionRouter; private final List guardrails; private final int maxTurns; private final Integer maxTokens; @@ -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; @@ -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())); } /** @@ -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 getGuardrails() { return guardrails; } @@ -498,6 +507,7 @@ public static class Builder { private List agents; private Strategy strategy = Strategy.HANDOFF; private Agent router; + private ToolDef decisionRouter; private List guardrails; private int maxTurns = 25; private Integer maxTokens; @@ -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; } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java index 34d7808db..372952344 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java @@ -211,7 +211,28 @@ private Map 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 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 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())); } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/DecisionRouterTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/DecisionRouterTest.java new file mode 100644 index 000000000..74a8dfa3d --- /dev/null +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/DecisionRouterTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Conductor Authors. + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 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 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 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 config : java.util.List.of(Map.of(), + Map.of("model", "", "questions", QUESTIONS), + Map.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()); + } +} diff --git a/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Switch.java b/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Switch.java index 14c9d42a1..a4606fc29 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Switch.java +++ b/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Switch.java @@ -28,6 +28,10 @@ public class Switch extends Task { public static final String JAVASCRIPT_NAME = "javascript"; + public static final String DECISION_NAME = "decision"; + + private String evaluatorType; + private String caseExpression; private boolean useJavascript; @@ -65,6 +69,8 @@ public Switch(String taskReferenceName, String caseExpression) { Switch(WorkflowTask workflowTask) { super(workflowTask); + this.evaluatorType = workflowTask.getEvaluatorType(); + this.caseExpression = workflowTask.getExpression(); Map> decisions = workflowTask.getDecisionCases(); decisions.entrySet().stream() @@ -85,6 +91,12 @@ public Switch(String taskReferenceName, String caseExpression) { } } + /** Set an explicit server evaluator, for example {@link #DECISION_NAME}. */ + public Switch evaluatorType(String evaluatorType) { + this.evaluatorType = evaluatorType; + return this; + } + public Switch defaultCase(Task... tasks) { defaultTasks = Arrays.asList(tasks); return this; @@ -133,7 +145,10 @@ public Map>> getBranches() { @Override public void updateWorkflowTask(WorkflowTask workflowTask) { - if (useJavascript) { + if (evaluatorType != null) { + workflowTask.setEvaluatorType(evaluatorType); + workflowTask.setExpression(caseExpression); + } else if (useJavascript) { workflowTask.setEvaluatorType(JAVASCRIPT_NAME); workflowTask.setExpression(caseExpression); diff --git a/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/def/DecisionSwitchTest.java b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/def/DecisionSwitchTest.java new file mode 100644 index 000000000..3b3522163 --- /dev/null +++ b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/def/DecisionSwitchTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2022 Conductor Authors. + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 com.netflix.conductor.sdk.workflow.def; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; +import com.netflix.conductor.sdk.workflow.def.tasks.Javascript; +import com.netflix.conductor.sdk.workflow.def.tasks.Switch; +import com.netflix.conductor.sdk.workflow.def.tasks.TaskRegistry; +import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor; + +import static org.junit.jupiter.api.Assertions.*; + +class DecisionSwitchTest { + @Test + void preservesExplicitDecisionEvaluatorAndQuestions() { + 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"))) + .switchCase("billing", new Javascript("billing", "'invoice'")); + WorkflowTask task = decision.getWorkflowDefTasks().get(0); + assertEquals("SWITCH", task.getType()); + assertEquals("decision", task.getEvaluatorType()); + assertEquals("route", task.getExpression()); + assertFalse(task.getInputParameters().containsKey("switchCaseValue")); + assertEquals("jev-1.13", task.getInputParameters().get("model")); + assertEquals(1, task.getDecisionCases().get("billing").size()); + assertTrue(task.getDefaultCase().isEmpty()); + } + + @Test + void roundTripsDecisionAndLegacySwitchesThroughTaskRegistry() { + WorkflowExecutor.initTaskImplementations(); + for (Switch original : java.util.List.of( + new Switch("decision", "route").evaluatorType("decision") + .input("model", "jev-1.13").switchCase("billing", new Javascript("handle", "'invoice'")), + new Switch("value", "${workflow.input.route}"), + new Switch("javascript", "$.route", true))) { + WorkflowTask before = original.getWorkflowDefTasks().get(0); + WorkflowTask after = TaskRegistry.getTask(before).getWorkflowDefTasks().get(0); + assertEquals(before.getEvaluatorType(), after.getEvaluatorType()); + assertEquals(before.getExpression(), after.getExpression()); + assertEquals(before.getInputParameters(), after.getInputParameters()); + assertEquals(before.getDecisionCases(), after.getDecisionCases()); + } + } + + @Test + void preservesLegacyValueAndJavascriptSwitches() { + WorkflowTask value = new Switch("route", "${workflow.input.route}").getWorkflowDefTasks().get(0); + assertEquals("value-param", value.getEvaluatorType()); + assertEquals("switchCaseValue", value.getExpression()); + assertEquals("${workflow.input.route}", value.getInputParameters().get("switchCaseValue")); + WorkflowTask javascript = new Switch("route", "$.route", true).getWorkflowDefTasks().get(0); + assertEquals("javascript", javascript.getEvaluatorType()); + assertEquals("$.route", javascript.getExpression()); + WorkflowTask explicit = new Switch("route", "route", true).evaluatorType("decision") + .getWorkflowDefTasks().get(0); + assertEquals("decision", explicit.getEvaluatorType()); + } +} diff --git a/docs/agents/README.md b/docs/agents/README.md index 85f4e36d1..15d45bca6 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -31,6 +31,25 @@ Replace `` with a published version from [Maven Central](https://search - **[Multi-Agent](concepts/multi-agent.md)** — sequential, parallel, handoff, swarm, and plan-execute agents. - **[Guardrails](concepts/guardrails.md)**, **[Termination](concepts/termination.md)**, **[Callbacks](concepts/callbacks.md)**, **[Stateful Agents](concepts/stateful.md)**, **[Streaming & Human-in-the-Loop](concepts/streaming-hitl.md)**, and **[Structured Output](concepts/structured-output.md)**. +## Decision inference examples + +These examples require a server with the synchronous decision evaluator and server-managed Decision credentials. No Java decision worker is needed. The router and tool examples also require a configured chat provider. Set `CONDUCTOR_DECISION_PROVIDER=openrouter` when using OpenRouter and optionally `CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini`. + +- [Traditional workflow](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouting.java): one decision-backed SWITCH chooses billing or technical INLINE handling and returns the selected result, answers, usage, cost, and latency. +- [Decision router](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionRouter.java): a decision `ToolDef` selects a child chat agent; `synthesize(false)` returns its result directly. +- [Decision tool](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/ExampleDecisionTool.java): a chat agent calls a branchless decision SWITCH tool before answering. + +```shell +export CONDUCTOR_SERVER_URL=http://localhost:8080/api +export CONDUCTOR_DECISION_PROVIDER=openrouter +./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.ExampleDecisionRouting +./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.ExampleDecisionRouting --args='"The product crashes on startup with error 500. Help troubleshoot it."' +./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.ExampleDecisionRouter --args=--run +./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.ExampleDecisionTool +``` + +The router prints its server compilation plan unless `--run` is passed. Workflow and agent names end in `_java` so these examples can coexist with other SDKs. The traditional task uses `.evaluatorType(Switch.DECISION_NAME)` with `model`, optional `provider`, `state`, and a choice question whose name is the SWITCH expression. Its choice keys match the decision-case keys. The server evaluates it synchronously; provider failures fail the workflow without task retries. + ## Framework bridges - **[Google ADK](frameworks/google-adk.md)** — bridge native ADK agents and sub-agent graphs. diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index c39fca84d..2e8635eff 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -15,7 +15,7 @@ below lists its builder method, the JSON key it serializes to, and any behavior | `tools` | `tools(ToolDef...)` | `tools` | List of tool configs. | | `agents` | `agents(Agent...)` | `agents` | Sub-agents (recursive). Requires a strategy. | | `strategy` | `strategy(Strategy)` | `strategy` | Emitted only when sub-agents or planner/fallback slots are present. Default `HANDOFF`. | -| `router` | `router(Agent)` | `router` | For `ROUTER` strategy. Nested agent config. | +| `router` | `router(Agent)` or `router(ToolDef)` | `router` | For `ROUTER` strategy. Nested agent config or decision tool selector (`toolType="decision"`, nonempty `config.model` and `config.questions`, optional `config.provider`). | | `guardrails` | `guardrails(GuardrailDef...)` | `guardrails` | Input/output guardrail configs. | | `maxTurns` | `maxTurns(int)` | `maxTurns` | Default 25. Emitted only if > 0. | | `maxTokens` | `maxTokens(int)` | `maxTokens` | LLM token cap. | diff --git a/docs/agents/reference/agent-schema.json b/docs/agents/reference/agent-schema.json index 5f46e0da7..b9e1f8f7d 100644 --- a/docs/agents/reference/agent-schema.json +++ b/docs/agents/reference/agent-schema.json @@ -55,10 +55,11 @@ "description": "Multi-agent orchestration strategy. Null/absent for a single agent." }, "router": { - "description": "ROUTER strategy router: a nested agent or a worker-task reference.", + "description": "ROUTER strategy router: a nested agent, worker-task reference, or decision selector.", "oneOf": [ { "$ref": "#" }, - { "$ref": "#/$defs/workerRef" } + { "$ref": "#/$defs/workerRef" }, + { "$ref": "#/$defs/decisionRouter" } ] }, "guardrails": { @@ -306,6 +307,18 @@ "taskName": { "type": "string" } } }, + "decisionRouter": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "model", "questions"], + "properties": { + "name": { "type": "string" }, + "kind": { "const": "decision" }, + "model": { "type": "string", "minLength": 1 }, + "provider": { "type": "string" }, + "questions": { "type": "object", "minProperties": 1, "additionalProperties": { "type": "object" } } + } + }, "workerRef": { "type": "object", "additionalProperties": false,