diff --git a/SDK_NEW_LANGUAGE_GUIDE.md b/SDK_NEW_LANGUAGE_GUIDE.md index dd2dc2db..675e99ed 100644 --- a/SDK_NEW_LANGUAGE_GUIDE.md +++ b/SDK_NEW_LANGUAGE_GUIDE.md @@ -1465,6 +1465,14 @@ an agents layer, mirror that catalog — e.g.: | `agents/03-multi-agent.ts` | Multi-agent orchestration | | `agents/09-human-in-the-loop.ts` | Human-in-the-loop AI | | `agents/04-mcp-weather.ts` | MCP tool integration | +| `ai-decision-routing.ts` | Synchronous decision SWITCH with inline branches | +| `agents/decision-router.ts` | Decision ToolDef selector routes to chat agents | +| `agents/decision-tool.ts` | Chat agent uses a branchless decision SWITCH tool | + +Decision support reuses `switchTask` (`evaluatorType: "decision"`, explicit expression +and model/state/questions inputs) and `ToolDef` (`toolType: "decision"`). Router tools +serialize as `{name, kind: "decision", model, questions, provider?}`; `synthesize: false` +skips the final parent LLM step. No new client methods or builders are required. #### 7.4 API Journey Examples (9 files) diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 25959980..3908ee63 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -15,7 +15,8 @@ Key `AgentOptions` fields: | `tools` | `unknown[]` | `tool()` wrappers, built-in tool defs, framework tools. | | `agents` | `Agent[]` | Sub-agents (multi-agent). | | `strategy` | `Strategy` | `'sequential' \| 'parallel' \| 'handoff' \| 'router' \| 'round_robin' \| 'random' \| 'swarm' \| 'manual' \| 'plan_execute'`. | -| `router` | `Agent \| (() => string)` | Required for `strategy: 'router'`. | +| `router` | `Agent \| ToolDef \| (() => string)` | Required for `strategy: 'router'`. A ToolDef must use `toolType: 'decision'` with `config.model` and nonempty `config.questions`; optional `config.provider` is forwarded. | +| `synthesize` | `boolean` | Default `true`. Set `false` to return specialist output without a final LLM synthesis step. | | `outputType` | Zod schema or JSON Schema | Structured output. | | `guardrails` | `unknown[]` | Guardrail defs / instances. | | `handoffs` | `HandoffCondition[]` | `OnTextMention` / `OnToolResult` / `OnCondition`. | diff --git a/docs/agents/reference/agent-schema.json b/docs/agents/reference/agent-schema.json index 9d88d12f..cf77c47f 100644 --- a/docs/agents/reference/agent-schema.json +++ b/docs/agents/reference/agent-schema.json @@ -5,6 +5,18 @@ "description": "The public wire shape emitted by AgentConfigSerializer.serializeAgent() under agentConfig.", "$ref": "#/$defs/agentConfig", "$defs": { + "decisionRouter": { + "type": "object", + "required": ["name", "kind", "model", "questions"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "kind": { "const": "decision" }, + "model": { "type": "string", "minLength": 1 }, + "provider": { "type": "string" }, + "questions": { "type": "object", "minProperties": 1 } + }, + "additionalProperties": false + }, "taskReference": { "type": "object", "required": ["taskName"], @@ -52,7 +64,8 @@ "tools": { "type": "array", "items": { "$ref": "#/$defs/tool" } }, "agents": { "type": "array", "items": { "$ref": "#/$defs/agentConfig" } }, "strategy": { "type": "string" }, - "router": { "anyOf": [{ "$ref": "#/$defs/taskReference" }, { "$ref": "#/$defs/agentConfig" }] }, + "router": { "anyOf": [{ "$ref": "#/$defs/taskReference" }, { "$ref": "#/$defs/agentConfig" }, { "$ref": "#/$defs/decisionRouter" }] }, + "synthesize": { "type": "boolean" }, "outputType": { "type": "object", "properties": { diff --git a/docs/agents/reference/agent-schema.md b/docs/agents/reference/agent-schema.md index 210bda8c..d3998c12 100644 --- a/docs/agents/reference/agent-schema.md +++ b/docs/agents/reference/agent-schema.md @@ -14,6 +14,11 @@ recursively via the same function. The published allowing intentionally open JSON payloads (tool `inputSchema`/`outputSchema`, `metadata`, guardrail/termination/handoff objects, and per-toolType `config`). +A decision ToolDef in `router` emits `{ name, kind: "decision", model, questions, +provider? }` instead of a nested agent. `model` and nonempty `questions` are required; +an absent provider is omitted. A normal tool emits `toolType: "decision"` with its +config unchanged. `synthesize: false` disables the final specialist synthesis step. + ## Framework-passthrough shapes Two agent kinds bypass the general shape above and emit a minimal diff --git a/examples/README.md b/examples/README.md index 363b3e12..02f84497 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,7 @@ npx ts-node examples/workers-e2e.ts | **[kitchensink.ts](kitchensink.ts)** | All major task types in one workflow | `npx ts-node examples/kitchensink.ts` | | **[workflow-ops.ts](workflow-ops.ts)** | Lifecycle: start, pause, resume, terminate, restart, retry, search | `npx ts-node examples/workflow-ops.ts` | | **[test-workflows.ts](test-workflows.ts)** | Unit testing with mock task outputs (no workers needed) | `npx ts-node examples/test-workflows.ts` | +| **[ai-decision-routing.ts](ai-decision-routing.ts)** | Server-side decision SWITCH routes to inline billing/technical branches | `npx tsx examples/ai-decision-routing.ts` | ### AI Agents @@ -59,6 +60,8 @@ for the full catalog and environment setup (`CONDUCTOR_SERVER_URL`, | **[agents/04-mcp-weather.ts](agents/04-mcp-weather.ts)** | MCP tool discovery + invocation for real-time data | `npx tsx examples/agents/04-mcp-weather.ts` | | **[agents/09-human-in-the-loop.ts](agents/09-human-in-the-loop.ts)** | Agent pauses for human approval mid-run | `npx tsx examples/agents/09-human-in-the-loop.ts` | | **[agents/15-agent-discussion.ts](agents/15-agent-discussion.ts)** | Multi-turn debate between agents with opposing viewpoints | `npx tsx examples/agents/15-agent-discussion.ts` | +| **[agents/decision-router.ts](agents/decision-router.ts)** | Decision ToolDef routes to billing/technical chat agents | `npx tsx examples/agents/decision-router.ts --run` | +| **[agents/decision-tool.ts](agents/decision-tool.ts)** | Chat agent calls a branchless decision tool | `npx tsx examples/agents/decision-tool.ts` | ### Monitoring diff --git a/examples/agents/README.md b/examples/agents/README.md index f4475696..bb0c657c 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -149,6 +149,14 @@ cd examples/agents/openai && npx tsx 01-basic-agent.ts ## Basic Examples +JEV examples require a server with synchronous decision SWITCH support and a configured +decision provider (`CONDUCTOR_DECISION_PROVIDER=openrouter` when using OpenRouter). +Run [decision-router.ts](decision-router.ts) with `--run` to route to a billing or +technical chat agent, or [decision-tool.ts](decision-tool.ts) to call a decision tool +from a chat agent. Both use `CONDUCTOR_AGENT_LLM_MODEL` (default `openai/gpt-4o-mini`) +and need no JavaScript workers. [../ai-decision-routing.ts](../ai-decision-routing.ts) +demonstrates a traditional workflow with inline branches and no chat model. + | # | Example | What it demonstrates | |---|---------|---------------------| | 01 | [Basic Agent](01-basic-agent.ts) | Simplest possible agent — single LLM, no tools | diff --git a/examples/agents/decision-router.ts b/examples/agents/decision-router.ts new file mode 100644 index 00000000..e251e2d5 --- /dev/null +++ b/examples/agents/decision-router.ts @@ -0,0 +1,63 @@ +/** + * Route to billing or technical chat agents using a decision ToolDef selector. + * No JavaScript worker or parent chat model is needed. + * + * CONDUCTOR_SERVER_URL=http://localhost:8081/api CONDUCTOR_DECISION_PROVIDER=openrouter \ + * npx tsx examples/agents/decision-router.ts [--run] + * Without --run, prints the server-compiled plan without inference. + */ +import { Agent, AgentRuntime } from "@io-orkes/conductor-javascript/agents"; +import type { ToolDef } from "@io-orkes/conductor-javascript/agents"; + +export function supportRouter() { + const model = process.env.CONDUCTOR_AGENT_LLM_MODEL ?? "openai/gpt-4o-mini"; + const selector: ToolDef = { + name: "support_selector", + description: "Choose the support agent that should handle the request.", + toolType: "decision", + inputSchema: { type: "object", properties: {} }, + config: { + model: "jev-1.13", + ...(process.env.CONDUCTOR_DECISION_PROVIDER !== undefined && { provider: process.env.CONDUCTOR_DECISION_PROVIDER }), + questions: { + agent: { + type: "choice", + instructions: "Choose the right support team.", + choices: { billing_javascript: "Payments and invoices", technical_javascript: "Product errors and troubleshooting" }, + }, + }, + }, + }; + return new Agent({ + name: "decision_support_router_javascript", + strategy: "router", + router: selector, + agents: [ + new Agent({ name: "billing_javascript", model, instructions: "Help the customer with billing and invoice questions." }), + new Agent({ name: "technical_javascript", model, instructions: "Help the customer troubleshoot product issues." }), + ], + maxTurns: 1, + synthesize: false, + }); +} + +async function main() { + const runtime = new AgentRuntime(); + try { + const agent = supportRouter(); + if (!process.argv.includes("--run")) { + console.log(JSON.stringify(await runtime.plan(agent), null, 2)); + return; + } + await runtime.deploy(agent); + const handle = await runtime.start(agent, "The customer reports a duplicate charge on the latest invoice.", { timeoutSeconds: 120 }); + console.log("Execution:", handle.executionId); + const result = await handle.wait(); + if (!result.isSuccess) throw new Error(`${result.status}: ${result.error}`); + console.log(JSON.stringify(result.output, null, 2)); + } finally { + await runtime.shutdown(); + } +} + +main().catch((error: unknown) => { console.error(error); process.exitCode = 1; }); diff --git a/examples/agents/decision-tool.ts b/examples/agents/decision-tool.ts new file mode 100644 index 00000000..298e4fa7 --- /dev/null +++ b/examples/agents/decision-tool.ts @@ -0,0 +1,52 @@ +/** + * Use a branchless decision SWITCH as a tool inside a normal chat agent. + * The server executes both inference calls; no JavaScript worker is needed. + * + * CONDUCTOR_SERVER_URL=http://localhost:8081/api CONDUCTOR_DECISION_PROVIDER=openrouter \ + * npx tsx examples/agents/decision-tool.ts + */ +import { Agent, AgentRuntime } from "@io-orkes/conductor-javascript/agents"; +import type { ToolDef } from "@io-orkes/conductor-javascript/agents"; + +export const classifyRequest: ToolDef = { + name: "classify_request", + description: "Classify the customer request before answering.", + toolType: "decision", + inputSchema: { + type: "object", + properties: { state: { type: "string" } }, + required: ["state"], + }, + config: { + model: "jev-1.13", + ...(process.env.CONDUCTOR_DECISION_PROVIDER !== undefined && { provider: process.env.CONDUCTOR_DECISION_PROVIDER }), + questions: { + department: { + type: "choice", + instructions: "Choose the correct support department.", + choices: { billing: "Payments and invoices", technical: "Product errors and troubleshooting" }, + }, + }, + }, +}; + +export const supportAgent = new Agent({ + name: "decision_tool_support_javascript", + model: process.env.CONDUCTOR_AGENT_LLM_MODEL ?? "openai/gpt-4o-mini", + instructions: "Call classify_request before answering the customer.", + tools: [classifyRequest], +}); + +async function main() { + const runtime = new AgentRuntime(); + try { + const result = await runtime.run(supportAgent, "I was charged twice on my latest invoice.", { timeoutSeconds: 120 }); + if (!result.isSuccess) throw new Error(`${result.status}: ${result.error}`); + console.log("Execution:", result.executionId); + console.log(JSON.stringify(result.output, null, 2)); + } finally { + await runtime.shutdown(); + } +} + +main().catch((error: unknown) => { console.error(error); process.exitCode = 1; }); diff --git a/examples/ai-decision-routing.ts b/examples/ai-decision-routing.ts new file mode 100644 index 00000000..f2b841f6 --- /dev/null +++ b/examples/ai-decision-routing.ts @@ -0,0 +1,77 @@ +/** + * Route a request with one synchronous, decision-backed SWITCH and inline branches. + * The server evaluates the decision; no JavaScript worker is needed. + * + * CONDUCTOR_SERVER_URL=http://localhost:8081/api CONDUCTOR_DECISION_PROVIDER=openrouter \ + * npx tsx examples/ai-decision-routing.ts [--request "Product crashes on startup"] + */ +import { parseArgs } from "node:util"; +import { setTimeout } from "node:timers/promises"; +import { ConductorWorkflow, OrkesClients, inlineTask, switchTask } from "../src/sdk"; +import type { WorkflowExecutor } from "../src/sdk"; + +export function createWorkflow(executor: WorkflowExecutor, model = "jev-1.13", provider?: string) { + const billing = inlineTask("handle_billing", '({team: "billing", nextAction: "review_invoice", message: "Check invoice line items and payment records.", request: $.request})'); + billing.inputParameters.request = "${workflow.input.request}"; + const technical = inlineTask("handle_technical", '({team: "technical", nextAction: "collect_diagnostics", message: "Collect error logs and steps to reproduce.", request: $.request})'); + technical.inputParameters.request = "${workflow.input.request}"; + + const decision = switchTask("route_request", "route", { billing: [billing], technical: [technical] }); + decision.evaluatorType = "decision"; + decision.expression = "route"; + decision.inputParameters = { + model, + ...(provider !== undefined && { provider }), + state: "${workflow.input.request}", + questions: { + route: { + type: "choice", + instructions: "Choose the team best suited to handle this request.", + choices: { + billing: "Payments, invoices, refunds, or subscriptions.", + technical: "Errors, outages, or product troubleshooting.", + }, + }, + }, + }; + const result = inlineTask("selected_result", "$.billing || $.technical"); + result.inputParameters.billing = "${handle_billing.output.result}"; + result.inputParameters.technical = "${handle_technical.output.result}"; + return new ConductorWorkflow(executor, "ai_decision_routing_javascript", 1) + .add([decision, result]) + .outputParameters({ + 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}", + }); +} + +async function main() { + const { values } = parseArgs({ options: { + request: { type: "string", default: "I was charged twice on my latest invoice." }, + model: { type: "string", default: "jev-1.13" }, + provider: { type: "string", default: process.env.CONDUCTOR_DECISION_PROVIDER }, + } }); + const clients = await OrkesClients.from(); + const executor = clients.getWorkflowClient(); + const workflow = createWorkflow(executor, values.model, values.provider); + await workflow.register(true); + const id = await workflow.startWorkflow({ request: values.request }); + console.log("Workflow:", id); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const run = await executor.getWorkflow(id, false); + if (["COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"].includes(run.status ?? "")) { + if (run.status !== "COMPLETED") throw new Error(`${run.status}: ${run.reasonForIncompletion}`); + console.log(JSON.stringify(run.output, null, 2)); + return; + } + await setTimeout(1000); + } + throw new Error(`Timed out waiting for workflow ${id}`); +} + +main().catch((error: unknown) => { console.error(error); process.exitCode = 1; }); diff --git a/src/agents/__tests__/serializer.test.ts b/src/agents/__tests__/serializer.test.ts index 405ba843..fa5dc400 100644 --- a/src/agents/__tests__/serializer.test.ts +++ b/src/agents/__tests__/serializer.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "@jest/globals"; import { z } from "zod"; import { AgentConfigSerializer } from "../serializer.js"; import { Agent, PromptTemplate, scatterGather } from "../agent.js"; +import type { ToolDef } from "../types.js"; import type { CallbackHandler, TerminationCondition, HandoffCondition } from "../agent.js"; import { tool, @@ -17,6 +18,52 @@ import { const serializer = new AgentConfigSerializer(); +describe("decision tools", () => { + const selector: ToolDef = { + name: "support_selector", + description: "Choose a team", + toolType: "decision", + inputSchema: { type: "object" }, + config: { model: "jev-1.13", questions: { agent: { type: "choice", choices: { billing: "Invoices" } } } }, + }; + const routed = (router: ToolDef) => new Agent({ + name: "support", + strategy: "router", + router, + agents: [new Agent({ name: "billing", model: "openai/gpt-4o-mini" })], + synthesize: false, + }); + + it("serializes a model-less decision router without a worker or implicit provider", () => { + const config = serializer.serializeAgent(routed(selector)); + expect(config.router).toEqual({ name: selector.name, kind: "decision", ...selector.config }); + expect(config.external).toBe(false); + expect(config.synthesize).toBe(false); + expect(config).not.toHaveProperty("model"); + expect(config.router).not.toHaveProperty("provider"); + expect(config.router).not.toHaveProperty("taskName"); + expect(serializer.serializeAgent(new Agent({ name: "chat" }))).not.toHaveProperty("synthesize"); + }); + + it("preserves an explicit provider and ordinary decision tool configuration", () => { + const withProvider = { ...selector, config: { ...selector.config, provider: "openrouter" } }; + expect(serializer.serializeAgent(routed(withProvider)).router).toMatchObject({ provider: "openrouter" }); + const config = serializer.serializeAgent(new Agent({ name: "chat", tools: [withProvider] })); + expect(config.tools).toEqual([{ + name: selector.name, description: selector.description, inputSchema: selector.inputSchema, + toolType: "decision", config: withProvider.config, + }]); + }); + + it.each([ + [{ ...selector, toolType: "http" }, "Only a decision ToolDef"], + [{ ...selector, config: { questions: { agent: {} } } }, "requires 'model'"], + [{ ...selector, config: { model: "jev-1.13", questions: {} } }, "requires 'questions'"], + ])("rejects an invalid router configuration", (router, message) => { + expect(() => serializer.serializeAgent(routed(router as ToolDef))).toThrow(message as string); + }); +}); + // ── serialize() — full payload ───────────────────────────── describe("AgentConfigSerializer.serialize()", () => { diff --git a/src/agents/agent.ts b/src/agents/agent.ts index 5c8d20cc..0f0f16e5 100644 --- a/src/agents/agent.ts +++ b/src/agents/agent.ts @@ -1,4 +1,4 @@ -import type { Strategy, CodeExecutionConfig, CliConfig, PrefillToolCall } from "./types.js"; +import type { Strategy, CodeExecutionConfig, CliConfig, PrefillToolCall, ToolDef } from "./types.js"; import { agentTool } from "./tool.js"; import { ConfigurationError } from "./errors.js"; import { ClaudeCode } from "./claude-code.js"; @@ -94,7 +94,9 @@ export interface AgentOptions { tools?: unknown[]; // Normalized via normalizeToolInput at serialization agents?: Agent[]; strategy?: Strategy; - router?: Agent | ((...args: unknown[]) => string); + router?: Agent | ToolDef | ((...args: unknown[]) => string); + /** Append an LLM synthesis step after specialist agents complete (default true). */ + synthesize?: boolean; outputType?: unknown; // ZodSchema or JSON Schema object guardrails?: unknown[]; memory?: ConversationMemory; @@ -204,7 +206,8 @@ export class Agent { readonly tools: unknown[]; readonly agents: Agent[]; readonly strategy?: Strategy; - readonly router?: Agent | ((...args: unknown[]) => string); + readonly router?: Agent | ToolDef | ((...args: unknown[]) => string); + readonly synthesize: boolean; readonly outputType?: unknown; readonly guardrails: unknown[]; readonly memory?: ConversationMemory; @@ -274,6 +277,7 @@ export class Agent { this.agents = options.agents ?? []; this.strategy = options.strategy; this.router = options.router; + this.synthesize = options.synthesize ?? true; this.outputType = options.outputType; this.guardrails = options.guardrails ?? []; this.memory = options.memory; @@ -381,7 +385,7 @@ export class Agent { if (this.strategy === "router" && !this.router) { throw new ConfigurationError( `Agent '${this.name}' uses strategy='router' but no 'router' parameter was provided. ` + - `Provide an Agent or function as the router.`, + `Provide an Agent, decision ToolDef, or function as the router.`, ); } diff --git a/src/agents/serializer.ts b/src/agents/serializer.ts index 801f8962..e45ae9de 100644 --- a/src/agents/serializer.ts +++ b/src/agents/serializer.ts @@ -9,6 +9,7 @@ import type { } from "./agent.js"; import { normalizeToolInput, isZodSchema } from "./tool.js"; import type { ToolDef } from "./types.js"; +import { ConfigurationError } from "./errors.js"; // ── Wire format types ───────────────────────────────────── @@ -195,6 +196,7 @@ export class AgentConfigSerializer { if (agent.reasoningEffort !== undefined) config.reasoningEffort = agent.reasoningEffort; config.timeoutSeconds = agent.timeoutSeconds; config.external = agent.external; + if (!agent.synthesize) config.synthesize = false; if (agent.stateful) config.stateful = true; // stopWhen @@ -465,7 +467,7 @@ export class AgentConfigSerializer { } private serializeRouter( - router: Agent | ((...args: unknown[]) => string), + router: Agent | ToolDef | ((...args: unknown[]) => string), agentName: string, ): unknown { if (router instanceof Agent) { @@ -474,7 +476,23 @@ export class AgentConfigSerializer { if (typeof router === "function") { return { taskName: `${agentName}_router_fn` }; } - return router; + if (router.toolType !== "decision") { + throw new ConfigurationError("Only a decision ToolDef can be used as a router"); + } + const config = router.config; + if (!config?.model) { + throw new ConfigurationError("Decision router tool config requires 'model'"); + } + if (!config.questions || Object.keys(config.questions).length === 0) { + throw new ConfigurationError("Decision router tool config requires 'questions'"); + } + return { + name: router.name, + kind: "decision", + model: config.model, + questions: config.questions, + ...(config.provider != null && { provider: config.provider }), + }; } private serializeMemory(memory: ConversationMemory): Record { diff --git a/src/agents/types.ts b/src/agents/types.ts index d19e108d..3078e856 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -91,6 +91,7 @@ export interface GuardrailDef { */ export type ToolType = | "worker" + | "decision" | "http" | "api" | "mcp" diff --git a/src/open-api/types.ts b/src/open-api/types.ts index 4248b437..e26e5958 100644 --- a/src/open-api/types.ts +++ b/src/open-api/types.ts @@ -236,7 +236,7 @@ export interface SwitchTaskDef extends CommonTaskDef { type: TaskType.SWITCH; decisionCases: Record; defaultCase: TaskDefTypes[]; - evaluatorType: "value-param" | "javascript"; + evaluatorType: "value-param" | "javascript" | "decision"; expression: string; optional?: boolean; }