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
8 changes: 8 additions & 0 deletions SDK_NEW_LANGUAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion docs/agents/reference/agent-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
15 changes: 14 additions & 1 deletion docs/agents/reference/agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions docs/agents/reference/agent-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions examples/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
63 changes: 63 additions & 0 deletions examples/agents/decision-router.ts
Original file line number Diff line number Diff line change
@@ -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; });
52 changes: 52 additions & 0 deletions examples/agents/decision-tool.ts
Original file line number Diff line number Diff line change
@@ -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; });
77 changes: 77 additions & 0 deletions examples/ai-decision-routing.ts
Original file line number Diff line number Diff line change
@@ -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; });
47 changes: 47 additions & 0 deletions src/agents/__tests__/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()", () => {
Expand Down
12 changes: 8 additions & 4 deletions src/agents/agent.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.`,
);
}

Expand Down
Loading
Loading