From de8a4f6fb8b6aebf2d1d84d9c8af62b99f39bc93 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Wed, 23 Sep 2026 21:30:09 -0700 Subject: [PATCH 01/14] feat(agents): add server-backed DecisionModelTool --- docs/agents/concepts/decision-models.md | 61 ++++++++++++ src/conductor/ai/agents/__init__.py | 3 + src/conductor/ai/agents/decision.py | 110 ++++++++++++++++++++++ tests/unit/ai/test_decision_model_tool.py | 57 +++++++++++ 4 files changed, 231 insertions(+) create mode 100644 docs/agents/concepts/decision-models.md create mode 100644 src/conductor/ai/agents/decision.py create mode 100644 tests/unit/ai/test_decision_model_tool.py diff --git a/docs/agents/concepts/decision-models.md b/docs/agents/concepts/decision-models.md new file mode 100644 index 00000000..e87cc70f --- /dev/null +++ b/docs/agents/concepts/decision-models.md @@ -0,0 +1,61 @@ +# Server decision tools + +`DecisionModelTool` exposes Conductor's `DECISION_MODEL` task to an agent. The +server owns provider credentials, inference, response validation, and usage +reporting. The SDK sends tool configuration and starts no Python tool worker. +This requires a server that supports the `decision_model` tool type. + +```python +from conductor.ai.agents import Agent, AgentRuntime, DecisionModelTool, agent + +class Support: + @agent( + model="openai/gpt-4o-mini", + tools=[DecisionModelTool( + name="choose_team", + description="Choose the team responsible for a support issue.", + provider="jev", + model="jev-1.13", + questions={"team": { + "type": "choice", + "instructions": "Which team should handle the issue?", + "choices": {"billing": "Payment issues", "technical": "Software issues"}, + }}, + max_calls=1, + )], + ) + def assistant(self): + """Call choose_team with the supplied state and report its decision.""" + +with AgentRuntime() as runtime: + result = runtime.run(Agent.from_instance(Support(), "assistant"), + "My invoice was charged twice.", timeout=120) +``` + +Select an orchestration model configured on your server. It handles conversation +and tool calling; the decision model evaluates state. Both can incur inference +costs. The Jev provider and its API key must be configured on the server, not in +the agent's environment, prompt, or tool arguments. + +Provider and model are fixed tool configuration. With fixed `questions`, only +`state` is exposed as a tool argument. Omitting `questions` exposes a typed +questions schema too. Generated arguments cannot override the configured provider, +model, or fixed questions. + +Questions have `type` and `instructions`: + +| Type | Additional fields | Answer | +| --- | --- | --- | +| `choice` | `choices`: 2–255 named options with descriptions | `choice`: one of the option names | +| `score` | `scale`: 2–10 ordered descriptions | `score`: number from 0 to scale length minus one | +| `boolean` | None | `probability`: number from 0 to 1 | + +The server returns `model`, `answers`, `usage`, `latencyMs`, and optional +`requestId`. Usage fields are `inputTokens`, `outputTokens`, and optional `cost` +and `currency`. Unknown costs are omitted, not reported as zero. Confidence is +optional per answer. Read the decision task's output for exact values; the agent's +final conversational summary is model-generated. + +The compiled tool task has zero retries by default. A caller explicitly rerunning +a workflow can still trigger another inference request. No Jev-specific HTTP or +authentication code belongs in the SDK. diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index fce5b308..ad053116 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -51,6 +51,8 @@ def get_weather(city: str) -> str: ServerlessCodeExecutor, ) +from conductor.ai.agents.decision import DecisionModelTool + # Exceptions from conductor.ai.agents.exceptions import ( AgentAPIError, @@ -232,6 +234,7 @@ def resolve_credentials(task: object, names: list) -> dict: from conductor.ai.agents.tracing import is_tracing_enabled __all__ = [ + "DecisionModelTool", # OpenAI Agents SDK compatibility "Runner", "RunResult", diff --git a/src/conductor/ai/agents/decision.py b/src/conductor/ai/agents/decision.py new file mode 100644 index 00000000..b5f36f64 --- /dev/null +++ b/src/conductor/ai/agents/decision.py @@ -0,0 +1,110 @@ +"""Server-executed decision tools. Provider transport and credentials stay on Conductor.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Dict, Optional + +from conductor.ai.agents.tool import ToolDef + + +class DecisionModelTool(ToolDef): + """Expose a server decision provider as an agent tool without a Python worker. + + ``provider`` and ``model`` are fixed configuration, not model-generated tool + arguments. When ``questions`` is supplied, only ``state`` is exposed to the + orchestration model. Otherwise the caller supplies both state and questions. + + Question types are ``choice`` (named ``choices``), ``score`` (ordered + ``scale``), and ``boolean`` (a probability). Each has ``instructions``. + Jev-specific wire formats are translated by the server implementation. + + Requires a Conductor server supporting the ``DECISION_MODEL`` task and + ``decision_model`` tool type. This does not register provider credentials. + """ + + def __init__( + self, + name: str, + description: str, + *, + provider: str, + model: str, + questions: Optional[Dict[str, Any]] = None, + max_calls: Optional[int] = None, + ) -> None: + if not isinstance(provider, str) or not provider.strip(): + raise ValueError("provider must be a nonempty server provider name") + if not isinstance(model, str) or not model.strip(): + raise ValueError("model must be nonempty") + if questions is not None and (not isinstance(questions, dict) or not questions): + raise ValueError("questions must be a nonempty mapping when supplied") + config: Dict[str, Any] = {"provider": provider, "model": model} + properties: Dict[str, Any] = { + "state": {"type": "string", "description": "Observed state to evaluate."}, + } + required = ["state"] + if questions is not None: + config["questions"] = deepcopy(questions) + else: + properties["questions"] = { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "oneOf": [ + _question_schema( + "choice", + "choices", + { + "type": "object", + "minProperties": 2, + "maxProperties": 255, + "additionalProperties": {"type": "string", "minLength": 1}, + }, + ), + _question_schema( + "score", + "scale", + { + "type": "array", + "minItems": 2, + "maxItems": 10, + "items": {"type": "string", "minLength": 1}, + }, + ), + _question_schema("boolean"), + ], + }, + } + required.append("questions") + super().__init__( + name=name, + description=description, + input_schema={ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + tool_type="decision_model", + config=config, + max_calls=max_calls, + retry_count=0, + ) + + +def _question_schema(kind: str, field: str = "", schema: Optional[dict] = None) -> dict: + properties = { + "type": {"type": "string", "enum": [kind]}, + "instructions": {"type": "string", "minLength": 1}, + } + required = ["type", "instructions"] + if field: + properties[field] = schema + required.append(field) + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } diff --git a/tests/unit/ai/test_decision_model_tool.py b/tests/unit/ai/test_decision_model_tool.py new file mode 100644 index 00000000..5a0957a9 --- /dev/null +++ b/tests/unit/ai/test_decision_model_tool.py @@ -0,0 +1,57 @@ +from conductor.ai.agents import Agent, DecisionModelTool, agent +from conductor.ai.agents.config_serializer import AgentConfigSerializer + + +def test_fixed_questions_are_configuration_and_require_no_worker(): + questions = { + "team": { + "type": "choice", + "instructions": "Select team", + "choices": {"billing": "Payments", "support": "Technical help"}, + } + } + tool = DecisionModelTool( + "decide", "Decide team", provider="jev", model="jev-1.13", questions=questions, max_calls=1 + ) + assert tool.func is None + assert tool.credentials == [] + questions["team"]["instructions"] = "mutated" + + class Example: + @agent(model="openai/configured-model", tools=[tool]) + def assistant(self): + """Use the decision tool.""" + + definition = Agent.from_instance(Example(), "assistant") + data = AgentConfigSerializer().serialize(definition)["tools"][0] + assert data["toolType"] == "decision_model" + assert data["config"]["provider"] == "jev" + assert data["config"]["questions"]["team"]["instructions"] == "Select team" + assert set(data["inputSchema"]["properties"]) == {"state"} + assert data["maxCalls"] == 1 + assert "credentials" not in data["config"] + + +def test_dynamic_questions_use_provider_neutral_schema(): + tool = DecisionModelTool("decide", "Evaluate questions", provider="another", model="v1") + assert tool.input_schema["required"] == ["state", "questions"] + variants = tool.input_schema["properties"]["questions"]["additionalProperties"]["oneOf"] + assert [v["properties"]["type"]["enum"] for v in variants] == [ + ["choice"], + ["score"], + ["boolean"], + ] + assert "provider" not in tool.input_schema["properties"] + assert "model" not in tool.input_schema["properties"] + assert tool.config == {"provider": "another", "model": "v1"} + + +def test_invalid_config_fails_locally(): + import pytest + + with pytest.raises(ValueError): + DecisionModelTool("decide", "Test", provider="", model="v1") + with pytest.raises(ValueError): + DecisionModelTool("decide", "Test", provider="jev", model="") + with pytest.raises(ValueError): + DecisionModelTool("decide", "Test", provider="jev", model="v1", questions={}) From a19a581820f8a99cc1f7c0d51f1686da4ff6f49c Mon Sep 17 00:00:00 2001 From: nicholascole Date: Wed, 23 Sep 2026 21:36:44 -0700 Subject: [PATCH 02/14] Trim decision tool documentation and tidy imports --- docs/agents/concepts/decision-models.md | 65 +++++++---------------- src/conductor/ai/agents/__init__.py | 1 - src/conductor/ai/agents/decision.py | 14 ++--- tests/unit/ai/test_decision_model_tool.py | 4 +- 4 files changed, 23 insertions(+), 61 deletions(-) diff --git a/docs/agents/concepts/decision-models.md b/docs/agents/concepts/decision-models.md index e87cc70f..ac06c328 100644 --- a/docs/agents/concepts/decision-models.md +++ b/docs/agents/concepts/decision-models.md @@ -1,61 +1,32 @@ -# Server decision tools +# Decision tools -`DecisionModelTool` exposes Conductor's `DECISION_MODEL` task to an agent. The -server owns provider credentials, inference, response validation, and usage -reporting. The SDK sends tool configuration and starts no Python tool worker. -This requires a server that supports the `decision_model` tool type. +`DecisionModelTool` calls the server's `DECISION_MODEL` task. Configure the +provider credential on Conductor; no Python worker is needed. ```python from conductor.ai.agents import Agent, AgentRuntime, DecisionModelTool, agent class Support: - @agent( - model="openai/gpt-4o-mini", - tools=[DecisionModelTool( - name="choose_team", - description="Choose the team responsible for a support issue.", - provider="jev", - model="jev-1.13", - questions={"team": { - "type": "choice", - "instructions": "Which team should handle the issue?", - "choices": {"billing": "Payment issues", "technical": "Software issues"}, - }}, - max_calls=1, - )], - ) + @agent(model="openai/gpt-4o-mini", tools=[DecisionModelTool( + "choose_team", "Choose the responsible support team.", + provider="jev", model="jev-1.13", + questions={"team": { + "type": "choice", "instructions": "Which team should handle this?", + "choices": {"billing": "Payment issues", "technical": "Software issues"}, + }}, + )]) def assistant(self): - """Call choose_team with the supplied state and report its decision.""" + """Call choose_team and report its decision.""" with AgentRuntime() as runtime: result = runtime.run(Agent.from_instance(Support(), "assistant"), "My invoice was charged twice.", timeout=120) ``` -Select an orchestration model configured on your server. It handles conversation -and tool calling; the decision model evaluates state. Both can incur inference -costs. The Jev provider and its API key must be configured on the server, not in -the agent's environment, prompt, or tool arguments. +Provider, model, and supplied questions are fixed configuration. Omitting +`questions` lets the agent supply them. Types: `choice` with named `choices`, +`score` with an ordered `scale`, and `boolean` returning a probability. -Provider and model are fixed tool configuration. With fixed `questions`, only -`state` is exposed as a tool argument. Omitting `questions` exposes a typed -questions schema too. Generated arguments cannot override the configured provider, -model, or fixed questions. - -Questions have `type` and `instructions`: - -| Type | Additional fields | Answer | -| --- | --- | --- | -| `choice` | `choices`: 2–255 named options with descriptions | `choice`: one of the option names | -| `score` | `scale`: 2–10 ordered descriptions | `score`: number from 0 to scale length minus one | -| `boolean` | None | `probability`: number from 0 to 1 | - -The server returns `model`, `answers`, `usage`, `latencyMs`, and optional -`requestId`. Usage fields are `inputTokens`, `outputTokens`, and optional `cost` -and `currency`. Unknown costs are omitted, not reported as zero. Confidence is -optional per answer. Read the decision task's output for exact values; the agent's -final conversational summary is model-generated. - -The compiled tool task has zero retries by default. A caller explicitly rerunning -a workflow can still trigger another inference request. No Jev-specific HTTP or -authentication code belongs in the SDK. +Requires a server with [decision-model support](https://github.com/conductor-oss/conductor/pull/1664). +The chat model handles orchestration; Jev evaluates state. Both incur inference +costs. Read the decision task's output for exact answers and usage. diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index ad053116..7ceb42bf 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -50,7 +50,6 @@ def get_weather(city: str) -> str: LocalCodeExecutor, ServerlessCodeExecutor, ) - from conductor.ai.agents.decision import DecisionModelTool # Exceptions diff --git a/src/conductor/ai/agents/decision.py b/src/conductor/ai/agents/decision.py index b5f36f64..10c2ca42 100644 --- a/src/conductor/ai/agents/decision.py +++ b/src/conductor/ai/agents/decision.py @@ -9,18 +9,10 @@ class DecisionModelTool(ToolDef): - """Expose a server decision provider as an agent tool without a Python worker. + """A server decision tool with fixed provider and model. - ``provider`` and ``model`` are fixed configuration, not model-generated tool - arguments. When ``questions`` is supplied, only ``state`` is exposed to the - orchestration model. Otherwise the caller supplies both state and questions. - - Question types are ``choice`` (named ``choices``), ``score`` (ordered - ``scale``), and ``boolean`` (a probability). Each has ``instructions``. - Jev-specific wire formats are translated by the server implementation. - - Requires a Conductor server supporting the ``DECISION_MODEL`` task and - ``decision_model`` tool type. This does not register provider credentials. + Supply ``questions`` to expose only ``state`` as a tool argument. Otherwise + the agent supplies both. Requires server support for ``DECISION_MODEL``. """ def __init__( diff --git a/tests/unit/ai/test_decision_model_tool.py b/tests/unit/ai/test_decision_model_tool.py index 5a0957a9..4570ada3 100644 --- a/tests/unit/ai/test_decision_model_tool.py +++ b/tests/unit/ai/test_decision_model_tool.py @@ -1,3 +1,5 @@ +import pytest + from conductor.ai.agents import Agent, DecisionModelTool, agent from conductor.ai.agents.config_serializer import AgentConfigSerializer @@ -47,8 +49,6 @@ def test_dynamic_questions_use_provider_neutral_schema(): def test_invalid_config_fails_locally(): - import pytest - with pytest.raises(ValueError): DecisionModelTool("decide", "Test", provider="", model="v1") with pytest.raises(ValueError): From aaf0cf8f2b3c898fca1eecab055ea9bbd1af0897 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Wed, 23 Sep 2026 21:47:59 -0700 Subject: [PATCH 03/14] Remove duplicate decision model documentation --- docs/agents/concepts/decision-models.md | 32 ------------------------- 1 file changed, 32 deletions(-) delete mode 100644 docs/agents/concepts/decision-models.md diff --git a/docs/agents/concepts/decision-models.md b/docs/agents/concepts/decision-models.md deleted file mode 100644 index ac06c328..00000000 --- a/docs/agents/concepts/decision-models.md +++ /dev/null @@ -1,32 +0,0 @@ -# Decision tools - -`DecisionModelTool` calls the server's `DECISION_MODEL` task. Configure the -provider credential on Conductor; no Python worker is needed. - -```python -from conductor.ai.agents import Agent, AgentRuntime, DecisionModelTool, agent - -class Support: - @agent(model="openai/gpt-4o-mini", tools=[DecisionModelTool( - "choose_team", "Choose the responsible support team.", - provider="jev", model="jev-1.13", - questions={"team": { - "type": "choice", "instructions": "Which team should handle this?", - "choices": {"billing": "Payment issues", "technical": "Software issues"}, - }}, - )]) - def assistant(self): - """Call choose_team and report its decision.""" - -with AgentRuntime() as runtime: - result = runtime.run(Agent.from_instance(Support(), "assistant"), - "My invoice was charged twice.", timeout=120) -``` - -Provider, model, and supplied questions are fixed configuration. Omitting -`questions` lets the agent supply them. Types: `choice` with named `choices`, -`score` with an ordered `scale`, and `boolean` returning a probability. - -Requires a server with [decision-model support](https://github.com/conductor-oss/conductor/pull/1664). -The chat model handles orchestration; Jev evaluates state. Both incur inference -costs. Read the decision task's output for exact answers and usage. From 7e908e918b2fcd0f8b8c944284d1b0ea3e9c3f78 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 12:25:25 -0700 Subject: [PATCH 04/14] feat(agents): replace decision model API with JevAgent --- docs/agents/reference/agent-definition.md | 24 +++ docs/agents/reference/agent-schema.json | 28 +++ examples/agents/README.md | 17 ++ examples/agents/jev_agent.py | 63 ++++++ src/conductor/ai/agents/__init__.py | 16 +- src/conductor/ai/agents/agent.py | 41 +++- src/conductor/ai/agents/config_serializer.py | 22 +- src/conductor/ai/agents/decision.py | 102 --------- src/conductor/ai/agents/jev.py | 143 +++++++++++++ src/conductor/ai/agents/result.py | 26 ++- src/conductor/ai/agents/runtime/runtime.py | 76 ++++++- tests/unit/ai/test_agent_schema_contract.py | 19 ++ tests/unit/ai/test_decision_model_tool.py | 57 ----- tests/unit/ai/test_jev_agent.py | 206 +++++++++++++++++++ 14 files changed, 666 insertions(+), 174 deletions(-) create mode 100644 examples/agents/jev_agent.py delete mode 100644 src/conductor/ai/agents/decision.py create mode 100644 src/conductor/ai/agents/jev.py delete mode 100644 tests/unit/ai/test_decision_model_tool.py create mode 100644 tests/unit/ai/test_jev_agent.py diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index fe630aa9..01ad2ade 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -10,3 +10,27 @@ Names must match `^[a-zA-Z_][a-zA-Z0-9_-]*$`. Empty models represent inherited o external-agent behavior. The complete constructor and serialization semantics are maintained in [api-reference.md](../api-reference.md) and `AgentConfigSerializer`; use those sources when adding a newly supported field. + +## Jev agents + +Use `JevAgent(name="jev_support_agent", model="jev-1.13", questions=questions)` +or `AgentDef(name="jev_support_agent", kind="jev", model="jev-1.13", questions=questions)`. +Both serialize through `AgentConfigSerializer` with `kind: "jev"` and use the +standard agent runtime APIs. Jev credentials and provider HTTP calls stay on +Conductor. A chat model and Python worker are not required. + +Each question requires `instructions`. Use `ChoiceQuestion(instructions, choices)` +for a choices map, `ScoreQuestion(instructions, scale)` for an ordered scale, or +`BooleanQuestion(instructions)` for a probability response. Plain dictionaries +with `type` and the corresponding fields are also accepted. Omit definition +questions to supply them in `context={"questions": questions}` for each run. + +`runtime.plan(agent, prompt, context=context)` calls `/agent/compile` without +inference. `runtime.start(agent, prompt, context=context)` calls `/agent/start`; +`handle.join()` polls status until complete. Check `result.is_success` and report +`result.error` on failure. `result.output["result"]` retains the structured +`model`, `answers`, `usage`, `latencyMs`, and optional `requestId` fields. + +See [the runnable Jev example](../../../examples/agents/jev_agent.py), which +compiles by default and requires `--run` to start inference. Jev is supported +only as an agent definition; there is no public Jev task or decision-model tool. diff --git a/docs/agents/reference/agent-schema.json b/docs/agents/reference/agent-schema.json index 12656b47..55d63f9a 100644 --- a/docs/agents/reference/agent-schema.json +++ b/docs/agents/reference/agent-schema.json @@ -34,12 +34,40 @@ }, "additionalProperties": true }, + "jevQuestion": { + "type": "object", + "required": ["type", "instructions"], + "properties": { + "type": { "enum": ["choice", "score", "boolean"] }, + "instructions": { "type": "string", "pattern": "\\S" }, + "choices": { + "type": "object", "minProperties": 2, "maxProperties": 255, + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "scale": { + "type": "array", "minItems": 2, "maxItems": 10, + "items": { "type": "string", "minLength": 1 } + } + }, + "oneOf": [ + { "properties": { "type": { "const": "choice" } }, "required": ["choices"], "not": { "required": ["scale"] } }, + { "properties": { "type": { "const": "score" } }, "required": ["scale"], "not": { "required": ["choices"] } }, + { "properties": { "type": { "const": "boolean" } }, "not": { "anyOf": [{ "required": ["choices"] }, { "required": ["scale"] }] } } + ], + "additionalProperties": false + }, "agentConfig": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$" }, "model": { "type": ["string", "null"] }, + "kind": { "const": "jev" }, + "questions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/jevQuestion" } + }, "baseUrl": { "type": ["string", "null"] }, "strategy": { "type": ["string", "null"] }, "maxTurns": { "type": ["integer", "null"], "minimum": 0 }, diff --git a/examples/agents/README.md b/examples/agents/README.md index 41bae16e..3091d0ef 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -26,3 +26,20 @@ the Conductor CLI manages the server with `conductor server start`. Framework-specific examples are in [ADK](adk/README.md), [LangGraph](langgraph/README.md), and [OpenAI Agents SDK](openai/README.md). Review tool side effects before using real credentials. + +## Jev agents + +`python examples/agents/jev_agent.py` (from the repository root) compiles the +`jev_support_agent` definition without inference. Pass `--run` to explicitly start +it and poll for completion. The example uses `JevAgent` and `AgentRuntime`, requires +no chat model or Python worker, and prints structured `output.result` data: +`model`, `answers`, `usage`, `latencyMs`, and optional `requestId`. +Configure Jev credentials only on Conductor; the SDK calls the agent APIs. + +The equivalent generic definition is +`AgentDef(name="jev_support_agent", kind="jev", model="jev-1.13", questions=questions)`. +Both forms support `runtime.plan()`, `runtime.start()`, and deployment. Questions +require `instructions`: `ChoiceQuestion` uses a choices map, `ScoreQuestion` uses +an ordered scale, and `BooleanQuestion` returns a probability. If questions are +omitted from the definition, pass `context={"questions": questions}` to `plan()` +or `start()`. There is no public Jev tool or standalone decision-model API. diff --git a/examples/agents/jev_agent.py b/examples/agents/jev_agent.py new file mode 100644 index 00000000..0eedf0dc --- /dev/null +++ b/examples/agents/jev_agent.py @@ -0,0 +1,63 @@ +"""Compile a Jev agent; pass --run to explicitly start inference. + + python examples/agents/jev_agent.py + python examples/agents/jev_agent.py --run + +Requires Conductor at http://localhost:8080/api (override CONDUCTOR_SERVER_URL). +Jev credentials and provider HTTP calls belong on the server. No chat model or +Python worker is required. View executions in the UI at http://localhost:1234. +""" + +import argparse +import json +import os + +from conductor.ai.agents import AgentRuntime, ChoiceQuestion, JevAgent +from conductor.client.configuration.configuration import Configuration + +PROMPT = "The customer reports a duplicate charge on the latest invoice." + + +def support_agent(): + return JevAgent( + name="jev_support_agent", + model="jev-1.13", + questions={ + "department": ChoiceQuestion( + instructions="Which team should handle this issue?", + choices={ + "billing": "Payment and invoice issues", + "technical": "Bugs and software issues", + "other": "Other requests", + }, + ) + }, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", action="store_true", help="Start live Jev inference") + args = parser.parse_args() + config = Configuration( + server_api_url=os.environ.get("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") + ) + with AgentRuntime(config) as runtime: + agent = support_agent() + if not args.run: + # POST /agent/compile: compilation only, with the same definition and prompt. + print(json.dumps(runtime.plan(agent, PROMPT), indent=2)) + return + + # POST /agent/start, then poll GET /agent/{executionId}/status until isComplete. + handle = runtime.start(agent, PROMPT) + print("Execution:", handle.execution_id) + result = handle.join(timeout=120) + if not result.is_success: + raise RuntimeError(f"{result.status}: {result.error}") + # Keep output.result as structured data, including answers and provider metrics. + print(json.dumps(result.output["result"], indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index 7ceb42bf..c5ab0ac1 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -50,7 +50,14 @@ def get_weather(city: str) -> str: LocalCodeExecutor, ServerlessCodeExecutor, ) -from conductor.ai.agents.decision import DecisionModelTool +from conductor.ai.agents.jev import ( + JevAgent, + ChoiceQuestion, + ScoreQuestion, + BooleanQuestion, + JevAnswer, + JevResult, +) # Exceptions from conductor.ai.agents.exceptions import ( @@ -233,7 +240,12 @@ def resolve_credentials(task: object, names: list) -> dict: from conductor.ai.agents.tracing import is_tracing_enabled __all__ = [ - "DecisionModelTool", + "JevAgent", + "ChoiceQuestion", + "ScoreQuestion", + "BooleanQuestion", + "JevAnswer", + "JevResult", # OpenAI Agents SDK compatibility "Runner", "RunResult", diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index b199aa11..2e147479 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -75,6 +75,8 @@ class AgentDef: name: Agent name (becomes the Conductor workflow name). model: LLM model in ``"provider/model"`` format. Empty string means "inherit from parent agent at resolution time". + kind: Set to "jev" for a Jev agent; omitted for chat agents. + questions: Fixed Jev questions, or omit and supply context.questions at runtime. instructions: System prompt — a string or the decorated callable. tools: List of tools for the agent. guardrails: List of guardrails for the agent. @@ -109,6 +111,8 @@ class AgentDef: credentials: List[Any] = field(default_factory=list) context_window_budget: Optional[int] = None prefill_tools: List[Any] = field(default_factory=list) + kind: Optional[str] = None + questions: Optional[Dict[str, Any]] = None # ── @agent decorator ──────────────────────────────────────────────────── @@ -207,7 +211,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": - """Convert an ``@agent``-decorated function into an :class:`Agent` instance. + """Convert an AgentDef or ``@agent``-decorated function into an Agent. If *obj* is already an :class:`Agent`, it is returned as-is. @@ -215,12 +219,35 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": *parent_model* is provided, the parent's model is inherited. Raises: - TypeError: If *obj* is not an Agent or ``@agent``-decorated function. + TypeError: If *obj* is not an Agent, AgentDef or ``@agent``-decorated function. """ if isinstance(obj, Agent): return obj - if callable(obj) and hasattr(obj, "_agent_def"): - ad: AgentDef = obj._agent_def + if isinstance(obj, AgentDef) or (callable(obj) and hasattr(obj, "_agent_def")): + ad: AgentDef = obj if isinstance(obj, AgentDef) else obj._agent_def + if ad.kind == "jev": + from conductor.ai.agents.jev import JevAgent + + if ( + ad.tools + or ad.agents + or ad.guardrails + or ad.instructions + or ad.func + or ad.local_code_execution + or ad.code_execution + or ad.cli_commands + or ad.credentials + or ad.prefill_tools + or ad.max_tokens is not None + or ad.temperature is not None + ): + raise ValueError( + "Jev AgentDef supports model, questions and metadata; chat configuration is unsupported" + ) + return JevAgent(ad.name, model=ad.model, questions=ad.questions, metadata=ad.metadata) + if ad.kind is not None: + raise ValueError(f"Unsupported agent kind: {ad.kind}") # Handle ClaudeCode: don't inherit parent model for claude-code agents if isinstance(ad.model, ClaudeCode): resolved_model = ad.model @@ -229,7 +256,7 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": return Agent( name=ad.name, model=resolved_model, - instructions=ad.func, + instructions=ad.func or ad.instructions, tools=ad.tools, guardrails=ad.guardrails, agents=ad.agents, @@ -249,7 +276,9 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": context_window_budget=ad.context_window_budget, prefill_tools=ad.prefill_tools or None, ) - raise TypeError(f"Expected an Agent or @agent-decorated function, got {type(obj).__name__}") + raise TypeError( + f"Expected an Agent, AgentDef or @agent-decorated function, got {type(obj).__name__}" + ) # ── from_instance resolution helpers ──────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index fcc8ed67..576394a4 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -36,7 +36,27 @@ def serialize(self, agent: "Agent") -> dict: return self._serialize_agent(agent) def _serialize_agent(self, agent: "Agent") -> dict: - from conductor.ai.agents.agent import PromptTemplate + from conductor.ai.agents.agent import AgentDef, PromptTemplate, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) + if getattr(agent, "kind", None) == "jev": + from conductor.ai.agents.jev import jev_questions + + if agent.tools or agent.agents or agent.memory or agent.guardrails or agent.output_type: + raise ValueError( + "Jev agents cannot contain chat tools, agents, memory, output schemas or guardrails" + ) + config = {"name": agent.name, "kind": "jev", "model": agent.model} + if agent.questions is not None: + config["questions"] = jev_questions(agent.questions) + if agent.timeout_seconds: + config["timeoutSeconds"] = agent.timeout_seconds + if agent.metadata: + config["metadata"] = agent.metadata + if agent.masked_fields: + config["maskedFields"] = agent.masked_fields + return config # Skill agents — emit the raw skill config so the server's # SkillNormalizer can compile sub-agents (e.g. gilfoyle, dinesh) diff --git a/src/conductor/ai/agents/decision.py b/src/conductor/ai/agents/decision.py deleted file mode 100644 index 10c2ca42..00000000 --- a/src/conductor/ai/agents/decision.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Server-executed decision tools. Provider transport and credentials stay on Conductor.""" - -from __future__ import annotations - -from copy import deepcopy -from typing import Any, Dict, Optional - -from conductor.ai.agents.tool import ToolDef - - -class DecisionModelTool(ToolDef): - """A server decision tool with fixed provider and model. - - Supply ``questions`` to expose only ``state`` as a tool argument. Otherwise - the agent supplies both. Requires server support for ``DECISION_MODEL``. - """ - - def __init__( - self, - name: str, - description: str, - *, - provider: str, - model: str, - questions: Optional[Dict[str, Any]] = None, - max_calls: Optional[int] = None, - ) -> None: - if not isinstance(provider, str) or not provider.strip(): - raise ValueError("provider must be a nonempty server provider name") - if not isinstance(model, str) or not model.strip(): - raise ValueError("model must be nonempty") - if questions is not None and (not isinstance(questions, dict) or not questions): - raise ValueError("questions must be a nonempty mapping when supplied") - config: Dict[str, Any] = {"provider": provider, "model": model} - properties: Dict[str, Any] = { - "state": {"type": "string", "description": "Observed state to evaluate."}, - } - required = ["state"] - if questions is not None: - config["questions"] = deepcopy(questions) - else: - properties["questions"] = { - "type": "object", - "minProperties": 1, - "additionalProperties": { - "oneOf": [ - _question_schema( - "choice", - "choices", - { - "type": "object", - "minProperties": 2, - "maxProperties": 255, - "additionalProperties": {"type": "string", "minLength": 1}, - }, - ), - _question_schema( - "score", - "scale", - { - "type": "array", - "minItems": 2, - "maxItems": 10, - "items": {"type": "string", "minLength": 1}, - }, - ), - _question_schema("boolean"), - ], - }, - } - required.append("questions") - super().__init__( - name=name, - description=description, - input_schema={ - "type": "object", - "properties": properties, - "required": required, - "additionalProperties": False, - }, - tool_type="decision_model", - config=config, - max_calls=max_calls, - retry_count=0, - ) - - -def _question_schema(kind: str, field: str = "", schema: Optional[dict] = None) -> dict: - properties = { - "type": {"type": "string", "enum": [kind]}, - "instructions": {"type": "string", "minLength": 1}, - } - required = ["type", "instructions"] - if field: - properties[field] = schema - required.append(field) - return { - "type": "object", - "properties": properties, - "required": required, - "additionalProperties": False, - } diff --git a/src/conductor/ai/agents/jev.py b/src/conductor/ai/agents/jev.py new file mode 100644 index 00000000..f0d213e8 --- /dev/null +++ b/src/conductor/ai/agents/jev.py @@ -0,0 +1,143 @@ +"""Server-executed Jev agents. Provider transport and credentials stay on Conductor.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict, dataclass +from typing import Any, Dict, Mapping, Optional, Sequence + +from conductor.ai.agents.agent import Agent + +_MIN_OPTIONS = 2 +_MAX_CHOICES = 255 +_MAX_SCORE_LABELS = 10 + + +@dataclass(frozen=True) +class ChoiceQuestion: + instructions: str + choices: Mapping[str, str] + type: str = "choice" + + +@dataclass(frozen=True) +class ScoreQuestion: + instructions: str + scale: Sequence[str] + type: str = "score" + + +@dataclass(frozen=True) +class BooleanQuestion: + instructions: str + type: str = "boolean" + + +def jev_questions(questions: Mapping[str, Any]) -> dict: + """Validate the typed question contract before registering or executing an agent.""" + if not isinstance(questions, Mapping) or not questions: + raise ValueError("questions must be a nonempty mapping") + result = {} + for name, question in questions.items(): + q = ( + asdict(question) + if isinstance(question, (ChoiceQuestion, ScoreQuestion, BooleanQuestion)) + else deepcopy(question) + ) + if not isinstance(name, str) or not name.strip() or not isinstance(q, dict): + raise ValueError("invalid Jev question") + kind = q.get("type") + fields = {"type", "instructions"} | ( + {"choices"} if kind == "choice" else {"scale"} if kind == "score" else set() + ) + if ( + kind not in ("choice", "score", "boolean") + or set(q) != fields + or not isinstance(q.get("instructions"), str) + or not q["instructions"].strip() + ): + raise ValueError("invalid Jev question") + if kind == "choice": + choices = q["choices"] + if ( + not isinstance(choices, dict) + or not _MIN_OPTIONS <= len(choices) <= _MAX_CHOICES + or any( + not isinstance(k, str) + or not k.strip() + or not isinstance(v, str) + or not v.strip() + for k, v in choices.items() + ) + ): + raise ValueError("choice requires 2..255 named choices") + if kind == "score": + if ( + not isinstance(q["scale"], (list, tuple)) + or not _MIN_OPTIONS <= len(q["scale"]) <= _MAX_SCORE_LABELS + or any(not isinstance(v, str) or not v.strip() for v in q["scale"]) + ): + raise ValueError("score requires 2..10 labels") + q["scale"] = list(q["scale"]) + result[name] = q + return result + + +class JevAgent(Agent): + """A Jev agent with the standard agent execution lifecycle. + + Pass state as the runtime prompt (strings or JSON-serializable mappings). + With no fixed questions, supply ``context={"questions": ...}`` when starting. + Credentials and inference stay on Conductor; no local tool workers are needed. + """ + + kind = "jev" + + def __init__( + self, + name: str, + *, + model: str, + questions: Optional[Mapping[str, Any]] = None, + timeout_seconds: int = 0, + metadata: Optional[Dict[str, Any]] = None, + masked_fields: Optional[list[str]] = None, + ): + if not isinstance(model, str) or not model.strip(): + raise ValueError("Jev model must be nonempty") + super().__init__( + name=name, + model=model, + timeout_seconds=timeout_seconds, + metadata=metadata, + masked_fields=masked_fields, + ) + self.questions = jev_questions(questions) if questions is not None else None + + +@dataclass(frozen=True) +class JevAnswer: + type: str + choice: Optional[str] = None + score: Optional[float] = None + probability: Optional[float] = None + confidence: Optional[float] = None + + +@dataclass(frozen=True) +class JevResult: + model: str + answers: Dict[str, JevAnswer] + usage: Optional[Dict[str, Any]] = None + latency_ms: Optional[int] = None + request_id: Optional[str] = None + + @classmethod + def from_dict(cls, value: dict) -> "JevResult": + return cls( + model=value["model"], + answers={k: JevAnswer(**v) for k, v in value["answers"].items()}, + usage=value.get("usage"), + latency_ms=value.get("latencyMs"), + request_id=value.get("requestId"), + ) diff --git a/src/conductor/ai/agents/result.py b/src/conductor/ai/agents/result.py index 29ccc92f..c1b831a1 100644 --- a/src/conductor/ai/agents/result.py +++ b/src/conductor/ai/agents/result.py @@ -8,7 +8,10 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional +from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Dict, Iterator, List, Optional + +if TYPE_CHECKING: + from conductor.ai.agents.jev import JevResult # ── Status & FinishReason enums ──────────────────────────────────────── @@ -94,7 +97,9 @@ class AgentResult: Attributes: output: The agent's final answer as a dict. Always contains a - ``"result"`` key whose value is a string (or ``None``). + ``"result"`` key whose value is a string (or ``None``) for chat + agents, or a structured result for Jev agents. The latter + is also available through :attr:`jev`. If ``output_type`` was set on the agent, this is a validated instance of that type instead. execution_id: The Conductor execution ID (for debugging in the UI). @@ -125,6 +130,21 @@ class AgentResult: events: List["AgentEvent"] = field(default_factory=list) sub_results: Dict[str, Any] = field(default_factory=dict) + @property + def jev(self) -> Optional[JevResult]: + """Typed view of a successful Jev-shaped result; raw data stays in output.""" + from conductor.ai.agents.jev import JevResult + + if ( + self.is_success + and isinstance(self.output, dict) + and isinstance(self.output.get("result"), dict) + ): + value = self.output["result"] + if isinstance(value.get("model"), str) and isinstance(value.get("answers"), dict): + return JevResult.from_dict(value) + return None + @property def is_success(self) -> bool: """Whether the agent completed successfully.""" @@ -598,7 +618,7 @@ def _build_result(self, status: "AgentStatus") -> "AgentResult": correlation_id=self.correlation_id, status=status.status, finish_reason=self._runtime._derive_finish_reason(status.status, status.output), - error=status.reason if status.status in ("FAILED", "TERMINATED") else None, + error=status.reason if status.status in ("FAILED", "TERMINATED", "TIMED_OUT") else None, token_usage=token_usage, metadata=metadata, ) diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index e53b921a..0793c175 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -842,6 +842,14 @@ def _start_via_server( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) + if getattr(agent, "kind", None) == "jev" and agent.questions is None: + from conductor.ai.agents.jev import jev_questions + + context = { + **(context or {}), + "questions": jev_questions((context or {}).get("questions")), + } + # Per-run LLM overrides (model/temperature/…) mutate the serialized # agentConfig before compile+register+start, so they flow into the # LLM tasks without a new server field. @@ -906,6 +914,14 @@ async def _start_via_server_async( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) + if getattr(agent, "kind", None) == "jev" and agent.questions is None: + from conductor.ai.agents.jev import jev_questions + + context = { + **(context or {}), + "questions": jev_questions((context or {}).get("questions")), + } + # Per-run LLM overrides (see :meth:`_start_via_server`). rs = RunSettings.coerce(run_settings) if rs is not None: @@ -1105,6 +1121,10 @@ def prepare(self, agent: Any) -> None: handle = runtime.start(agent, prompt) """ from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) if isinstance(agent, str): return # nothing to prepare for run-by-name @@ -2003,6 +2023,9 @@ def _resolve_prompt(self, prompt: Any) -> str: return "" if isinstance(prompt, str): return prompt + if isinstance(prompt, (dict, list)): + # Structured agent input must be JSON, not Python's repr with single quotes. + return json.dumps(prompt, separators=(",", ":"), allow_nan=False) if not isinstance(prompt, PromptTemplate): return str(prompt) @@ -2233,7 +2256,7 @@ def _ensure_models_for_agent(self, agent: Agent) -> None: def _collect(a: Agent) -> None: if not isinstance(a, Agent): return - if a.model and a.model not in seen: + if a.model and a.model not in seen and getattr(a, "kind", None) != "jev": seen.add(a.model) for sub in a.agents: _collect(sub) @@ -2299,20 +2322,32 @@ def _has_worker_tools(self, agent: Agent) -> bool: # ── Plan (compile without executing) ──────────────────────────── - def plan(self, agent: Agent) -> Any: + def plan( + self, + agent: Agent, + prompt: Optional[str] = None, + *, + context: Optional[Dict[str, Any]] = None, + ) -> Any: """Compile an agent to a Conductor workflow definition and return it. This does NOT register, start workers, or execute. Useful for inspecting, debugging, or exporting the compiled workflow. Args: - agent: The agent to compile. + agent: The Agent or AgentDef to compile. + prompt: Optional input to include in the compilation request. + context: Optional run context, including dynamic Jev questions. Returns: The raw server response dict with ``workflowDef`` and ``requiredWorkers`` keys. """ from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) if framework: @@ -2330,6 +2365,14 @@ def plan(self, agent: Agent) -> Any: config_json = serializer.serialize(agent) payload = {"agentConfig": config_json} + if prompt is not None: + payload["prompt"] = self._resolve_prompt(prompt) + if context is not None: + if getattr(agent, "kind", None) == "jev" and agent.questions is None: + from conductor.ai.agents.jev import jev_questions + + context = {**context, "questions": jev_questions(context.get("questions"))} + payload["context"] = context return self._agent_client.compile_agent(payload) # ── Deploy (CI/CD) ───────────────────────────────────────────── @@ -2375,6 +2418,10 @@ def deploy( results = [] for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) @@ -2413,6 +2460,10 @@ async def deploy_async( results = [] for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) @@ -2545,9 +2596,12 @@ def serve( # Register local Python worker functions for each agent from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent has_new = False for agent in all_agents: + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) # Register the agent (workflow + task defs) on the server before # bringing up local workers. Mirrors run()'s deploy-then-register @@ -2697,6 +2751,10 @@ def run( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) @@ -3807,6 +3865,10 @@ def start( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) if framework is not None: @@ -4066,6 +4128,10 @@ async def run_async( # Foreign framework check from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) @@ -4237,6 +4303,10 @@ async def start_async( ) from conductor.ai.agents.frameworks.serializer import detect_framework + from conductor.ai.agents.agent import AgentDef, _resolve_agent + + if isinstance(agent, AgentDef): + agent = _resolve_agent(agent) framework = detect_framework(agent) if framework is not None: diff --git a/tests/unit/ai/test_agent_schema_contract.py b/tests/unit/ai/test_agent_schema_contract.py index eecd1db3..a2d31dc4 100644 --- a/tests/unit/ai/test_agent_schema_contract.py +++ b/tests/unit/ai/test_agent_schema_contract.py @@ -49,3 +49,22 @@ def test_agent_schema_rejects_unknown_root_fields(): schema = json.loads(SCHEMA_PATH.read_text()) with pytest.raises(jsonschema.ValidationError): jsonschema.validate({"name": "valid_name", "unknownField": True}, schema) + + +def test_agent_schema_accepts_jev_definition(): + from conductor.ai.agents import JevAgent, ChoiceQuestion, ScoreQuestion, BooleanQuestion + + definition = JevAgent( + "jev", + model="jev-1.13", + questions={ + "team": ChoiceQuestion("Choose", {"billing": "Payments", "technical": "Bugs"}), + "priority": ScoreQuestion("Score", ["low", "high"]), + "ready": BooleanQuestion("Ready?"), + }, + ) + schema = json.loads(SCHEMA_PATH.read_text()) + jsonschema.validate(AgentConfigSerializer().serialize(definition), schema) + jsonschema.validate( + AgentConfigSerializer().serialize(JevAgent("dynamic", model="jev-1.13")), schema + ) diff --git a/tests/unit/ai/test_decision_model_tool.py b/tests/unit/ai/test_decision_model_tool.py deleted file mode 100644 index 4570ada3..00000000 --- a/tests/unit/ai/test_decision_model_tool.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from conductor.ai.agents import Agent, DecisionModelTool, agent -from conductor.ai.agents.config_serializer import AgentConfigSerializer - - -def test_fixed_questions_are_configuration_and_require_no_worker(): - questions = { - "team": { - "type": "choice", - "instructions": "Select team", - "choices": {"billing": "Payments", "support": "Technical help"}, - } - } - tool = DecisionModelTool( - "decide", "Decide team", provider="jev", model="jev-1.13", questions=questions, max_calls=1 - ) - assert tool.func is None - assert tool.credentials == [] - questions["team"]["instructions"] = "mutated" - - class Example: - @agent(model="openai/configured-model", tools=[tool]) - def assistant(self): - """Use the decision tool.""" - - definition = Agent.from_instance(Example(), "assistant") - data = AgentConfigSerializer().serialize(definition)["tools"][0] - assert data["toolType"] == "decision_model" - assert data["config"]["provider"] == "jev" - assert data["config"]["questions"]["team"]["instructions"] == "Select team" - assert set(data["inputSchema"]["properties"]) == {"state"} - assert data["maxCalls"] == 1 - assert "credentials" not in data["config"] - - -def test_dynamic_questions_use_provider_neutral_schema(): - tool = DecisionModelTool("decide", "Evaluate questions", provider="another", model="v1") - assert tool.input_schema["required"] == ["state", "questions"] - variants = tool.input_schema["properties"]["questions"]["additionalProperties"]["oneOf"] - assert [v["properties"]["type"]["enum"] for v in variants] == [ - ["choice"], - ["score"], - ["boolean"], - ] - assert "provider" not in tool.input_schema["properties"] - assert "model" not in tool.input_schema["properties"] - assert tool.config == {"provider": "another", "model": "v1"} - - -def test_invalid_config_fails_locally(): - with pytest.raises(ValueError): - DecisionModelTool("decide", "Test", provider="", model="v1") - with pytest.raises(ValueError): - DecisionModelTool("decide", "Test", provider="jev", model="") - with pytest.raises(ValueError): - DecisionModelTool("decide", "Test", provider="jev", model="v1", questions={}) diff --git a/tests/unit/ai/test_jev_agent.py b/tests/unit/ai/test_jev_agent.py new file mode 100644 index 00000000..87946e11 --- /dev/null +++ b/tests/unit/ai/test_jev_agent.py @@ -0,0 +1,206 @@ +import json + +import pytest + +from conductor.ai.agents import ( + Agent, + AgentResult, + BooleanQuestion, + ChoiceQuestion, + JevAgent, + ScoreQuestion, +) +from conductor.ai.agents.config_serializer import AgentConfigSerializer +from conductor.ai.agents.jev import jev_questions +from conductor.ai.agents.runtime.runtime import AgentRuntime + + +def test_jev_agent_serializes_as_an_agent_and_as_a_child(): + definition = JevAgent( + "choose", + model="jev-1.13", + questions={ + "action": ChoiceQuestion("Choose an action", {"go": "Proceed", "wait": "Wait"}), + "quality": ScoreQuestion("Evaluate quality", ["low", "high"]), + "ready": BooleanQuestion("Ready to proceed?"), + }, + ) + assert isinstance(definition, Agent) + config = AgentConfigSerializer().serialize(definition) + assert config["kind"] == "jev" + assert "decisionProvider" not in config + assert config["questions"]["action"]["choices"] == {"go": "Proceed", "wait": "Wait"} + assert "tools" not in config + assert "credentials" not in config + parent = Agent("parent", agents=[definition], strategy="sequential") + assert AgentConfigSerializer().serialize(parent)["agents"][0]["kind"] == "jev" + assert "kind" not in AgentConfigSerializer().serialize(Agent("chat", model="openai/model")) + + +def test_dynamic_questions_and_json_state(): + agent = JevAgent("choose", model="jev-1.13") + assert "questions" not in AgentConfigSerializer().serialize(agent) + runtime = AgentRuntime.__new__(AgentRuntime) + assert json.loads(runtime._resolve_prompt({"ready": True, "value": None})) == { + "ready": True, + "value": None, + } + # No attempt to register Jev models as chat providers. + runtime._ensure_models_for_agent(agent) + assert jev_questions({"ready": BooleanQuestion("Ready?")})["ready"]["type"] == "boolean" + + +@pytest.mark.parametrize( + "question", + [ + {"type": "choice", "instructions": "Choose", "choices": {"only": "Only"}}, + {"type": "score", "instructions": "Score", "scale": []}, + {"type": "boolean", "instructions": "", "choices": {}}, + ], +) +def test_invalid_question_contract_is_rejected(question): + with pytest.raises(ValueError, match=r"requires|invalid"): + JevAgent("bad", model="jev-1.13", questions={"q": question}) + + +def test_typed_result_preserves_provider_details_without_chat_wrapping(): + raw = { + "model": "jev-1.13", + "answers": { + "action": {"type": "choice", "choice": "go", "confidence": 0.9}, + "score": {"type": "score", "score": 1.2}, + "ready": {"type": "boolean", "probability": 0.8}, + }, + "usage": {"inputTokens": 10, "outputTokens": 3, "cost": 0.001, "currency": "USD"}, + "requestId": "req", + "latencyMs": 42, + } + result = AgentResult(output={"result": raw}) + assert result.jev.answers["action"].choice == "go" + assert result.jev.answers["score"].score == raw["answers"]["score"]["score"] + assert result.jev.answers["ready"].probability == raw["answers"]["ready"]["probability"] + assert result.jev.usage == raw["usage"] + assert result.jev.latency_ms == raw["latencyMs"] + assert result.jev.request_id == "req" + assert AgentResult(output={"result": "chat"}).jev is None + + +def test_failed_jev_has_no_typed_answer(): + result = AgentResult(status="FAILED", output={"agentKind": "jev", "result": {}}) + assert result.jev is None + + +@pytest.fixture +def runtime(): + from unittest.mock import patch + from conductor.ai.agents.runtime.config import AgentConfig + + with patch("conductor.client.orkes_clients.OrkesClients"): + with AgentRuntime(settings=AgentConfig(auto_start_workers=False)) as runtime: + yield runtime + + +def test_agent_def_and_convenience_class_serialize_identically(runtime): + from conductor.ai.agents import AgentDef + + questions = {"ready": BooleanQuestion("Ready?")} + definition = AgentDef(name="ready", kind="jev", model="jev-1.13", questions=questions) + expected = { + "name": "ready", + "kind": "jev", + "model": "jev-1.13", + "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, + } + assert AgentConfigSerializer().serialize(definition) == expected + assert ( + AgentConfigSerializer().serialize(JevAgent("ready", model="jev-1.13", questions=questions)) + == expected + ) + runtime.plan(definition, "Ready to ship") + runtime._agent_client.compile_agent.assert_called_once_with( + {"agentConfig": expected, "prompt": "Ready to ship"} + ) + runtime._agent_client.start_agent.assert_not_called() + + +@pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"]) +def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, status): + from unittest.mock import patch + from conductor.ai.agents import AgentDef + + definition = AgentDef(name="ready", kind="jev", model="jev-1.13") + raw = { + "model": "jev-1.13", + "answers": {"ready": {"type": "boolean", "probability": 0.8}}, + "usage": {"inputTokens": 10}, + "latencyMs": 42, + "requestId": "request-1", + } + runtime._agent_client.start_agent.return_value = { + "executionId": "execution-1", + "requiredWorkers": [], + } + runtime._agent_client.get_status.side_effect = [ + {"isComplete": False, "status": "RUNNING"}, + { + "isComplete": True, + "status": status, + "output": {"result": raw}, + "reasonForIncompletion": "provider failure" if status != "COMPLETED" else None, + }, + ] + handle = runtime.start( + definition, "Ready?", context={"questions": {"ready": BooleanQuestion("Ready?")}} + ) + assert handle.execution_id == "execution-1" + payload = runtime._agent_client.start_agent.call_args.args[0] + assert payload["agentConfig"] == {"name": "ready", "kind": "jev", "model": "jev-1.13"} + assert payload["context"]["questions"]["ready"] == {"type": "boolean", "instructions": "Ready?"} + assert not runtime._workers_started + with patch("time.sleep"): + result = handle.join(timeout=5) + assert runtime._agent_client.get_status.call_count == 2 + assert result.output["result"] == raw + assert result.is_success == (status == "COMPLETED") + assert result.error == (None if status == "COMPLETED" else "provider failure") + + +def test_dynamic_questions_required_before_start(runtime): + with pytest.raises(ValueError, match="questions"): + runtime.start(JevAgent("ready", model="jev-1.13"), "Ready?") + runtime._agent_client.start_agent.assert_not_called() + + +def test_async_start_and_dynamic_compile(runtime): + import asyncio + from unittest.mock import AsyncMock + from conductor.ai.agents import AgentDef + + definition = AgentDef(name="ready", kind="jev", model="jev-1.13") + context = {"questions": {"ready": BooleanQuestion("Ready?")}} + runtime.plan(definition, "Ready?", context=context) + assert ( + runtime._agent_client.compile_agent.call_args.args[0]["context"]["questions"]["ready"][ + "type" + ] + == "boolean" + ) + runtime._agent_client.start_agent_async = AsyncMock( + return_value={"executionId": "async-1", "requiredWorkers": []} + ) + handle = asyncio.run(runtime.start_async(definition, "Ready?", context=context)) + assert handle.execution_id == "async-1" + assert ( + runtime._agent_client.start_agent_async.call_args.args[0]["context"]["questions"]["ready"][ + "type" + ] + == "boolean" + ) + + +def test_jev_has_no_tool_or_internal_task_exports(): + import conductor.ai.agents as agents + + assert not hasattr(agents, "DecisionModelTool") + assert not hasattr(agents, "DecisionAgent") + assert not hasattr(agents, "JEV_AGENT") From 2fd3226aeb8be842b8ad9f7c9f4f5b8b8d99c091 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 12:58:01 -0700 Subject: [PATCH 05/14] feat(agents): simplify Jev support and add routing examples --- docs/agents/reference/agent-definition.md | 33 ++--- docs/agents/reference/agent-schema.json | 28 +--- examples/agents/README.md | 23 +-- examples/agents/jev_agent.py | 24 +--- examples/agents/jev_nested_triage.py | 86 +++++++++++ examples/agents/jev_specialists.py | 122 ++++++++++++++++ examples/agents/luna_jev_triage.py | 63 ++++++++ src/conductor/ai/agents/__init__.py | 14 +- src/conductor/ai/agents/agent.py | 15 +- src/conductor/ai/agents/config_serializer.py | 8 +- src/conductor/ai/agents/jev.py | 132 +---------------- src/conductor/ai/agents/result.py | 27 +--- src/conductor/ai/agents/runtime/runtime.py | 44 ++---- tests/unit/ai/test_agent_schema_contract.py | 23 ++- tests/unit/ai/test_jev_agent.py | 144 +++++-------------- tests/unit/ai/test_tool_extraction.py | 31 ++++ 16 files changed, 405 insertions(+), 412 deletions(-) create mode 100644 examples/agents/jev_nested_triage.py create mode 100644 examples/agents/jev_specialists.py create mode 100644 examples/agents/luna_jev_triage.py diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 01ad2ade..6b4a1acd 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -13,24 +13,21 @@ maintained in [api-reference.md](../api-reference.md) and ## Jev agents -Use `JevAgent(name="jev_support_agent", model="jev-1.13", questions=questions)` -or `AgentDef(name="jev_support_agent", kind="jev", model="jev-1.13", questions=questions)`. -Both serialize through `AgentConfigSerializer` with `kind: "jev"` and use the -standard agent runtime APIs. Jev credentials and provider HTTP calls stay on -Conductor. A chat model and Python worker are not required. +Use `JevAgent(name, model="jev-1.13", questions=questions)` or +`AgentDef(name=name, kind="jev", model="jev-1.13", questions=questions)`. +Questions are dictionaries with `instructions` and a `type`: +`choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` +returns a probability. Omit questions to supply `context={"questions": questions}`. -Each question requires `instructions`. Use `ChoiceQuestion(instructions, choices)` -for a choices map, `ScoreQuestion(instructions, scale)` for an ordered scale, or -`BooleanQuestion(instructions)` for a probability response. Plain dictionaries -with `type` and the corresponding fields are also accepted. Omit definition -questions to supply them in `context={"questions": questions}` for each run. +Use `runtime.plan(agent, prompt)` to compile or `runtime.start(agent, prompt)` +to run. Call `handle.join()` and check `result.is_success` or `result.error`. +`result.output["result"]` preserves `model`, `answers`, `usage`, `latencyMs` +and optional `requestId`. Credentials and inference stay on Conductor. +No chat model or Python worker is needed. -`runtime.plan(agent, prompt, context=context)` calls `/agent/compile` without -inference. `runtime.start(agent, prompt, context=context)` calls `/agent/start`; -`handle.join()` polls status until complete. Check `result.is_success` and report -`result.error` on failure. `result.output["result"]` retains the structured -`model`, `answers`, `usage`, `latencyMs`, and optional `requestId` fields. +[Example](../../../examples/agents/jev_agent.py) -See [the runnable Jev example](../../../examples/agents/jev_agent.py), which -compiles by default and requires `--run` to start inference. Jev is supported -only as an agent definition; there is no public Jev task or decision-model tool. +For server-side Jev routing, use `Agent(strategy="router", router=selector, +agents=children)`. The Jev selector must have one fixed choice question whose +choice keys match the child agent names. No parent chat model is needed. +Routing runs one child and preserves its structured result. diff --git a/docs/agents/reference/agent-schema.json b/docs/agents/reference/agent-schema.json index 55d63f9a..425d9019 100644 --- a/docs/agents/reference/agent-schema.json +++ b/docs/agents/reference/agent-schema.json @@ -34,28 +34,6 @@ }, "additionalProperties": true }, - "jevQuestion": { - "type": "object", - "required": ["type", "instructions"], - "properties": { - "type": { "enum": ["choice", "score", "boolean"] }, - "instructions": { "type": "string", "pattern": "\\S" }, - "choices": { - "type": "object", "minProperties": 2, "maxProperties": 255, - "additionalProperties": { "type": "string", "minLength": 1 } - }, - "scale": { - "type": "array", "minItems": 2, "maxItems": 10, - "items": { "type": "string", "minLength": 1 } - } - }, - "oneOf": [ - { "properties": { "type": { "const": "choice" } }, "required": ["choices"], "not": { "required": ["scale"] } }, - { "properties": { "type": { "const": "score" } }, "required": ["scale"], "not": { "required": ["choices"] } }, - { "properties": { "type": { "const": "boolean" } }, "not": { "anyOf": [{ "required": ["choices"] }, { "required": ["scale"] }] } } - ], - "additionalProperties": false - }, "agentConfig": { "type": "object", "required": ["name"], @@ -63,11 +41,7 @@ "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$" }, "model": { "type": ["string", "null"] }, "kind": { "const": "jev" }, - "questions": { - "type": "object", - "minProperties": 1, - "additionalProperties": { "$ref": "#/$defs/jevQuestion" } - }, + "questions": { "type": "object", "additionalProperties": { "type": "object" } }, "baseUrl": { "type": ["string", "null"] }, "strategy": { "type": ["string", "null"] }, "maxTurns": { "type": ["integer", "null"], "minimum": 0 }, diff --git a/examples/agents/README.md b/examples/agents/README.md index 3091d0ef..690f6f45 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -27,19 +27,10 @@ Framework-specific examples are in [ADK](adk/README.md), [LangGraph](langgraph/README.md), and [OpenAI Agents SDK](openai/README.md). Review tool side effects before using real credentials. -## Jev agents - -`python examples/agents/jev_agent.py` (from the repository root) compiles the -`jev_support_agent` definition without inference. Pass `--run` to explicitly start -it and poll for completion. The example uses `JevAgent` and `AgentRuntime`, requires -no chat model or Python worker, and prints structured `output.result` data: -`model`, `answers`, `usage`, `latencyMs`, and optional `requestId`. -Configure Jev credentials only on Conductor; the SDK calls the agent APIs. - -The equivalent generic definition is -`AgentDef(name="jev_support_agent", kind="jev", model="jev-1.13", questions=questions)`. -Both forms support `runtime.plan()`, `runtime.start()`, and deployment. Questions -require `instructions`: `ChoiceQuestion` uses a choices map, `ScoreQuestion` uses -an ordered scale, and `BooleanQuestion` returns a probability. If questions are -omitted from the definition, pass `context={"questions": questions}` to `plan()` -or `start()`. There is no public Jev tool or standalone decision-model API. +Jev: [jev_agent.py](jev_agent.py) compiles by default. Pass `--run` for inference. + +- `jev_nested_triage.py`: Jev department selection, Jev specialist selection, then a Jev specialist. +- `luna_jev_triage.py --model INTEGRATION/luna-6`: Luna selects one of ten Jev specialists. + +Both compile by default. Pass `--run` for inference. These require the server's +Jev router support and structured output for single-turn routers without synthesis. diff --git a/examples/agents/jev_agent.py b/examples/agents/jev_agent.py index 0eedf0dc..6923467d 100644 --- a/examples/agents/jev_agent.py +++ b/examples/agents/jev_agent.py @@ -1,18 +1,10 @@ -"""Compile a Jev agent; pass --run to explicitly start inference. - - python examples/agents/jev_agent.py - python examples/agents/jev_agent.py --run - -Requires Conductor at http://localhost:8080/api (override CONDUCTOR_SERVER_URL). -Jev credentials and provider HTTP calls belong on the server. No chat model or -Python worker is required. View executions in the UI at http://localhost:1234. -""" +"""Compile a Jev agent. Pass --run for inference. Configure credentials on Conductor.""" import argparse import json import os -from conductor.ai.agents import AgentRuntime, ChoiceQuestion, JevAgent +from conductor.ai.agents import AgentRuntime, JevAgent from conductor.client.configuration.configuration import Configuration PROMPT = "The customer reports a duplicate charge on the latest invoice." @@ -23,14 +15,15 @@ def support_agent(): name="jev_support_agent", model="jev-1.13", questions={ - "department": ChoiceQuestion( - instructions="Which team should handle this issue?", - choices={ + "department": { + "type": "choice", + "instructions": "Which team should handle this issue?", + "choices": { "billing": "Payment and invoice issues", "technical": "Bugs and software issues", "other": "Other requests", }, - ) + } }, ) @@ -45,17 +38,14 @@ def main(): with AgentRuntime(config) as runtime: agent = support_agent() if not args.run: - # POST /agent/compile: compilation only, with the same definition and prompt. print(json.dumps(runtime.plan(agent, PROMPT), indent=2)) return - # POST /agent/start, then poll GET /agent/{executionId}/status until isComplete. handle = runtime.start(agent, PROMPT) print("Execution:", handle.execution_id) result = handle.join(timeout=120) if not result.is_success: raise RuntimeError(f"{result.status}: {result.error}") - # Keep output.result as structured data, including answers and provider metrics. print(json.dumps(result.output["result"], indent=2)) diff --git a/examples/agents/jev_nested_triage.py b/examples/agents/jev_nested_triage.py new file mode 100644 index 00000000..220237f0 --- /dev/null +++ b/examples/agents/jev_nested_triage.py @@ -0,0 +1,86 @@ +"""Three levels: Jev department selector, Jev specialist selector, Jev specialist. + +Requires server support for Jev routers. Compile by default, --run for inference. +""" + +import argparse +import json +import os + +from conductor.ai.agents import Agent, AgentRuntime, JevAgent, Strategy +from conductor.client.configuration.configuration import Configuration +from jev_specialists import SPECIALTIES, specialists + +PROMPT = "Our latest invoice has two settled charges with different transaction IDs for the same purchase." + + +def routing_team(name, children, descriptions): + return Agent( + name=name, + strategy=Strategy.ROUTER, + router=JevAgent( + name=f"{name}_selector", + model="jev-1.13", + questions={ + "agent": { + "type": "choice", + "instructions": "Choose the agent best suited to handle this request.", + "choices": descriptions, + } + }, + ), + agents=children, + max_turns=1, + synthesize=False, + ) + + +def triage_agent(): + leaves = specialists() + departments = [] + for department in ("billing", "technical", "account"): + members = { + name: agent for name, agent in leaves.items() if SPECIALTIES[name][0] == department + } + departments.append( + routing_team( + f"jev_{department}_team", + list(members.values()), + {agent.name: SPECIALTIES[name][1] for name, agent in members.items()}, + ) + ) + return routing_team( + "jev_nested_triage", + departments, + { + "jev_billing_team": "Charges, refunds and subscriptions", + "jev_technical_team": "API errors, outages, integrations and setup", + "jev_account_team": "Access, security and privacy", + }, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", action="store_true") + parser.add_argument("--prompt", default=PROMPT) + args = parser.parse_args() + config = Configuration( + server_api_url=os.getenv("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") + ) + with AgentRuntime(config) as runtime: + agent = triage_agent() + if not args.run: + print(json.dumps(runtime.plan(agent, args.prompt), indent=2)) + return + runtime.deploy(agent) + handle = runtime.start(agent, args.prompt) + print("Execution:", handle.execution_id) + result = handle.join(timeout=180) + if not result.is_success: + raise RuntimeError(result.error) + print(json.dumps(result.output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/agents/jev_specialists.py b/examples/agents/jev_specialists.py new file mode 100644 index 00000000..1275163d --- /dev/null +++ b/examples/agents/jev_specialists.py @@ -0,0 +1,122 @@ +"""Ten Jev specialists used by the routing examples.""" + +from conductor.ai.agents import JevAgent + +SPECIALTIES = { + "duplicate_charge": ( + "billing", + "Duplicate payments or repeated invoice charges", + { + "check_transactions": "Compare charge identifiers and payment status", + "refund_duplicate": "Refund a confirmed duplicate settled charge", + "explain_hold": "Explain a pending authorization hold", + }, + ), + "refund": ( + "billing", + "Refund eligibility and refund progress", + { + "review_eligibility": "Check purchase date and refund policy", + "approve_refund": "Approve a refund supported by the supplied policy and evidence", + "trace_refund": "Trace an already approved refund", + }, + ), + "subscription": ( + "billing", + "Subscription renewals, cancellation and plan changes", + { + "review_renewal": "Check renewal timing and notice", + "cancel_renewal": "Schedule cancellation at the end of the current term", + "change_plan": "Review the requested plan change", + }, + ), + "api_errors": ( + "technical", + "API request failures, rate limits and invalid payloads", + { + "inspect_request": "Collect request identifiers and validate the payload", + "backoff": "Apply retry backoff for documented rate limiting", + "escalate_bug": "Escalate a reproducible server defect", + }, + ), + "outage": ( + "technical", + "Service outages and widespread availability failures", + { + "check_scope": "Verify the affected services and customers", + "open_incident": "Open an incident for confirmed widespread impact", + "update_incident": "Attach evidence to an existing incident", + }, + ), + "integration": ( + "technical", + "Webhook delivery and third-party integration failures", + { + "inspect_delivery": "Inspect delivery logs and endpoint responses", + "repair_config": "Correct an identified integration configuration error", + "replay_delivery": "Replay a failed delivery after checking idempotency", + }, + ), + "onboarding": ( + "technical", + "Initial setup, configuration and first successful request", + { + "collect_requirements": "Clarify the intended setup and environment", + "provide_setup": "Provide the relevant setup steps", + "validate_setup": "Verify the completed configuration", + }, + ), + "access": ( + "account", + "Login problems, account recovery and permissions", + { + "verify_identity": "Verify identity before changing account access", + "recover_account": "Use the approved account recovery process", + "review_permissions": "Review the required role and authorization", + }, + ), + "security": ( + "account", + "Suspected compromise, leaked credentials and abuse", + { + "investigate": "Collect evidence and assess the scope of compromise", + "contain": "Recommend revoking confirmed compromised credentials", + "escalate_security": "Escalate an active compromise to security response", + }, + ), + "privacy": ( + "account", + "Personal data access, export and deletion requests", + { + "verify_request": "Verify the requester and scope of the request", + "export_data": "Route a verified data export request", + "delete_data": "Route a verified deletion request through policy review", + }, + ), +} + + +def specialists(): + return { + name: JevAgent( + name=f"jev_{name}", + model="jev-1.13", + questions={ + "action": { + "type": "choice", + "instructions": f"Recommend the next step for {description}. Use only supplied evidence. Do not claim any action was executed.", + "choices": actions, + }, + "urgency": { + "type": "score", + "instructions": "Rate urgency from low to critical based on customer impact.", + "scale": ["low", "normal", "high", "critical"], + }, + "needs_more_information": { + "type": "boolean", + "instructions": "Is more information needed before taking the recommended action?", + }, + }, + ) + for name, (_, description, actions) in SPECIALTIES.items() + } diff --git a/examples/agents/luna_jev_triage.py b/examples/agents/luna_jev_triage.py new file mode 100644 index 00000000..16f63da2 --- /dev/null +++ b/examples/agents/luna_jev_triage.py @@ -0,0 +1,63 @@ +"""Luna-6 selects one of ten Jev specialists using server-side routing. + +Pass --model INTEGRATION/luna-6. Compile by default, --run for inference. +""" + +import argparse +import json +import os + +from conductor.ai.agents import Agent, AgentRuntime, Strategy +from conductor.client.configuration.configuration import Configuration +from jev_specialists import SPECIALTIES, specialists + + +def triage_agent(model): + candidates = specialists() + descriptions = "\n".join( + f"{agent.name}: {SPECIALTIES[name][1]}" for name, agent in candidates.items() + ) + return Agent( + name="luna_jev_triage", + model=model, + strategy=Strategy.ROUTER, + router=Agent( + name="luna_jev_selector", + model=model, + instructions=f"Select exactly one specialist for the request. Return only its agent name.\n{descriptions}", + ), + agents=list(candidates.values()), + max_turns=1, + synthesize=False, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", default=os.getenv("CONDUCTOR_AGENT_LLM_MODEL")) + parser.add_argument("--run", action="store_true") + parser.add_argument( + "--prompt", default="Our webhook endpoint returns 503 and order notifications are missing." + ) + args = parser.parse_args() + if not args.model: + parser.error("Pass --model with your server's integration/luna-6 identifier") + config = Configuration( + server_api_url=os.getenv("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") + ) + with AgentRuntime(config) as runtime: + agent = triage_agent(args.model) + if not args.run: + print(json.dumps(runtime.plan(agent, args.prompt), indent=2)) + return + runtime.deploy(agent) + handle = runtime.start(agent, args.prompt) + print("Execution:", handle.execution_id) + result = handle.join(timeout=180) + if not result.is_success: + raise RuntimeError(result.error) + print(json.dumps(result.output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index c5ab0ac1..b42b7934 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -50,14 +50,7 @@ def get_weather(city: str) -> str: LocalCodeExecutor, ServerlessCodeExecutor, ) -from conductor.ai.agents.jev import ( - JevAgent, - ChoiceQuestion, - ScoreQuestion, - BooleanQuestion, - JevAnswer, - JevResult, -) +from conductor.ai.agents.jev import JevAgent # Exceptions from conductor.ai.agents.exceptions import ( @@ -241,11 +234,6 @@ def resolve_credentials(task: object, names: list) -> dict: __all__ = [ "JevAgent", - "ChoiceQuestion", - "ScoreQuestion", - "BooleanQuestion", - "JevAnswer", - "JevResult", # OpenAI Agents SDK compatibility "Runner", "RunResult", diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index 2e147479..583b11bc 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -75,7 +75,7 @@ class AgentDef: name: Agent name (becomes the Conductor workflow name). model: LLM model in ``"provider/model"`` format. Empty string means "inherit from parent agent at resolution time". - kind: Set to "jev" for a Jev agent; omitted for chat agents. + kind: Set to "jev" for a Jev agent. Omit for chat agents. questions: Fixed Jev questions, or omit and supply context.questions at runtime. instructions: System prompt — a string or the decorated callable. tools: List of tools for the agent. @@ -242,9 +242,7 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": or ad.max_tokens is not None or ad.temperature is not None ): - raise ValueError( - "Jev AgentDef supports model, questions and metadata; chat configuration is unsupported" - ) + raise ValueError("Jev AgentDef does not support chat configuration") return JevAgent(ad.name, model=ad.model, questions=ad.questions, metadata=ad.metadata) if ad.kind is not None: raise ValueError(f"Unsupported agent kind: {ad.kind}") @@ -942,11 +940,12 @@ def is_claude_code(self) -> bool: def external(self) -> bool: """``True`` if this agent references an external workflow (no local definition). - An agent with no ``model`` is treated as external — the server - produces a ``SubWorkflowTask`` referencing the workflow by name - instead of compiling the agent inline. + An agent with no model references an existing workflow, except a + ROUTER with a Jev selector, which is compiled locally by the server. """ - return not self.model + return not self.model and not ( + self.strategy == Strategy.ROUTER and getattr(self.router, "kind", None) == "jev" + ) # ── Instance-method resolution ────────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index 576394a4..9800bb6e 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -41,21 +41,15 @@ def _serialize_agent(self, agent: "Agent") -> dict: if isinstance(agent, AgentDef): agent = _resolve_agent(agent) if getattr(agent, "kind", None) == "jev": - from conductor.ai.agents.jev import jev_questions - if agent.tools or agent.agents or agent.memory or agent.guardrails or agent.output_type: raise ValueError( "Jev agents cannot contain chat tools, agents, memory, output schemas or guardrails" ) config = {"name": agent.name, "kind": "jev", "model": agent.model} if agent.questions is not None: - config["questions"] = jev_questions(agent.questions) - if agent.timeout_seconds: - config["timeoutSeconds"] = agent.timeout_seconds + config["questions"] = agent.questions if agent.metadata: config["metadata"] = agent.metadata - if agent.masked_fields: - config["maskedFields"] = agent.masked_fields return config # Skill agents — emit the raw skill config so the server's diff --git a/src/conductor/ai/agents/jev.py b/src/conductor/ai/agents/jev.py index f0d213e8..f9d3e70f 100644 --- a/src/conductor/ai/agents/jev.py +++ b/src/conductor/ai/agents/jev.py @@ -1,95 +1,13 @@ -"""Server-executed Jev agents. Provider transport and credentials stay on Conductor.""" - -from __future__ import annotations +"""Jev agent definitions. Inference and credentials stay on Conductor.""" from copy import deepcopy -from dataclasses import asdict, dataclass -from typing import Any, Dict, Mapping, Optional, Sequence +from typing import Any, Dict, Optional from conductor.ai.agents.agent import Agent -_MIN_OPTIONS = 2 -_MAX_CHOICES = 255 -_MAX_SCORE_LABELS = 10 - - -@dataclass(frozen=True) -class ChoiceQuestion: - instructions: str - choices: Mapping[str, str] - type: str = "choice" - - -@dataclass(frozen=True) -class ScoreQuestion: - instructions: str - scale: Sequence[str] - type: str = "score" - - -@dataclass(frozen=True) -class BooleanQuestion: - instructions: str - type: str = "boolean" - - -def jev_questions(questions: Mapping[str, Any]) -> dict: - """Validate the typed question contract before registering or executing an agent.""" - if not isinstance(questions, Mapping) or not questions: - raise ValueError("questions must be a nonempty mapping") - result = {} - for name, question in questions.items(): - q = ( - asdict(question) - if isinstance(question, (ChoiceQuestion, ScoreQuestion, BooleanQuestion)) - else deepcopy(question) - ) - if not isinstance(name, str) or not name.strip() or not isinstance(q, dict): - raise ValueError("invalid Jev question") - kind = q.get("type") - fields = {"type", "instructions"} | ( - {"choices"} if kind == "choice" else {"scale"} if kind == "score" else set() - ) - if ( - kind not in ("choice", "score", "boolean") - or set(q) != fields - or not isinstance(q.get("instructions"), str) - or not q["instructions"].strip() - ): - raise ValueError("invalid Jev question") - if kind == "choice": - choices = q["choices"] - if ( - not isinstance(choices, dict) - or not _MIN_OPTIONS <= len(choices) <= _MAX_CHOICES - or any( - not isinstance(k, str) - or not k.strip() - or not isinstance(v, str) - or not v.strip() - for k, v in choices.items() - ) - ): - raise ValueError("choice requires 2..255 named choices") - if kind == "score": - if ( - not isinstance(q["scale"], (list, tuple)) - or not _MIN_OPTIONS <= len(q["scale"]) <= _MAX_SCORE_LABELS - or any(not isinstance(v, str) or not v.strip() for v in q["scale"]) - ): - raise ValueError("score requires 2..10 labels") - q["scale"] = list(q["scale"]) - result[name] = q - return result - class JevAgent(Agent): - """A Jev agent with the standard agent execution lifecycle. - - Pass state as the runtime prompt (strings or JSON-serializable mappings). - With no fixed questions, supply ``context={"questions": ...}`` when starting. - Credentials and inference stay on Conductor; no local tool workers are needed. - """ + """Supply questions here or through context.questions at runtime.""" kind = "jev" @@ -98,46 +16,8 @@ def __init__( name: str, *, model: str, - questions: Optional[Mapping[str, Any]] = None, - timeout_seconds: int = 0, + questions: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None, - masked_fields: Optional[list[str]] = None, ): - if not isinstance(model, str) or not model.strip(): - raise ValueError("Jev model must be nonempty") - super().__init__( - name=name, - model=model, - timeout_seconds=timeout_seconds, - metadata=metadata, - masked_fields=masked_fields, - ) - self.questions = jev_questions(questions) if questions is not None else None - - -@dataclass(frozen=True) -class JevAnswer: - type: str - choice: Optional[str] = None - score: Optional[float] = None - probability: Optional[float] = None - confidence: Optional[float] = None - - -@dataclass(frozen=True) -class JevResult: - model: str - answers: Dict[str, JevAnswer] - usage: Optional[Dict[str, Any]] = None - latency_ms: Optional[int] = None - request_id: Optional[str] = None - - @classmethod - def from_dict(cls, value: dict) -> "JevResult": - return cls( - model=value["model"], - answers={k: JevAnswer(**v) for k, v in value["answers"].items()}, - usage=value.get("usage"), - latency_ms=value.get("latencyMs"), - request_id=value.get("requestId"), - ) + super().__init__(name=name, model=model, metadata=metadata) + self.questions = deepcopy(questions) diff --git a/src/conductor/ai/agents/result.py b/src/conductor/ai/agents/result.py index c1b831a1..41e8b335 100644 --- a/src/conductor/ai/agents/result.py +++ b/src/conductor/ai/agents/result.py @@ -8,10 +8,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Dict, Iterator, List, Optional - -if TYPE_CHECKING: - from conductor.ai.agents.jev import JevResult +from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional # ── Status & FinishReason enums ──────────────────────────────────────── @@ -97,9 +94,7 @@ class AgentResult: Attributes: output: The agent's final answer as a dict. Always contains a - ``"result"`` key whose value is a string (or ``None``) for chat - agents, or a structured result for Jev agents. The latter - is also available through :attr:`jev`. + ``"result"`` key with text, structured data or ``None``. If ``output_type`` was set on the agent, this is a validated instance of that type instead. execution_id: The Conductor execution ID (for debugging in the UI). @@ -130,21 +125,6 @@ class AgentResult: events: List["AgentEvent"] = field(default_factory=list) sub_results: Dict[str, Any] = field(default_factory=dict) - @property - def jev(self) -> Optional[JevResult]: - """Typed view of a successful Jev-shaped result; raw data stays in output.""" - from conductor.ai.agents.jev import JevResult - - if ( - self.is_success - and isinstance(self.output, dict) - and isinstance(self.output.get("result"), dict) - ): - value = self.output["result"] - if isinstance(value.get("model"), str) and isinstance(value.get("answers"), dict): - return JevResult.from_dict(value) - return None - @property def is_success(self) -> bool: """Whether the agent completed successfully.""" @@ -731,6 +711,7 @@ class EventType(str, Enum): """Types of events emitted during agent execution.""" THINKING = "thinking" + JEV = "jev" TOOL_CALL = "tool_call" TOOL_RESULT = "tool_result" HANDOFF = "handoff" @@ -752,7 +733,7 @@ class AgentEvent: ``guardrail_pass``, ``guardrail_fail``). tool_name: Tool name (for ``tool_call``, ``tool_result``). args: Tool call arguments (for ``tool_call``). - result: Tool result (for ``tool_result``). + result: Structured result for ``tool_result`` or ``jev``. target: Target agent name (for ``handoff``). output: Final output (for ``done``). execution_id: The Conductor execution ID. diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index 0793c175..ad51a00e 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -23,7 +23,7 @@ from conductor.ai.agents.runtime.config import AgentConfig from conductor.client.configuration.configuration import Configuration -from conductor.ai.agents.agent import Agent +from conductor.ai.agents.agent import Agent, AgentDef, _resolve_agent from conductor.ai.agents.run_settings import RunSettings from conductor.ai.agents.result import ( AgentEvent, @@ -292,6 +292,7 @@ def _normalize_handoff_target(task_ref: str) -> str: _NON_TOOL_TASK_TYPES = frozenset( { "LLM_CHAT_COMPLETE", + "JEV_AGENT", "SWITCH", "DO_WHILE", "INLINE", @@ -440,6 +441,15 @@ def _task_events(task: Any, execution_id: str) -> Iterator[AgentEvent]: task_status = str(getattr(task, "status", "") or "").upper() output_data = getattr(task, "output_data", None) or {} + if task_type == "JEV_AGENT" and task_status == "COMPLETED": + yield AgentEvent( + type=EventType.JEV, + content=task_ref, + result=output_data, + execution_id=execution_id, + ) + return + # LLM task -> THINKING if "LLM_CHAT_COMPLETE" in task_type: yield AgentEvent( @@ -842,14 +852,6 @@ def _start_via_server( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) - if getattr(agent, "kind", None) == "jev" and agent.questions is None: - from conductor.ai.agents.jev import jev_questions - - context = { - **(context or {}), - "questions": jev_questions((context or {}).get("questions")), - } - # Per-run LLM overrides (model/temperature/…) mutate the serialized # agentConfig before compile+register+start, so they flow into the # LLM tasks without a new server field. @@ -914,14 +916,6 @@ async def _start_via_server_async( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) - if getattr(agent, "kind", None) == "jev" and agent.questions is None: - from conductor.ai.agents.jev import jev_questions - - context = { - **(context or {}), - "questions": jev_questions((context or {}).get("questions")), - } - # Per-run LLM overrides (see :meth:`_start_via_server`). rs = RunSettings.coerce(run_settings) if rs is not None: @@ -1121,7 +1115,6 @@ def prepare(self, agent: Any) -> None: handle = runtime.start(agent, prompt) """ from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -2023,9 +2016,6 @@ def _resolve_prompt(self, prompt: Any) -> str: return "" if isinstance(prompt, str): return prompt - if isinstance(prompt, (dict, list)): - # Structured agent input must be JSON, not Python's repr with single quotes. - return json.dumps(prompt, separators=(",", ":"), allow_nan=False) if not isinstance(prompt, PromptTemplate): return str(prompt) @@ -2344,7 +2334,6 @@ def plan( ``requiredWorkers`` keys. """ from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -2368,10 +2357,6 @@ def plan( if prompt is not None: payload["prompt"] = self._resolve_prompt(prompt) if context is not None: - if getattr(agent, "kind", None) == "jev" and agent.questions is None: - from conductor.ai.agents.jev import jev_questions - - context = {**context, "questions": jev_questions(context.get("questions"))} payload["context"] = context return self._agent_client.compile_agent(payload) @@ -2418,7 +2403,6 @@ def deploy( results = [] for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -2460,7 +2444,6 @@ async def deploy_async( results = [] for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -2596,7 +2579,6 @@ def serve( # Register local Python worker functions for each agent from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent has_new = False for agent in all_agents: @@ -2751,7 +2733,6 @@ def run( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -3865,7 +3846,6 @@ def start( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -4128,7 +4108,6 @@ async def run_async( # Foreign framework check from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) @@ -4303,7 +4282,6 @@ async def start_async( ) from conductor.ai.agents.frameworks.serializer import detect_framework - from conductor.ai.agents.agent import AgentDef, _resolve_agent if isinstance(agent, AgentDef): agent = _resolve_agent(agent) diff --git a/tests/unit/ai/test_agent_schema_contract.py b/tests/unit/ai/test_agent_schema_contract.py index a2d31dc4..dd481167 100644 --- a/tests/unit/ai/test_agent_schema_contract.py +++ b/tests/unit/ai/test_agent_schema_contract.py @@ -52,19 +52,14 @@ def test_agent_schema_rejects_unknown_root_fields(): def test_agent_schema_accepts_jev_definition(): - from conductor.ai.agents import JevAgent, ChoiceQuestion, ScoreQuestion, BooleanQuestion + from conductor.ai.agents import JevAgent - definition = JevAgent( - "jev", - model="jev-1.13", - questions={ - "team": ChoiceQuestion("Choose", {"billing": "Payments", "technical": "Bugs"}), - "priority": ScoreQuestion("Score", ["low", "high"]), - "ready": BooleanQuestion("Ready?"), - }, - ) + questions = { + "team": {"type": "choice", "instructions": "Choose", "choices": {"a": "A", "b": "B"}}, + "priority": {"type": "score", "instructions": "Score", "scale": ["low", "high"]}, + "ready": {"type": "boolean", "instructions": "Ready?"}, + } schema = json.loads(SCHEMA_PATH.read_text()) - jsonschema.validate(AgentConfigSerializer().serialize(definition), schema) - jsonschema.validate( - AgentConfigSerializer().serialize(JevAgent("dynamic", model="jev-1.13")), schema - ) + for fixed_questions in (questions, None): + agent = JevAgent("jev", model="jev-1.13", questions=fixed_questions) + jsonschema.validate(AgentConfigSerializer().serialize(agent), schema) diff --git a/tests/unit/ai/test_jev_agent.py b/tests/unit/ai/test_jev_agent.py index 87946e11..ac72b67d 100644 --- a/tests/unit/ai/test_jev_agent.py +++ b/tests/unit/ai/test_jev_agent.py @@ -1,99 +1,17 @@ -import json +from unittest.mock import AsyncMock, patch +import asyncio import pytest -from conductor.ai.agents import ( - Agent, - AgentResult, - BooleanQuestion, - ChoiceQuestion, - JevAgent, - ScoreQuestion, -) +from conductor.ai.agents import AgentDef, AgentRuntime, JevAgent from conductor.ai.agents.config_serializer import AgentConfigSerializer -from conductor.ai.agents.jev import jev_questions -from conductor.ai.agents.runtime.runtime import AgentRuntime - - -def test_jev_agent_serializes_as_an_agent_and_as_a_child(): - definition = JevAgent( - "choose", - model="jev-1.13", - questions={ - "action": ChoiceQuestion("Choose an action", {"go": "Proceed", "wait": "Wait"}), - "quality": ScoreQuestion("Evaluate quality", ["low", "high"]), - "ready": BooleanQuestion("Ready to proceed?"), - }, - ) - assert isinstance(definition, Agent) - config = AgentConfigSerializer().serialize(definition) - assert config["kind"] == "jev" - assert "decisionProvider" not in config - assert config["questions"]["action"]["choices"] == {"go": "Proceed", "wait": "Wait"} - assert "tools" not in config - assert "credentials" not in config - parent = Agent("parent", agents=[definition], strategy="sequential") - assert AgentConfigSerializer().serialize(parent)["agents"][0]["kind"] == "jev" - assert "kind" not in AgentConfigSerializer().serialize(Agent("chat", model="openai/model")) - - -def test_dynamic_questions_and_json_state(): - agent = JevAgent("choose", model="jev-1.13") - assert "questions" not in AgentConfigSerializer().serialize(agent) - runtime = AgentRuntime.__new__(AgentRuntime) - assert json.loads(runtime._resolve_prompt({"ready": True, "value": None})) == { - "ready": True, - "value": None, - } - # No attempt to register Jev models as chat providers. - runtime._ensure_models_for_agent(agent) - assert jev_questions({"ready": BooleanQuestion("Ready?")})["ready"]["type"] == "boolean" - - -@pytest.mark.parametrize( - "question", - [ - {"type": "choice", "instructions": "Choose", "choices": {"only": "Only"}}, - {"type": "score", "instructions": "Score", "scale": []}, - {"type": "boolean", "instructions": "", "choices": {}}, - ], -) -def test_invalid_question_contract_is_rejected(question): - with pytest.raises(ValueError, match=r"requires|invalid"): - JevAgent("bad", model="jev-1.13", questions={"q": question}) - - -def test_typed_result_preserves_provider_details_without_chat_wrapping(): - raw = { - "model": "jev-1.13", - "answers": { - "action": {"type": "choice", "choice": "go", "confidence": 0.9}, - "score": {"type": "score", "score": 1.2}, - "ready": {"type": "boolean", "probability": 0.8}, - }, - "usage": {"inputTokens": 10, "outputTokens": 3, "cost": 0.001, "currency": "USD"}, - "requestId": "req", - "latencyMs": 42, - } - result = AgentResult(output={"result": raw}) - assert result.jev.answers["action"].choice == "go" - assert result.jev.answers["score"].score == raw["answers"]["score"]["score"] - assert result.jev.answers["ready"].probability == raw["answers"]["ready"]["probability"] - assert result.jev.usage == raw["usage"] - assert result.jev.latency_ms == raw["latencyMs"] - assert result.jev.request_id == "req" - assert AgentResult(output={"result": "chat"}).jev is None +from conductor.ai.agents.runtime.config import AgentConfig - -def test_failed_jev_has_no_typed_answer(): - result = AgentResult(status="FAILED", output={"agentKind": "jev", "result": {}}) - assert result.jev is None +QUESTIONS = {"ready": {"type": "boolean", "instructions": "Ready?"}} @pytest.fixture def runtime(): - from unittest.mock import patch - from conductor.ai.agents.runtime.config import AgentConfig with patch("conductor.client.orkes_clients.OrkesClients"): with AgentRuntime(settings=AgentConfig(auto_start_workers=False)) as runtime: @@ -101,9 +19,8 @@ def runtime(): def test_agent_def_and_convenience_class_serialize_identically(runtime): - from conductor.ai.agents import AgentDef - questions = {"ready": BooleanQuestion("Ready?")} + questions = QUESTIONS definition = AgentDef(name="ready", kind="jev", model="jev-1.13", questions=questions) expected = { "name": "ready", @@ -121,12 +38,11 @@ def test_agent_def_and_convenience_class_serialize_identically(runtime): {"agentConfig": expected, "prompt": "Ready to ship"} ) runtime._agent_client.start_agent.assert_not_called() + runtime._ensure_models_for_agent(JevAgent("ready", model="jev-1.13")) @pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"]) def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, status): - from unittest.mock import patch - from conductor.ai.agents import AgentDef definition = AgentDef(name="ready", kind="jev", model="jev-1.13") raw = { @@ -149,9 +65,7 @@ def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, "reasonForIncompletion": "provider failure" if status != "COMPLETED" else None, }, ] - handle = runtime.start( - definition, "Ready?", context={"questions": {"ready": BooleanQuestion("Ready?")}} - ) + handle = runtime.start(definition, "Ready?", context={"questions": QUESTIONS}) assert handle.execution_id == "execution-1" payload = runtime._agent_client.start_agent.call_args.args[0] assert payload["agentConfig"] == {"name": "ready", "kind": "jev", "model": "jev-1.13"} @@ -165,19 +79,10 @@ def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, assert result.error == (None if status == "COMPLETED" else "provider failure") -def test_dynamic_questions_required_before_start(runtime): - with pytest.raises(ValueError, match="questions"): - runtime.start(JevAgent("ready", model="jev-1.13"), "Ready?") - runtime._agent_client.start_agent.assert_not_called() - - def test_async_start_and_dynamic_compile(runtime): - import asyncio - from unittest.mock import AsyncMock - from conductor.ai.agents import AgentDef definition = AgentDef(name="ready", kind="jev", model="jev-1.13") - context = {"questions": {"ready": BooleanQuestion("Ready?")}} + context = {"questions": QUESTIONS} runtime.plan(definition, "Ready?", context=context) assert ( runtime._agent_client.compile_agent.call_args.args[0]["context"]["questions"]["ready"][ @@ -198,9 +103,28 @@ def test_async_start_and_dynamic_compile(runtime): ) -def test_jev_has_no_tool_or_internal_task_exports(): - import conductor.ai.agents as agents - - assert not hasattr(agents, "DecisionModelTool") - assert not hasattr(agents, "DecisionAgent") - assert not hasattr(agents, "JEV_AGENT") +def test_nested_jev_example_routes_to_named_agents(monkeypatch): + import runpy + from pathlib import Path + + examples = Path(__file__).resolve().parents[3] / "examples" / "agents" + monkeypatch.syspath_prepend(str(examples)) + nested = runpy.run_path(str(examples / "jev_nested_triage.py"))["triage_agent"]() + serializer = AgentConfigSerializer() + config = serializer.serialize(nested) + assert config["external"] is False + assert not config.get("model") + assert len(config["agents"]) == 3 + assert sum(len(team["agents"]) for team in config["agents"]) == 10 + for team in [config, *config["agents"]]: + assert team["router"]["kind"] == "jev" + assert set(team["router"]["questions"]["agent"]["choices"]) == { + child["name"] for child in team["agents"] + } + luna = runpy.run_path(str(examples / "luna_jev_triage.py"))["triage_agent"]("configured/luna-6") + config = serializer.serialize(luna) + assert config["router"]["model"] == "configured/luna-6" + assert len(config["agents"]) == 10 + assert all(child["kind"] == "jev" for child in config["agents"]) + assert config["maxTurns"] == 1 + assert config["synthesize"] is False diff --git a/tests/unit/ai/test_tool_extraction.py b/tests/unit/ai/test_tool_extraction.py index 80b73588..2588513d 100644 --- a/tests/unit/ai/test_tool_extraction.py +++ b/tests/unit/ai/test_tool_extraction.py @@ -587,3 +587,34 @@ def test_survives_an_unreachable_execution(self, runtime): runtime._workflow_client.get_workflow.side_effect = RuntimeError("boom") assert runtime._extract_tool_calls_and_events("exec-1") == ([], []) + + +@pytest.mark.parametrize("status", ["COMPLETED", "IN_PROGRESS", "FAILED"]) +def test_jev_is_inference_not_a_tool(runtime, status): + output = { + "model": "jev-1.13", + "answers": {"department": {"type": "choice", "choice": "billing"}}, + "usage": {"inputTokens": 10, "outputTokens": 2}, + "latencyMs": 42, + "requestId": "request-1", + } + task = FakeTask( + task_type="JEV_AGENT", + task_def_name="JEV_AGENT", + reference_task_name="support_jev", + output_data=output, + status=status, + ) + workflow = FakeWorkflowRun([task], status=status) + assert runtime._extract_tool_calls(workflow) == [] + events = runtime._extract_events(workflow, "wf-1") + assert not any(e.type in (EventType.TOOL_CALL, EventType.TOOL_RESULT) for e in events) + if status == "COMPLETED": + sse = runtime._sse_to_agent_event( + {"event": "jev", "data": {"content": "support_jev", "result": output}}, "wf-1" + ) + assert events[0] == sse + assert events[0].type == EventType.JEV + assert events[0].result == output + elif status == "FAILED": + assert any(e.type == EventType.ERROR for e in events) From 9f639f6b7a442e71e3ecf4defdfc47180fa64d8c Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 14:29:15 -0700 Subject: [PATCH 06/14] feat(workflow): add AI_DECISION task and Jev routing example --- examples/agentic_workflows/README.md | 10 ++- .../agentic_workflows/ai_decision_routing.py | 72 +++++++++++++++++++ .../client/workflow/task/ai_decision_task.py | 35 +++++++++ .../client/workflow/task/task_type.py | 1 + tests/unit/workflow/test_ai_decision_task.py | 56 +++++++++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 examples/agentic_workflows/ai_decision_routing.py create mode 100644 src/conductor/client/workflow/task/ai_decision_task.py create mode 100644 tests/unit/workflow/test_ai_decision_task.py diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index b1b7fed5..fb15443f 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -1,11 +1,13 @@ # Agentic Workflow Examples -AI/LLM workflow examples using Conductor's built-in system tasks (`LLM_CHAT_COMPLETE`, `LLM_INDEX_TEXT`, `LLM_SEARCH_INDEX`, MCP tools) combined with Python workers. +AI workflow examples using Conductor's built-in system tasks, with Python workers where needed. -All examples use **inline ChatMessage objects** for system prompts -- no named prompt templates or AIOrchestrator required. They work with OSS Conductor with AI/LLM support. +Chat examples use inline ChatMessage objects for system prompts. No named prompt templates or AIOrchestrator required. ## Prerequisites +The AI decision example requires server-side `AI_DECISION` support and Jev credentials. It needs no chat model or Python worker. The other examples require: + - Conductor server with AI/LLM support running (e.g., `http://localhost:7001/api`) - LLM provider named `openai` configured with a valid API key - `export CONDUCTOR_SERVER_URL=http://localhost:7001/api` @@ -14,6 +16,7 @@ All examples use **inline ChatMessage objects** for system prompts -- no named p | Example | Description | Interactive? | Pattern | |---------|-------------|:------------:|---------| +| [ai_decision_routing.py](ai_decision_routing.py) | Route requests to billing or technical support | No | AI_DECISION + SwitchTask + SetVariableTask | | [llm_chat.py](llm_chat.py) | Automated multi-turn science Q&A between two LLMs | No | LoopTask + LLM_CHAT_COMPLETE + worker for history | | [llm_chat_human_in_loop.py](llm_chat_human_in_loop.py) | Interactive chat with WAIT task pauses for user input | Yes | LoopTask + WaitTask + LLM_CHAT_COMPLETE | | [multiagent_chat.py](multiagent_chat.py) | Multi-agent debate with moderator routing between panelists | No | LoopTask + SwitchTask + SetVariableTask + JavaScript routing | @@ -23,6 +26,9 @@ All examples use **inline ChatMessage objects** for system prompts -- no named p ## Quick Start ```bash +# Jev request routing with server-managed credentials +CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing + # Automated multi-turn chat (no interaction needed) python examples/agentic_workflows/llm_chat.py diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py new file mode 100644 index 00000000..283edcc3 --- /dev/null +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -0,0 +1,72 @@ +"""Route a request with AI_DECISION → SWITCH → SET_VARIABLE. + +Requires server-side AI_DECISION support and Jev credentials. No worker needed. +Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing +""" + +import argparse +import json +import time + +from conductor.client.configuration.configuration import Configuration +from conductor.client.orkes_clients import OrkesClients +from conductor.client.workflow.conductor_workflow import ConductorWorkflow +from conductor.client.workflow.task.ai_decision_task import AiDecisionTask +from conductor.client.workflow.task.set_variable_task import SetVariableTask +from conductor.client.workflow.task.switch_task import SwitchTask + + +def create_workflow(executor) -> ConductorWorkflow: + workflow = ConductorWorkflow(executor=executor, name="ai_decision_routing", version=1) + decision = AiDecisionTask( + task_ref_name="decision", + model="jev-1.13", + 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.", + }, + } + }, + ) + route = SwitchTask("route_request", decision.output("selectedCase")) + route.switch_case("billing", [ + SetVariableTask("assign_billing").input_parameter("team", "billing"), + ]) + route.switch_case("technical", [ + SetVariableTask("assign_technical").input_parameter("team", "technical"), + ]) + workflow >> decision >> route + workflow.output_parameters({ + "decision": decision.output(), + "team": "${workflow.variables.team}", + }) + return workflow + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--request", default="I was charged twice on my latest invoice.") + args = parser.parse_args() + clients = OrkesClients(configuration=Configuration()) + workflow = create_workflow(clients.get_workflow_executor()) + workflow.register(overwrite=True) + workflow_id = workflow.start_workflow_with_input({"request": args.request}) + print(f"Workflow: {workflow_id}") + client = clients.get_workflow_client() + while True: + result = client.get_workflow(workflow_id=workflow_id, include_tasks=False) + if result.is_completed(): + break + time.sleep(1) + if result.status != "COMPLETED": + raise SystemExit(f"{result.status}: {result.reason_for_incompletion}") + print(json.dumps(result.output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/conductor/client/workflow/task/ai_decision_task.py b/src/conductor/client/workflow/task/ai_decision_task.py new file mode 100644 index 00000000..c1d54bdd --- /dev/null +++ b/src/conductor/client/workflow/task/ai_decision_task.py @@ -0,0 +1,35 @@ +from typing import Any, Dict, Optional + +from conductor.client.workflow.task.task import TaskInterface +from conductor.client.workflow.task.task_type import TaskType + + +class AiDecisionTask(TaskInterface): + """Run a Jev choice decision using server-managed credentials. + + State and question instructions must be nonempty strings. Questions must + contain exactly one choice question with 2 to 255 choices. The server validates + inputs after resolving workflow references. + + Output retains model, answers, usage, latencyMs and optional requestId. + Use output("selectedCase") with a separate SwitchTask to route the result. + """ + + def __init__( + self, + task_ref_name: str, + model: str, + state: str, + questions: Dict[str, Any], + task_name: Optional[str] = None, + ) -> None: + super().__init__( + task_reference_name=task_ref_name, + task_type=TaskType.AI_DECISION, + task_name=task_name or "ai_decision", + input_parameters={ + "model": model, + "state": state, + "questions": questions, + }, + ) diff --git a/src/conductor/client/workflow/task/task_type.py b/src/conductor/client/workflow/task/task_type.py index 36108f72..8628ffb3 100644 --- a/src/conductor/client/workflow/task/task_type.py +++ b/src/conductor/client/workflow/task/task_type.py @@ -26,6 +26,7 @@ class TaskType(str, Enum): JSON_JQ_TRANSFORM = "JSON_JQ_TRANSFORM" SET_VARIABLE = "SET_VARIABLE" GET_DOCUMENT = "GET_DOCUMENT" + AI_DECISION = "AI_DECISION" LLM_GENERATE_EMBEDDINGS = "LLM_GENERATE_EMBEDDINGS" LLM_GET_EMBEDDINGS = "LLM_GET_EMBEDDINGS" LLM_TEXT_COMPLETE = "LLM_TEXT_COMPLETE" diff --git a/tests/unit/workflow/test_ai_decision_task.py b/tests/unit/workflow/test_ai_decision_task.py new file mode 100644 index 00000000..c29969ad --- /dev/null +++ b/tests/unit/workflow/test_ai_decision_task.py @@ -0,0 +1,56 @@ +from conductor.client.http.api_client import ApiClient +from conductor.client.workflow.task.ai_decision_task import AiDecisionTask +from conductor.client.workflow.task.task_type import TaskType +from examples.agentic_workflows.ai_decision_routing import create_workflow + + +def test_ai_decision_references_and_serialization(): + questions = { + "route": { + "type": "choice", + "instructions": "Choose a team.", + "choices": {"billing": "Payments", "technical": "Errors"}, + } + } + task = AiDecisionTask("decision", "jev-1.13", "${workflow.input.request}", questions) + serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) + assert task.task_type == TaskType.AI_DECISION + assert serialized["type"] == "AI_DECISION" + assert serialized["name"] == "ai_decision" + assert serialized["taskReferenceName"] == "decision" + assert serialized["inputParameters"] == { + "model": "jev-1.13", + "state": "${workflow.input.request}", + "questions": questions, + } + assert task.input("state") == "${decision.input.state}" + assert task.output() == "${decision.output}" + assert task.output("answers.route.choice") == "${decision.output.answers.route.choice}" + assert task.output("selectedCase") == "${decision.output.selectedCase}" + + +def test_custom_name_and_input_reference(): + task = AiDecisionTask("decision", "jev-1.13", "request", {}, task_name="choose_team") + task.input_parameter("questions", "${workflow.input.questions}") + definition = task.to_workflow_task() + assert definition.name == "choose_team" + assert definition.input_parameters["questions"] == "${workflow.input.questions}" + + +def test_routing_example_preserves_decision_output(): + definition = ApiClient().sanitize_for_serialization(create_workflow(None).to_workflow_def()) + decision, switch = definition["tasks"] + assert decision["type"] == "AI_DECISION" + assert decision["inputParameters"]["state"] == "${workflow.input.request}" + assert switch["type"] == "SWITCH" + assert switch["evaluatorType"] == "value-param" + assert switch["inputParameters"]["switchCaseValue"] == "${decision.output.selectedCase}" + assert set(switch["decisionCases"]) == {"billing", "technical"} + for team, tasks in switch["decisionCases"].items(): + assert len(tasks) == 1 + assert tasks[0]["type"] == "SET_VARIABLE" + assert tasks[0]["inputParameters"] == {"team": team} + assert definition["outputParameters"] == { + "decision": "${decision.output}", + "team": "${workflow.variables.team}", + } From ca11744557949762cfb50f51e5a63578df25ea6b Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 14:39:16 -0700 Subject: [PATCH 07/14] fix(examples): return selected Jev routing branch result --- examples/agentic_workflows/README.md | 2 +- .../agentic_workflows/ai_decision_routing.py | 33 +++++++++++++------ tests/unit/workflow/test_ai_decision_task.py | 12 ++++--- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index fb15443f..d8244c4a 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -16,7 +16,7 @@ The AI decision example requires server-side `AI_DECISION` support and Jev crede | Example | Description | Interactive? | Pattern | |---------|-------------|:------------:|---------| -| [ai_decision_routing.py](ai_decision_routing.py) | Route requests to billing or technical support | No | AI_DECISION + SwitchTask + SetVariableTask | +| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AI_DECISION + SwitchTask + InlineTask | | [llm_chat.py](llm_chat.py) | Automated multi-turn science Q&A between two LLMs | No | LoopTask + LLM_CHAT_COMPLETE + worker for history | | [llm_chat_human_in_loop.py](llm_chat_human_in_loop.py) | Interactive chat with WAIT task pauses for user input | Yes | LoopTask + WaitTask + LLM_CHAT_COMPLETE | | [multiagent_chat.py](multiagent_chat.py) | Multi-agent debate with moderator routing between panelists | No | LoopTask + SwitchTask + SetVariableTask + JavaScript routing | diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index 283edcc3..e283dc3d 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -1,4 +1,4 @@ -"""Route a request with AI_DECISION → SWITCH → SET_VARIABLE. +"""Route a request with AI_DECISION → SWITCH → INLINE branch → INLINE result. Requires server-side AI_DECISION support and Jev credentials. No worker needed. Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing @@ -12,7 +12,7 @@ from conductor.client.orkes_clients import OrkesClients from conductor.client.workflow.conductor_workflow import ConductorWorkflow from conductor.client.workflow.task.ai_decision_task import AiDecisionTask -from conductor.client.workflow.task.set_variable_task import SetVariableTask +from conductor.client.workflow.task.inline import InlineTask from conductor.client.workflow.task.switch_task import SwitchTask @@ -33,17 +33,30 @@ def create_workflow(executor) -> ConductorWorkflow: } }, ) + billing = InlineTask( + "handle_billing", + script='({team: "billing", nextAction: "review_invoice", ' + 'message: "Check invoice line items and payment records.", request: $.request})', + bindings={"request": workflow.input("request")}, + ) + technical = InlineTask( + "handle_technical", + script='({team: "technical", nextAction: "collect_diagnostics", ' + 'message: "Collect error logs and steps to reproduce.", request: $.request})', + bindings={"request": workflow.input("request")}, + ) route = SwitchTask("route_request", decision.output("selectedCase")) - route.switch_case("billing", [ - SetVariableTask("assign_billing").input_parameter("team", "billing"), - ]) - route.switch_case("technical", [ - SetVariableTask("assign_technical").input_parameter("team", "technical"), - ]) - workflow >> decision >> route + route.switch_case("billing", [billing]) + route.switch_case("technical", [technical]) + result = InlineTask( + "selected_result", + script="$.billing || $.technical", + bindings={"billing": billing.output("result"), "technical": technical.output("result")}, + ) + workflow >> decision >> route >> result workflow.output_parameters({ "decision": decision.output(), - "team": "${workflow.variables.team}", + "result": result.output("result"), }) return workflow diff --git a/tests/unit/workflow/test_ai_decision_task.py b/tests/unit/workflow/test_ai_decision_task.py index c29969ad..f56e2e13 100644 --- a/tests/unit/workflow/test_ai_decision_task.py +++ b/tests/unit/workflow/test_ai_decision_task.py @@ -39,7 +39,7 @@ def test_custom_name_and_input_reference(): def test_routing_example_preserves_decision_output(): definition = ApiClient().sanitize_for_serialization(create_workflow(None).to_workflow_def()) - decision, switch = definition["tasks"] + decision, switch, result = definition["tasks"] assert decision["type"] == "AI_DECISION" assert decision["inputParameters"]["state"] == "${workflow.input.request}" assert switch["type"] == "SWITCH" @@ -48,9 +48,13 @@ def test_routing_example_preserves_decision_output(): assert set(switch["decisionCases"]) == {"billing", "technical"} for team, tasks in switch["decisionCases"].items(): assert len(tasks) == 1 - assert tasks[0]["type"] == "SET_VARIABLE" - assert tasks[0]["inputParameters"] == {"team": team} + assert tasks[0]["type"] == "INLINE" + assert tasks[0]["taskReferenceName"] == f"handle_{team}" + assert tasks[0]["inputParameters"]["request"] == "${workflow.input.request}" + assert result["type"] == "INLINE" + assert result["inputParameters"]["billing"] == "${handle_billing.output.result}" + assert result["inputParameters"]["technical"] == "${handle_technical.output.result}" assert definition["outputParameters"] == { "decision": "${decision.output}", - "team": "${workflow.variables.team}", + "result": "${selected_result.output.result}", } From c87d53de283eb154b79a396ade3850d85ff9cd3c Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 22:06:19 -0700 Subject: [PATCH 08/14] feat(agents): generalize Jev as decision agents --- docs/agents/reference/agent-definition.md | 10 +-- docs/agents/reference/agent-schema.json | 3 +- examples/agentic_workflows/README.md | 14 ++- .../agentic_workflows/ai_decision_routing.py | 61 ++++++------- examples/agents/jev_agent.py | 4 +- examples/agents/jev_nested_triage.py | 4 +- examples/agents/jev_specialists.py | 4 +- src/conductor/ai/agents/__init__.py | 2 + src/conductor/ai/agents/agent.py | 22 +++-- src/conductor/ai/agents/config_serializer.py | 8 +- src/conductor/ai/agents/decision.py | 25 ++++++ src/conductor/ai/agents/jev.py | 24 +----- src/conductor/ai/agents/result.py | 5 +- src/conductor/ai/agents/runtime/runtime.py | 26 +++--- .../client/workflow/task/ai_decision_task.py | 31 ++++--- tests/unit/ai/test_jev_agent.py | 8 +- tests/unit/workflow/test_ai_decision_task.py | 86 +++++++++++++------ 17 files changed, 207 insertions(+), 130 deletions(-) create mode 100644 src/conductor/ai/agents/decision.py diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 6b4a1acd..0bf2199d 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -11,10 +11,10 @@ external-agent behavior. The complete constructor and serialization semantics ar maintained in [api-reference.md](../api-reference.md) and `AgentConfigSerializer`; use those sources when adding a newly supported field. -## Jev agents +## Decision agents -Use `JevAgent(name, model="jev-1.13", questions=questions)` or -`AgentDef(name=name, kind="jev", model="jev-1.13", questions=questions)`. +Use `DecisionAgent(name, model="jev-1.13", questions=questions)` or +`AgentDef(name=name, kind="decision", model="jev-1.13", questions=questions)`. Questions are dictionaries with `instructions` and a `type`: `choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` returns a probability. Omit questions to supply `context={"questions": questions}`. @@ -27,7 +27,7 @@ No chat model or Python worker is needed. [Example](../../../examples/agents/jev_agent.py) -For server-side Jev routing, use `Agent(strategy="router", router=selector, -agents=children)`. The Jev selector must have one fixed choice question whose +For server-side Decision routing, use `Agent(strategy="router", router=selector, +agents=children)`. The Decision selector must have one fixed choice question whose choice keys match the child agent names. No parent chat model is needed. Routing runs one child and preserves its structured result. diff --git a/docs/agents/reference/agent-schema.json b/docs/agents/reference/agent-schema.json index 425d9019..e5f0d358 100644 --- a/docs/agents/reference/agent-schema.json +++ b/docs/agents/reference/agent-schema.json @@ -40,7 +40,8 @@ "properties": { "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$" }, "model": { "type": ["string", "null"] }, - "kind": { "const": "jev" }, + "kind": { "const": "decision" }, + "provider": { "type": "string" }, "questions": { "type": "object", "additionalProperties": { "type": "object" } }, "baseUrl": { "type": ["string", "null"] }, "strategy": { "type": ["string", "null"] }, diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index d8244c4a..42bde936 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -6,7 +6,7 @@ Chat examples use inline ChatMessage objects for system prompts. No named prompt ## Prerequisites -The AI decision example requires server-side `AI_DECISION` support and Jev credentials. It needs no chat model or Python worker. The other examples require: +The AI decision example requires the server-side `AI_DECISION` routing contract below and Decision credentials. No chat model or Python worker is needed. The other examples require: - Conductor server with AI/LLM support running (e.g., `http://localhost:7001/api`) - LLM provider named `openai` configured with a valid API key @@ -16,7 +16,7 @@ The AI decision example requires server-side `AI_DECISION` support and Jev crede | Example | Description | Interactive? | Pattern | |---------|-------------|:------------:|---------| -| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AI_DECISION + SwitchTask + InlineTask | +| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AiDecisionTask with inline branches | | [llm_chat.py](llm_chat.py) | Automated multi-turn science Q&A between two LLMs | No | LoopTask + LLM_CHAT_COMPLETE + worker for history | | [llm_chat_human_in_loop.py](llm_chat_human_in_loop.py) | Interactive chat with WAIT task pauses for user input | Yes | LoopTask + WaitTask + LLM_CHAT_COMPLETE | | [multiagent_chat.py](multiagent_chat.py) | Multi-agent debate with moderator routing between panelists | No | LoopTask + SwitchTask + SetVariableTask + JavaScript routing | @@ -26,7 +26,7 @@ The AI decision example requires server-side `AI_DECISION` support and Jev crede ## Quick Start ```bash -# Jev request routing with server-managed credentials +# Decision request routing with server-managed credentials CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing # Automated multi-turn chat (no interaction needed) @@ -44,6 +44,14 @@ python examples/agentic_workflows/function_calling_example.py ## Key Patterns +### AI decision routing contract (pending server support) + +The SDK emits one `AI_DECISION` task with `model`, `state`, `instructions`, and `choices` in `inputParameters`. Each choice has a `description` and a `tasks` list of serialized workflow tasks. + +The server compiles the decision and switch before resolving branch references, runs only the selected branch, and exposes its final task's `output.result` as the original task's `output.result`. If that task has no `result` field, use its full output. Preserve the decision's `model`, `answers`, `usage`, `latencyMs`, optional `requestId`, and `selectedCase` alongside `result`. + +Require nonempty state and instructions, 2 to 255 choices, and a nonempty task list per choice. Propagate branch failures. No SDK-generated switch or result collector is needed. + ### Passing dynamic messages to LLM_CHAT_COMPLETE When passing a workflow reference as `messages`, set it via `input_parameters` AFTER construction: diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index e283dc3d..5e772671 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -1,6 +1,7 @@ -"""Route a request with AI_DECISION → SWITCH → INLINE branch → INLINE result. +"""Let the server select and run an inline branch using decision inference. -Requires server-side AI_DECISION support and Jev credentials. No worker needed. +Requires server-side AI_DECISION support and inference credentials. No worker needed. +The server compiles instructions/choices into inference and a SWITCH. Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing """ @@ -13,60 +14,52 @@ from conductor.client.workflow.conductor_workflow import ConductorWorkflow from conductor.client.workflow.task.ai_decision_task import AiDecisionTask from conductor.client.workflow.task.inline import InlineTask -from conductor.client.workflow.task.switch_task import SwitchTask -def create_workflow(executor) -> ConductorWorkflow: +def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkflow: workflow = ConductorWorkflow(executor=executor, name="ai_decision_routing", version=1) - decision = AiDecisionTask( - task_ref_name="decision", - model="jev-1.13", - 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.", - }, - } - }, - ) billing = InlineTask( "handle_billing", script='({team: "billing", nextAction: "review_invoice", ' - 'message: "Check invoice line items and payment records.", request: $.request})', + 'message: "Check invoice line items and payment records.", request: $.request})', bindings={"request": workflow.input("request")}, ) technical = InlineTask( "handle_technical", script='({team: "technical", nextAction: "collect_diagnostics", ' - 'message: "Collect error logs and steps to reproduce.", request: $.request})', + 'message: "Collect error logs and steps to reproduce.", request: $.request})', bindings={"request": workflow.input("request")}, ) - route = SwitchTask("route_request", decision.output("selectedCase")) - route.switch_case("billing", [billing]) - route.switch_case("technical", [technical]) - result = InlineTask( - "selected_result", - script="$.billing || $.technical", - bindings={"billing": billing.output("result"), "technical": technical.output("result")}, + route = AiDecisionTask( + task_ref_name="route_request", + model=model, + provider=provider, + state=workflow.input("request"), + instructions="Choose the team best suited to handle this request.", + choices={ + "billing": { + "description": "Payments, invoices, refunds, or subscriptions.", + "tasks": [billing], + }, + "technical": { + "description": "Errors, outages, or product troubleshooting.", + "tasks": [technical], + }, + }, ) - workflow >> decision >> route >> result - workflow.output_parameters({ - "decision": decision.output(), - "result": result.output("result"), - }) + workflow.add(route) + workflow.output_parameters({"result": route.output("result")}) return workflow def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--request", default="I was charged twice on my latest invoice.") + parser.add_argument("--model", default="jev-1.13") + parser.add_argument("--provider") args = parser.parse_args() clients = OrkesClients(configuration=Configuration()) - workflow = create_workflow(clients.get_workflow_executor()) + workflow = create_workflow(clients.get_workflow_executor(), args.model, args.provider) workflow.register(overwrite=True) workflow_id = workflow.start_workflow_with_input({"request": args.request}) print(f"Workflow: {workflow_id}") diff --git a/examples/agents/jev_agent.py b/examples/agents/jev_agent.py index 6923467d..e4785b68 100644 --- a/examples/agents/jev_agent.py +++ b/examples/agents/jev_agent.py @@ -4,14 +4,14 @@ import json import os -from conductor.ai.agents import AgentRuntime, JevAgent +from conductor.ai.agents import AgentRuntime, DecisionAgent from conductor.client.configuration.configuration import Configuration PROMPT = "The customer reports a duplicate charge on the latest invoice." def support_agent(): - return JevAgent( + return DecisionAgent( name="jev_support_agent", model="jev-1.13", questions={ diff --git a/examples/agents/jev_nested_triage.py b/examples/agents/jev_nested_triage.py index 220237f0..6242879a 100644 --- a/examples/agents/jev_nested_triage.py +++ b/examples/agents/jev_nested_triage.py @@ -7,7 +7,7 @@ import json import os -from conductor.ai.agents import Agent, AgentRuntime, JevAgent, Strategy +from conductor.ai.agents import Agent, AgentRuntime, DecisionAgent, Strategy from conductor.client.configuration.configuration import Configuration from jev_specialists import SPECIALTIES, specialists @@ -18,7 +18,7 @@ def routing_team(name, children, descriptions): return Agent( name=name, strategy=Strategy.ROUTER, - router=JevAgent( + router=DecisionAgent( name=f"{name}_selector", model="jev-1.13", questions={ diff --git a/examples/agents/jev_specialists.py b/examples/agents/jev_specialists.py index 1275163d..f1cee04c 100644 --- a/examples/agents/jev_specialists.py +++ b/examples/agents/jev_specialists.py @@ -1,6 +1,6 @@ """Ten Jev specialists used by the routing examples.""" -from conductor.ai.agents import JevAgent +from conductor.ai.agents import DecisionAgent SPECIALTIES = { "duplicate_charge": ( @@ -98,7 +98,7 @@ def specialists(): return { - name: JevAgent( + name: DecisionAgent( name=f"jev_{name}", model="jev-1.13", questions={ diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index b42b7934..6f622b5c 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -50,6 +50,7 @@ def get_weather(city: str) -> str: LocalCodeExecutor, ServerlessCodeExecutor, ) +from conductor.ai.agents.decision import DecisionAgent from conductor.ai.agents.jev import JevAgent # Exceptions @@ -233,6 +234,7 @@ def resolve_credentials(task: object, names: list) -> dict: from conductor.ai.agents.tracing import is_tracing_enabled __all__ = [ + "DecisionAgent", "JevAgent", # OpenAI Agents SDK compatibility "Runner", diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index 583b11bc..e3ffa24d 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -75,8 +75,8 @@ class AgentDef: name: Agent name (becomes the Conductor workflow name). model: LLM model in ``"provider/model"`` format. Empty string means "inherit from parent agent at resolution time". - kind: Set to "jev" for a Jev agent. Omit for chat agents. - questions: Fixed Jev questions, or omit and supply context.questions at runtime. + kind: Set to "decision" for a decision agent. Omit for chat agents. + questions: Fixed decision questions, or omit and supply context.questions at runtime. instructions: System prompt — a string or the decorated callable. tools: List of tools for the agent. guardrails: List of guardrails for the agent. @@ -113,6 +113,7 @@ class AgentDef: prefill_tools: List[Any] = field(default_factory=list) kind: Optional[str] = None questions: Optional[Dict[str, Any]] = None + provider: Optional[str] = None # ── @agent decorator ──────────────────────────────────────────────────── @@ -225,8 +226,8 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": return obj if isinstance(obj, AgentDef) or (callable(obj) and hasattr(obj, "_agent_def")): ad: AgentDef = obj if isinstance(obj, AgentDef) else obj._agent_def - if ad.kind == "jev": - from conductor.ai.agents.jev import JevAgent + if ad.kind in ("decision", "jev"): + from conductor.ai.agents.decision import DecisionAgent if ( ad.tools @@ -242,8 +243,14 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": or ad.max_tokens is not None or ad.temperature is not None ): - raise ValueError("Jev AgentDef does not support chat configuration") - return JevAgent(ad.name, model=ad.model, questions=ad.questions, metadata=ad.metadata) + raise ValueError("Decision AgentDef does not support chat configuration") + return DecisionAgent( + ad.name, + model=ad.model, + provider=ad.provider, + questions=ad.questions, + metadata=ad.metadata, + ) if ad.kind is not None: raise ValueError(f"Unsupported agent kind: {ad.kind}") # Handle ClaudeCode: don't inherit parent model for claude-code agents @@ -944,7 +951,8 @@ def external(self) -> bool: ROUTER with a Jev selector, which is compiled locally by the server. """ return not self.model and not ( - self.strategy == Strategy.ROUTER and getattr(self.router, "kind", None) == "jev" + self.strategy == Strategy.ROUTER + and getattr(self.router, "kind", None) in ("decision", "jev") ) # ── Instance-method resolution ────────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index 9800bb6e..00ac96fb 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -40,12 +40,14 @@ def _serialize_agent(self, agent: "Agent") -> dict: if isinstance(agent, AgentDef): agent = _resolve_agent(agent) - if getattr(agent, "kind", None) == "jev": + if getattr(agent, "kind", None) in ("decision", "jev"): if agent.tools or agent.agents or agent.memory or agent.guardrails or agent.output_type: raise ValueError( - "Jev agents cannot contain chat tools, agents, memory, output schemas or guardrails" + "Decision agents cannot contain chat tools, agents, memory, output schemas or guardrails" ) - config = {"name": agent.name, "kind": "jev", "model": agent.model} + config = {"name": agent.name, "kind": "decision", "model": agent.model} + if getattr(agent, "provider", None) is not None: + config["provider"] = agent.provider if agent.questions is not None: config["questions"] = agent.questions if agent.metadata: diff --git a/src/conductor/ai/agents/decision.py b/src/conductor/ai/agents/decision.py new file mode 100644 index 00000000..698cab15 --- /dev/null +++ b/src/conductor/ai/agents/decision.py @@ -0,0 +1,25 @@ +"""Decision agent definitions. Inference and credentials stay on Conductor.""" + +from copy import deepcopy +from typing import Any, Dict, Optional + +from conductor.ai.agents.agent import Agent + + +class DecisionAgent(Agent): + """Supply questions here or through context.questions at runtime.""" + + kind = "decision" + + def __init__( + self, + name: str, + *, + model: str, + provider: Optional[str] = None, + questions: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + ): + super().__init__(name=name, model=model, metadata=metadata) + self.provider = provider + self.questions = deepcopy(questions) diff --git a/src/conductor/ai/agents/jev.py b/src/conductor/ai/agents/jev.py index f9d3e70f..cbb281bf 100644 --- a/src/conductor/ai/agents/jev.py +++ b/src/conductor/ai/agents/jev.py @@ -1,23 +1,7 @@ -"""Jev agent definitions. Inference and credentials stay on Conductor.""" +"""Compatibility import; new code should use DecisionAgent.""" -from copy import deepcopy -from typing import Any, Dict, Optional +from conductor.ai.agents.decision import DecisionAgent -from conductor.ai.agents.agent import Agent - -class JevAgent(Agent): - """Supply questions here or through context.questions at runtime.""" - - kind = "jev" - - def __init__( - self, - name: str, - *, - model: str, - questions: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, - ): - super().__init__(name=name, model=model, metadata=metadata) - self.questions = deepcopy(questions) +class JevAgent(DecisionAgent): + """Deprecated name for DecisionAgent; serializes kind="decision".""" diff --git a/src/conductor/ai/agents/result.py b/src/conductor/ai/agents/result.py index 41e8b335..e4560582 100644 --- a/src/conductor/ai/agents/result.py +++ b/src/conductor/ai/agents/result.py @@ -711,7 +711,8 @@ class EventType(str, Enum): """Types of events emitted during agent execution.""" THINKING = "thinking" - JEV = "jev" + DECISION = "decision" + JEV = "decision" # Compatibility alias TOOL_CALL = "tool_call" TOOL_RESULT = "tool_result" HANDOFF = "handoff" @@ -733,7 +734,7 @@ class AgentEvent: ``guardrail_pass``, ``guardrail_fail``). tool_name: Tool name (for ``tool_call``, ``tool_result``). args: Tool call arguments (for ``tool_call``). - result: Structured result for ``tool_result`` or ``jev``. + result: Structured result for ``tool_result`` or ``decision``. target: Target agent name (for ``handoff``). output: Final output (for ``done``). execution_id: The Conductor execution ID. diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index ad51a00e..8162efb7 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -292,6 +292,7 @@ def _normalize_handoff_target(task_ref: str) -> str: _NON_TOOL_TASK_TYPES = frozenset( { "LLM_CHAT_COMPLETE", + "DECISION_AGENT", "JEV_AGENT", "SWITCH", "DO_WHILE", @@ -441,9 +442,9 @@ def _task_events(task: Any, execution_id: str) -> Iterator[AgentEvent]: task_status = str(getattr(task, "status", "") or "").upper() output_data = getattr(task, "output_data", None) or {} - if task_type == "JEV_AGENT" and task_status == "COMPLETED": + if task_type in ("DECISION_AGENT", "JEV_AGENT") and task_status == "COMPLETED": yield AgentEvent( - type=EventType.JEV, + type=EventType.DECISION, content=task_ref, result=output_data, execution_id=execution_id, @@ -1721,8 +1722,7 @@ def _register_gate_worker( )(gate_worker) def _register_callback_worker( - self, agent_name: str, position: str, handlers, legacy_fn, - domain: "Optional[str]" = None + self, agent_name: str, position: str, handlers, legacy_fn, domain: "Optional[str]" = None ) -> None: """Register a before_model or after_model callback worker. @@ -2246,7 +2246,11 @@ def _ensure_models_for_agent(self, agent: Agent) -> None: def _collect(a: Agent) -> None: if not isinstance(a, Agent): return - if a.model and a.model not in seen and getattr(a, "kind", None) != "jev": + if ( + a.model + and a.model not in seen + and getattr(a, "kind", None) not in ("decision", "jev") + ): seen.add(a.model) for sub in a.agents: _collect(sub) @@ -3440,9 +3444,7 @@ def _register_graph_workers(self, raw_config: dict, workers: list) -> None: elif llm_role == "prep" and llm_var_name: wrapped = GraphWorkerEntry("make_llm_prep_worker", w.func, w.name, llm_var_name) elif llm_role == "finish" and llm_var_name: - wrapped = GraphWorkerEntry( - "make_llm_finish_worker", w.func, w.name, llm_var_name - ) + wrapped = GraphWorkerEntry("make_llm_finish_worker", w.func, w.name, llm_var_name) elif w.name in router_refs: is_dynamic = extra.get("is_dynamic_fanout", False) wrapped = GraphWorkerEntry( @@ -3787,7 +3789,7 @@ def _sse_to_agent_event(sse_event: Dict[str, Any], execution_id: str) -> Optiona return None return AgentEvent( - type=event_type, + type="decision" if event_type == "jev" else event_type, content=data.get("content"), tool_name=data.get("toolName"), args=data.get("args"), @@ -5174,8 +5176,10 @@ def _build_result_from_workflow( try: # Reuse the passed workflow when it already carries tasks; # otherwise fetch the full execution for enrichment. - full = wf if getattr(wf, "tasks", None) else self._workflow_client.get_workflow( - execution_id, include_tasks=True + full = ( + wf + if getattr(wf, "tasks", None) + else self._workflow_client.get_workflow(execution_id, include_tasks=True) ) tool_calls = self._extract_tool_calls(full) messages = self._extract_messages(full) diff --git a/src/conductor/client/workflow/task/ai_decision_task.py b/src/conductor/client/workflow/task/ai_decision_task.py index c1d54bdd..f795581e 100644 --- a/src/conductor/client/workflow/task/ai_decision_task.py +++ b/src/conductor/client/workflow/task/ai_decision_task.py @@ -1,18 +1,18 @@ from typing import Any, Dict, Optional -from conductor.client.workflow.task.task import TaskInterface +from conductor.client.workflow.task.task import ( + TaskInterface, + get_task_interface_list_as_workflow_task_list, +) from conductor.client.workflow.task.task_type import TaskType class AiDecisionTask(TaskInterface): - """Run a Jev choice decision using server-managed credentials. + """Describe a model decision and its branches for server-side routing. - State and question instructions must be nonempty strings. Questions must - contain exactly one choice question with 2 to 255 choices. The server validates - inputs after resolving workflow references. - - Output retains model, answers, usage, latencyMs and optional requestId. - Use output("selectedCase") with a separate SwitchTask to route the result. + Each choice contains a description and a list of SDK tasks. The server + constructs the decision and switch and exposes the selected branch result + through output("result"). Requires server support for this routing contract. """ def __init__( @@ -20,8 +20,11 @@ def __init__( task_ref_name: str, model: str, state: str, - questions: Dict[str, Any], + instructions: str, + choices: Dict[str, Any], + *, task_name: Optional[str] = None, + provider: Optional[str] = None, ) -> None: super().__init__( task_reference_name=task_ref_name, @@ -29,7 +32,15 @@ def __init__( task_name=task_name or "ai_decision", input_parameters={ "model": model, + **({"provider": provider} if provider is not None else {}), "state": state, - "questions": questions, + "instructions": instructions, + "choices": { + key: { + "description": choice["description"], + "tasks": get_task_interface_list_as_workflow_task_list(*choice["tasks"]), + } + for key, choice in choices.items() + }, }, ) diff --git a/tests/unit/ai/test_jev_agent.py b/tests/unit/ai/test_jev_agent.py index ac72b67d..fbacd5a4 100644 --- a/tests/unit/ai/test_jev_agent.py +++ b/tests/unit/ai/test_jev_agent.py @@ -24,7 +24,7 @@ def test_agent_def_and_convenience_class_serialize_identically(runtime): definition = AgentDef(name="ready", kind="jev", model="jev-1.13", questions=questions) expected = { "name": "ready", - "kind": "jev", + "kind": "decision", "model": "jev-1.13", "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, } @@ -68,7 +68,7 @@ def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, handle = runtime.start(definition, "Ready?", context={"questions": QUESTIONS}) assert handle.execution_id == "execution-1" payload = runtime._agent_client.start_agent.call_args.args[0] - assert payload["agentConfig"] == {"name": "ready", "kind": "jev", "model": "jev-1.13"} + assert payload["agentConfig"] == {"name": "ready", "kind": "decision", "model": "jev-1.13"} assert payload["context"]["questions"]["ready"] == {"type": "boolean", "instructions": "Ready?"} assert not runtime._workers_started with patch("time.sleep"): @@ -117,7 +117,7 @@ def test_nested_jev_example_routes_to_named_agents(monkeypatch): assert len(config["agents"]) == 3 assert sum(len(team["agents"]) for team in config["agents"]) == 10 for team in [config, *config["agents"]]: - assert team["router"]["kind"] == "jev" + assert team["router"]["kind"] == "decision" assert set(team["router"]["questions"]["agent"]["choices"]) == { child["name"] for child in team["agents"] } @@ -125,6 +125,6 @@ def test_nested_jev_example_routes_to_named_agents(monkeypatch): config = serializer.serialize(luna) assert config["router"]["model"] == "configured/luna-6" assert len(config["agents"]) == 10 - assert all(child["kind"] == "jev" for child in config["agents"]) + assert all(child["kind"] == "decision" for child in config["agents"]) assert config["maxTurns"] == 1 assert config["synthesize"] is False diff --git a/tests/unit/workflow/test_ai_decision_task.py b/tests/unit/workflow/test_ai_decision_task.py index f56e2e13..cdcda5ea 100644 --- a/tests/unit/workflow/test_ai_decision_task.py +++ b/tests/unit/workflow/test_ai_decision_task.py @@ -1,18 +1,19 @@ from conductor.client.http.api_client import ApiClient from conductor.client.workflow.task.ai_decision_task import AiDecisionTask +from conductor.client.workflow.task.inline import InlineTask +from conductor.client.workflow.task.switch_task import SwitchTask from conductor.client.workflow.task.task_type import TaskType from examples.agentic_workflows.ai_decision_routing import create_workflow def test_ai_decision_references_and_serialization(): - questions = { - "route": { - "type": "choice", - "instructions": "Choose a team.", - "choices": {"billing": "Payments", "technical": "Errors"}, - } + choices = { + "billing": {"description": "Payments", "tasks": [InlineTask("billing", "'billing'")]}, + "technical": {"description": "Errors", "tasks": [InlineTask("technical", "'technical'")]}, } - task = AiDecisionTask("decision", "jev-1.13", "${workflow.input.request}", questions) + task = AiDecisionTask( + "decision", "jev-1.13", "${workflow.input.request}", "Choose a team.", choices + ) serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) assert task.task_type == TaskType.AI_DECISION assert serialized["type"] == "AI_DECISION" @@ -21,40 +22,77 @@ def test_ai_decision_references_and_serialization(): assert serialized["inputParameters"] == { "model": "jev-1.13", "state": "${workflow.input.request}", - "questions": questions, + "instructions": "Choose a team.", + "choices": { + key: { + "description": choice["description"], + "tasks": [ + ApiClient().sanitize_for_serialization(choice["tasks"][0].to_workflow_task()) + ], + } + for key, choice in choices.items() + }, } assert task.input("state") == "${decision.input.state}" assert task.output() == "${decision.output}" - assert task.output("answers.route.choice") == "${decision.output.answers.route.choice}" + assert task.output("answers") == "${decision.output.answers}" + assert task.output("result") == "${decision.output.result}" assert task.output("selectedCase") == "${decision.output.selectedCase}" def test_custom_name_and_input_reference(): - task = AiDecisionTask("decision", "jev-1.13", "request", {}, task_name="choose_team") - task.input_parameter("questions", "${workflow.input.questions}") + task = AiDecisionTask( + "decision", "jev-1.13", "request", "Choose a team.", {}, task_name="choose_team" + ) + task.input_parameter("state", "${workflow.input.request}") definition = task.to_workflow_task() assert definition.name == "choose_team" - assert definition.input_parameters["questions"] == "${workflow.input.questions}" + assert definition.input_parameters["state"] == "${workflow.input.request}" -def test_routing_example_preserves_decision_output(): +def test_routing_example_serializes_one_task_with_branches(): definition = ApiClient().sanitize_for_serialization(create_workflow(None).to_workflow_def()) - decision, switch, result = definition["tasks"] + assert len(definition["tasks"]) == 1 + decision = definition["tasks"][0] assert decision["type"] == "AI_DECISION" assert decision["inputParameters"]["state"] == "${workflow.input.request}" - assert switch["type"] == "SWITCH" - assert switch["evaluatorType"] == "value-param" - assert switch["inputParameters"]["switchCaseValue"] == "${decision.output.selectedCase}" - assert set(switch["decisionCases"]) == {"billing", "technical"} - for team, tasks in switch["decisionCases"].items(): + assert decision["taskReferenceName"] == "route_request" + choices = decision["inputParameters"]["choices"] + assert set(choices) == {"billing", "technical"} + for team, choice in choices.items(): + assert choice["description"] + tasks = choice["tasks"] assert len(tasks) == 1 assert tasks[0]["type"] == "INLINE" assert tasks[0]["taskReferenceName"] == f"handle_{team}" assert tasks[0]["inputParameters"]["request"] == "${workflow.input.request}" - assert result["type"] == "INLINE" - assert result["inputParameters"]["billing"] == "${handle_billing.output.result}" - assert result["inputParameters"]["technical"] == "${handle_technical.output.result}" assert definition["outputParameters"] == { - "decision": "${decision.output}", - "result": "${selected_result.output.result}", + "result": "${route_request.output.result}", } + + +def test_nested_branch_serialization_preserves_task_references(): + branch = SwitchTask("nested", "${workflow.input.priority}") + branch.switch_case( + "high", [InlineTask("urgent", "$.request", {"request": "${workflow.input.request}"})] + ) + task = AiDecisionTask( + "route", + "jev-1.13", + "request", + "Choose a team.", + { + "billing": {"description": "Payments", "tasks": [branch]}, + "technical": { + "description": "Errors", + "tasks": [InlineTask("technical", "'technical'")], + }, + }, + ) + serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) + nested = serialized["inputParameters"]["choices"]["billing"]["tasks"][0] + assert nested["type"] == "SWITCH" + assert nested["inputParameters"]["switchCaseValue"] == "${workflow.input.priority}" + urgent = nested["decisionCases"]["high"][0] + assert urgent["taskReferenceName"] == "urgent" + assert urgent["inputParameters"]["request"] == "${workflow.input.request}" From 0c06528a4473304c252c20106141e83012898c9b Mon Sep 17 00:00:00 2001 From: nicholascole Date: Fri, 25 Sep 2026 23:36:27 -0700 Subject: [PATCH 09/14] fix(agents): align decision APIs with server contract --- CHANGELOG.md | 2 + docs/agents/reference/agent-definition.md | 5 +- examples/agentic_workflows/README.md | 17 ++-- .../agentic_workflows/ai_decision_routing.py | 53 +++++++---- examples/agents/README.md | 8 +- .../{jev_agent.py => decision_agent.py} | 6 +- ...ed_triage.py => decision_nested_triage.py} | 16 ++-- ...specialists.py => decision_specialists.py} | 4 +- ..._jev_triage.py => luna_decision_triage.py} | 11 ++- src/conductor/ai/agents/__init__.py | 2 - src/conductor/ai/agents/agent.py | 7 +- src/conductor/ai/agents/config_serializer.py | 2 +- src/conductor/ai/agents/jev.py | 7 -- src/conductor/ai/agents/result.py | 1 - src/conductor/ai/agents/runtime/runtime.py | 13 +-- .../client/workflow/task/ai_decision_task.py | 27 ++---- tests/unit/ai/test_agent_schema_contract.py | 6 +- ...st_jev_agent.py => test_decision_agent.py} | 59 ++++++++++-- tests/unit/ai/test_tool_extraction.py | 17 ++-- tests/unit/workflow/test_ai_decision_task.py | 93 +++++++------------ 20 files changed, 188 insertions(+), 168 deletions(-) rename examples/agents/{jev_agent.py => decision_agent.py} (90%) rename examples/agents/{jev_nested_triage.py => decision_nested_triage.py} (81%) rename examples/agents/{jev_specialists.py => decision_specialists.py} (97%) rename examples/agents/{luna_jev_triage.py => luna_decision_triage.py} (84%) delete mode 100644 src/conductor/ai/agents/jev.py rename tests/unit/ai/{test_jev_agent.py => test_decision_agent.py} (71%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a98406b..0a57a5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `DecisionAgent` provides server-backed structured decisions with optional provider selection - Worker isolation mode: `CONDUCTOR_WORKER_ISOLATION=thread` runs every worker as a thread instead of a `multiprocessing.Process` (default `process` is unchanged — `spawn` remains the start method). For environments where multiprocessing's fork+exec bootstraps (spawn children, `resource_tracker`) fail, e.g. Firecracker microVM guests. Thread-mode tradeoffs: no per-worker force-kill (shutdown is cooperative), CPU-bound workers share the GIL, and `signal.signal` becomes a no-op off the main thread. Implementation: the Windows-only Process→Thread shim moved to `conductor.client.automator.worker_isolation` (the private `worker_manager._patch_conductor_use_threads_on_windows` helper is removed — the Windows gate calls `apply_thread_isolation()` directly) and now also swaps the logging-relay `Queue` for a plain `queue.Queue` - Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased) @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Decision agents and events use the canonical wire name `decision`; agent configurations use `kind: "decision"` and compiled inference tasks use `DECISION_AGENT` - **Multiprocessing start method now defaults to `spawn` on all platforms** (was `fork` everywhere except Windows). `fork` caused silently-restarting `SIGSEGV` worker subprocesses on macOS (exitcode `-11`) and fork-with-held-lock deadlocks on POSIX. - Legacy metrics emit unchanged by default; no env var required - `metrics_collector.py` is now a compatibility shim; `from conductor.client.telemetry.metrics_collector import MetricsCollector` continues to work diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 0bf2199d..4dc0283a 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -15,6 +15,9 @@ maintained in [api-reference.md](../api-reference.md) and Use `DecisionAgent(name, model="jev-1.13", questions=questions)` or `AgentDef(name=name, kind="decision", model="jev-1.13", questions=questions)`. +Pass `provider="typesafe"` (or another configured provider) to override the +server's default decision provider. Arbitrary agent metadata may be supplied with +`metadata={...}`. Questions are dictionaries with `instructions` and a `type`: `choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` returns a probability. Omit questions to supply `context={"questions": questions}`. @@ -25,7 +28,7 @@ to run. Call `handle.join()` and check `result.is_success` or `result.error`. and optional `requestId`. Credentials and inference stay on Conductor. No chat model or Python worker is needed. -[Example](../../../examples/agents/jev_agent.py) +[Example](../../../examples/agents/decision_agent.py) For server-side Decision routing, use `Agent(strategy="router", router=selector, agents=children)`. The Decision selector must have one fixed choice question whose diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index 42bde936..15c60f63 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -6,7 +6,7 @@ Chat examples use inline ChatMessage objects for system prompts. No named prompt ## Prerequisites -The AI decision example requires the server-side `AI_DECISION` routing contract below and Decision credentials. No chat model or Python worker is needed. The other examples require: +The AI decision example requires server-side `AI_DECISION` support and Decision credentials. No chat model or Python worker is needed. The other examples require: - Conductor server with AI/LLM support running (e.g., `http://localhost:7001/api`) - LLM provider named `openai` configured with a valid API key @@ -16,7 +16,7 @@ The AI decision example requires the server-side `AI_DECISION` routing contract | Example | Description | Interactive? | Pattern | |---------|-------------|:------------:|---------| -| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AiDecisionTask with inline branches | +| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AI_DECISION + SwitchTask + InlineTask | | [llm_chat.py](llm_chat.py) | Automated multi-turn science Q&A between two LLMs | No | LoopTask + LLM_CHAT_COMPLETE + worker for history | | [llm_chat_human_in_loop.py](llm_chat_human_in_loop.py) | Interactive chat with WAIT task pauses for user input | Yes | LoopTask + WaitTask + LLM_CHAT_COMPLETE | | [multiagent_chat.py](multiagent_chat.py) | Multi-agent debate with moderator routing between panelists | No | LoopTask + SwitchTask + SetVariableTask + JavaScript routing | @@ -44,13 +44,16 @@ python examples/agentic_workflows/function_calling_example.py ## Key Patterns -### AI decision routing contract (pending server support) +### AI decision routing -The SDK emits one `AI_DECISION` task with `model`, `state`, `instructions`, and `choices` in `inputParameters`. Each choice has a `description` and a `tasks` list of serialized workflow tasks. +`AiDecisionTask` emits `model`, optional `provider`, `state`, and exactly one +choice question in `questions`. The server performs inference and returns the +structured decision plus `selectedCase`. -The server compiles the decision and switch before resolving branch references, runs only the selected branch, and exposes its final task's `output.result` as the original task's `output.result`. If that task has no `result` field, use its full output. Preserve the decision's `model`, `answers`, `usage`, `latencyMs`, optional `requestId`, and `selectedCase` alongside `result`. - -Require nonempty state and instructions, 2 to 255 choices, and a nonempty task list per choice. Propagate branch failures. No SDK-generated switch or result collector is needed. +Add an explicit `SwitchTask` using `decision.output("selectedCase")`, define the +branch tasks on that switch, and collect the selected branch result separately. +Agent definitions with a `DecisionAgent` router are different: the server compiles +their selector, switch, and selected child workflow automatically. ### Passing dynamic messages to LLM_CHAT_COMPLETE diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index 5e772671..a92a82f2 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -1,7 +1,6 @@ -"""Let the server select and run an inline branch using decision inference. +"""Route a request with AI_DECISION, SWITCH, and an inline branch. Requires server-side AI_DECISION support and inference credentials. No worker needed. -The server compiles instructions/choices into inference and a SWITCH. Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing """ @@ -14,10 +13,27 @@ from conductor.client.workflow.conductor_workflow import ConductorWorkflow from conductor.client.workflow.task.ai_decision_task import AiDecisionTask from conductor.client.workflow.task.inline import InlineTask +from conductor.client.workflow.task.switch_task import SwitchTask def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkflow: workflow = ConductorWorkflow(executor=executor, name="ai_decision_routing", version=1) + decision = AiDecisionTask( + task_ref_name="decision", + model=model, + provider=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.", + }, + } + }, + ) billing = InlineTask( "handle_billing", script='({team: "billing", nextAction: "review_invoice", ' @@ -30,25 +46,24 @@ def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkf 'message: "Collect error logs and steps to reproduce.", request: $.request})', bindings={"request": workflow.input("request")}, ) - route = AiDecisionTask( - task_ref_name="route_request", - model=model, - provider=provider, - state=workflow.input("request"), - instructions="Choose the team best suited to handle this request.", - choices={ - "billing": { - "description": "Payments, invoices, refunds, or subscriptions.", - "tasks": [billing], - }, - "technical": { - "description": "Errors, outages, or product troubleshooting.", - "tasks": [technical], - }, + route = SwitchTask("route_request", decision.output("selectedCase")) + route.switch_case("billing", [billing]) + route.switch_case("technical", [technical]) + result = InlineTask( + "selected_result", + script="$.billing || $.technical", + bindings={ + "billing": billing.output("result"), + "technical": technical.output("result"), }, ) - workflow.add(route) - workflow.output_parameters({"result": route.output("result")}) + workflow >> decision >> route >> result + workflow.output_parameters( + { + "decision": decision.output(), + "result": result.output("result"), + } + ) return workflow diff --git a/examples/agents/README.md b/examples/agents/README.md index 690f6f45..c4879349 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -27,10 +27,10 @@ Framework-specific examples are in [ADK](adk/README.md), [LangGraph](langgraph/README.md), and [OpenAI Agents SDK](openai/README.md). Review tool side effects before using real credentials. -Jev: [jev_agent.py](jev_agent.py) compiles by default. Pass `--run` for inference. +Decision: [decision_agent.py](decision_agent.py) compiles by default. Pass `--run` for inference. -- `jev_nested_triage.py`: Jev department selection, Jev specialist selection, then a Jev specialist. -- `luna_jev_triage.py --model INTEGRATION/luna-6`: Luna selects one of ten Jev specialists. +- `decision_nested_triage.py`: Decision department selection, Decision specialist selection, then a Decision specialist. +- `luna_decision_triage.py --model INTEGRATION/luna-6`: Luna selects one of ten Decision specialists. Both compile by default. Pass `--run` for inference. These require the server's -Jev router support and structured output for single-turn routers without synthesis. +Decision router support and structured output for single-turn routers without synthesis. diff --git a/examples/agents/jev_agent.py b/examples/agents/decision_agent.py similarity index 90% rename from examples/agents/jev_agent.py rename to examples/agents/decision_agent.py index e4785b68..cbc4e448 100644 --- a/examples/agents/jev_agent.py +++ b/examples/agents/decision_agent.py @@ -1,4 +1,4 @@ -"""Compile a Jev agent. Pass --run for inference. Configure credentials on Conductor.""" +"""Compile a Decision agent. Pass --run for inference. Configure credentials on Conductor.""" import argparse import json @@ -12,7 +12,7 @@ def support_agent(): return DecisionAgent( - name="jev_support_agent", + name="decision_support_agent", model="jev-1.13", questions={ "department": { @@ -30,7 +30,7 @@ def support_agent(): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--run", action="store_true", help="Start live Jev inference") + parser.add_argument("--run", action="store_true", help="Start live Decision inference") args = parser.parse_args() config = Configuration( server_api_url=os.environ.get("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") diff --git a/examples/agents/jev_nested_triage.py b/examples/agents/decision_nested_triage.py similarity index 81% rename from examples/agents/jev_nested_triage.py rename to examples/agents/decision_nested_triage.py index 6242879a..aefb4baa 100644 --- a/examples/agents/jev_nested_triage.py +++ b/examples/agents/decision_nested_triage.py @@ -1,6 +1,6 @@ -"""Three levels: Jev department selector, Jev specialist selector, Jev specialist. +"""Three levels of Decision selectors and specialists. -Requires server support for Jev routers. Compile by default, --run for inference. +Requires server support for Decision routers. Compile by default, --run for inference. """ import argparse @@ -9,7 +9,7 @@ from conductor.ai.agents import Agent, AgentRuntime, DecisionAgent, Strategy from conductor.client.configuration.configuration import Configuration -from jev_specialists import SPECIALTIES, specialists +from decision_specialists import SPECIALTIES, specialists PROMPT = "Our latest invoice has two settled charges with different transaction IDs for the same purchase." @@ -44,18 +44,18 @@ def triage_agent(): } departments.append( routing_team( - f"jev_{department}_team", + f"decision_{department}_team", list(members.values()), {agent.name: SPECIALTIES[name][1] for name, agent in members.items()}, ) ) return routing_team( - "jev_nested_triage", + "decision_nested_triage", departments, { - "jev_billing_team": "Charges, refunds and subscriptions", - "jev_technical_team": "API errors, outages, integrations and setup", - "jev_account_team": "Access, security and privacy", + "decision_billing_team": "Charges, refunds and subscriptions", + "decision_technical_team": "API errors, outages, integrations and setup", + "decision_account_team": "Access, security and privacy", }, ) diff --git a/examples/agents/jev_specialists.py b/examples/agents/decision_specialists.py similarity index 97% rename from examples/agents/jev_specialists.py rename to examples/agents/decision_specialists.py index f1cee04c..ec03acb1 100644 --- a/examples/agents/jev_specialists.py +++ b/examples/agents/decision_specialists.py @@ -1,4 +1,4 @@ -"""Ten Jev specialists used by the routing examples.""" +"""Ten Decision specialists used by the routing examples.""" from conductor.ai.agents import DecisionAgent @@ -99,7 +99,7 @@ def specialists(): return { name: DecisionAgent( - name=f"jev_{name}", + name=f"decision_{name}", model="jev-1.13", questions={ "action": { diff --git a/examples/agents/luna_jev_triage.py b/examples/agents/luna_decision_triage.py similarity index 84% rename from examples/agents/luna_jev_triage.py rename to examples/agents/luna_decision_triage.py index 16f63da2..257d604d 100644 --- a/examples/agents/luna_jev_triage.py +++ b/examples/agents/luna_decision_triage.py @@ -1,6 +1,7 @@ -"""Luna-6 selects one of ten Jev specialists using server-side routing. +"""A Luna-6 chat router selects one of ten Decision specialists. -Pass --model INTEGRATION/luna-6. Compile by default, --run for inference. +Unlike a Decision router, this example requires a parent chat model. Pass +--model INTEGRATION/luna-6. Compile by default, --run for inference. """ import argparse @@ -9,7 +10,7 @@ from conductor.ai.agents import Agent, AgentRuntime, Strategy from conductor.client.configuration.configuration import Configuration -from jev_specialists import SPECIALTIES, specialists +from decision_specialists import SPECIALTIES, specialists def triage_agent(model): @@ -18,11 +19,11 @@ def triage_agent(model): f"{agent.name}: {SPECIALTIES[name][1]}" for name, agent in candidates.items() ) return Agent( - name="luna_jev_triage", + name="luna_decision_triage", model=model, strategy=Strategy.ROUTER, router=Agent( - name="luna_jev_selector", + name="luna_decision_selector", model=model, instructions=f"Select exactly one specialist for the request. Return only its agent name.\n{descriptions}", ), diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index 6f622b5c..f8296ab4 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -51,7 +51,6 @@ def get_weather(city: str) -> str: ServerlessCodeExecutor, ) from conductor.ai.agents.decision import DecisionAgent -from conductor.ai.agents.jev import JevAgent # Exceptions from conductor.ai.agents.exceptions import ( @@ -235,7 +234,6 @@ def resolve_credentials(task: object, names: list) -> dict: __all__ = [ "DecisionAgent", - "JevAgent", # OpenAI Agents SDK compatibility "Runner", "RunResult", diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index e3ffa24d..3519c1f3 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -226,7 +226,7 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": return obj if isinstance(obj, AgentDef) or (callable(obj) and hasattr(obj, "_agent_def")): ad: AgentDef = obj if isinstance(obj, AgentDef) else obj._agent_def - if ad.kind in ("decision", "jev"): + if ad.kind == "decision": from conductor.ai.agents.decision import DecisionAgent if ( @@ -948,11 +948,10 @@ def external(self) -> bool: """``True`` if this agent references an external workflow (no local definition). An agent with no model references an existing workflow, except a - ROUTER with a Jev selector, which is compiled locally by the server. + ROUTER with a Decision selector, which is compiled locally by the server. """ return not self.model and not ( - self.strategy == Strategy.ROUTER - and getattr(self.router, "kind", None) in ("decision", "jev") + self.strategy == Strategy.ROUTER and getattr(self.router, "kind", None) == "decision" ) # ── Instance-method resolution ────────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index 00ac96fb..c0c0108e 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -40,7 +40,7 @@ def _serialize_agent(self, agent: "Agent") -> dict: if isinstance(agent, AgentDef): agent = _resolve_agent(agent) - if getattr(agent, "kind", None) in ("decision", "jev"): + if getattr(agent, "kind", None) == "decision": if agent.tools or agent.agents or agent.memory or agent.guardrails or agent.output_type: raise ValueError( "Decision agents cannot contain chat tools, agents, memory, output schemas or guardrails" diff --git a/src/conductor/ai/agents/jev.py b/src/conductor/ai/agents/jev.py deleted file mode 100644 index cbb281bf..00000000 --- a/src/conductor/ai/agents/jev.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Compatibility import; new code should use DecisionAgent.""" - -from conductor.ai.agents.decision import DecisionAgent - - -class JevAgent(DecisionAgent): - """Deprecated name for DecisionAgent; serializes kind="decision".""" diff --git a/src/conductor/ai/agents/result.py b/src/conductor/ai/agents/result.py index e4560582..3a06d29c 100644 --- a/src/conductor/ai/agents/result.py +++ b/src/conductor/ai/agents/result.py @@ -712,7 +712,6 @@ class EventType(str, Enum): THINKING = "thinking" DECISION = "decision" - JEV = "decision" # Compatibility alias TOOL_CALL = "tool_call" TOOL_RESULT = "tool_result" HANDOFF = "handoff" diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index 8162efb7..bc73cc40 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -293,7 +293,6 @@ def _normalize_handoff_target(task_ref: str) -> str: { "LLM_CHAT_COMPLETE", "DECISION_AGENT", - "JEV_AGENT", "SWITCH", "DO_WHILE", "INLINE", @@ -442,7 +441,7 @@ def _task_events(task: Any, execution_id: str) -> Iterator[AgentEvent]: task_status = str(getattr(task, "status", "") or "").upper() output_data = getattr(task, "output_data", None) or {} - if task_type in ("DECISION_AGENT", "JEV_AGENT") and task_status == "COMPLETED": + if task_type == "DECISION_AGENT" and task_status == "COMPLETED": yield AgentEvent( type=EventType.DECISION, content=task_ref, @@ -2246,11 +2245,7 @@ def _ensure_models_for_agent(self, agent: Agent) -> None: def _collect(a: Agent) -> None: if not isinstance(a, Agent): return - if ( - a.model - and a.model not in seen - and getattr(a, "kind", None) not in ("decision", "jev") - ): + if a.model and a.model not in seen and getattr(a, "kind", None) != "decision": seen.add(a.model) for sub in a.agents: _collect(sub) @@ -2331,7 +2326,7 @@ def plan( Args: agent: The Agent or AgentDef to compile. prompt: Optional input to include in the compilation request. - context: Optional run context, including dynamic Jev questions. + context: Optional run context, including dynamic Decision questions. Returns: The raw server response dict with ``workflowDef`` and @@ -3789,7 +3784,7 @@ def _sse_to_agent_event(sse_event: Dict[str, Any], execution_id: str) -> Optiona return None return AgentEvent( - type="decision" if event_type == "jev" else event_type, + type=event_type, content=data.get("content"), tool_name=data.get("toolName"), args=data.get("args"), diff --git a/src/conductor/client/workflow/task/ai_decision_task.py b/src/conductor/client/workflow/task/ai_decision_task.py index f795581e..ccae1d93 100644 --- a/src/conductor/client/workflow/task/ai_decision_task.py +++ b/src/conductor/client/workflow/task/ai_decision_task.py @@ -1,18 +1,15 @@ from typing import Any, Dict, Optional -from conductor.client.workflow.task.task import ( - TaskInterface, - get_task_interface_list_as_workflow_task_list, -) +from conductor.client.workflow.task.task import TaskInterface from conductor.client.workflow.task.task_type import TaskType class AiDecisionTask(TaskInterface): - """Describe a model decision and its branches for server-side routing. + """Run one choice decision using server-managed inference credentials. - Each choice contains a description and a list of SDK tasks. The server - constructs the decision and switch and exposes the selected branch result - through output("result"). Requires server support for this routing contract. + The server returns the structured decision and ``selectedCase``. Compose + this task with :class:`SwitchTask` in the workflow definition to run the + selected branch. """ def __init__( @@ -20,10 +17,9 @@ def __init__( task_ref_name: str, model: str, state: str, - instructions: str, - choices: Dict[str, Any], - *, + questions: Dict[str, Any], task_name: Optional[str] = None, + *, provider: Optional[str] = None, ) -> None: super().__init__( @@ -34,13 +30,6 @@ def __init__( "model": model, **({"provider": provider} if provider is not None else {}), "state": state, - "instructions": instructions, - "choices": { - key: { - "description": choice["description"], - "tasks": get_task_interface_list_as_workflow_task_list(*choice["tasks"]), - } - for key, choice in choices.items() - }, + "questions": questions, }, ) diff --git a/tests/unit/ai/test_agent_schema_contract.py b/tests/unit/ai/test_agent_schema_contract.py index dd481167..a3881e62 100644 --- a/tests/unit/ai/test_agent_schema_contract.py +++ b/tests/unit/ai/test_agent_schema_contract.py @@ -51,8 +51,8 @@ def test_agent_schema_rejects_unknown_root_fields(): jsonschema.validate({"name": "valid_name", "unknownField": True}, schema) -def test_agent_schema_accepts_jev_definition(): - from conductor.ai.agents import JevAgent +def test_agent_schema_accepts_decision_definition(): + from conductor.ai.agents import DecisionAgent questions = { "team": {"type": "choice", "instructions": "Choose", "choices": {"a": "A", "b": "B"}}, @@ -61,5 +61,5 @@ def test_agent_schema_accepts_jev_definition(): } schema = json.loads(SCHEMA_PATH.read_text()) for fixed_questions in (questions, None): - agent = JevAgent("jev", model="jev-1.13", questions=fixed_questions) + agent = DecisionAgent("decision", model="jev-1.13", questions=fixed_questions) jsonschema.validate(AgentConfigSerializer().serialize(agent), schema) diff --git a/tests/unit/ai/test_jev_agent.py b/tests/unit/ai/test_decision_agent.py similarity index 71% rename from tests/unit/ai/test_jev_agent.py rename to tests/unit/ai/test_decision_agent.py index fbacd5a4..34f0594e 100644 --- a/tests/unit/ai/test_jev_agent.py +++ b/tests/unit/ai/test_decision_agent.py @@ -3,7 +3,7 @@ import pytest -from conductor.ai.agents import AgentDef, AgentRuntime, JevAgent +from conductor.ai.agents import AgentDef, AgentRuntime, DecisionAgent from conductor.ai.agents.config_serializer import AgentConfigSerializer from conductor.ai.agents.runtime.config import AgentConfig @@ -21,7 +21,7 @@ def runtime(): def test_agent_def_and_convenience_class_serialize_identically(runtime): questions = QUESTIONS - definition = AgentDef(name="ready", kind="jev", model="jev-1.13", questions=questions) + definition = AgentDef(name="ready", kind="decision", model="jev-1.13", questions=questions) expected = { "name": "ready", "kind": "decision", @@ -30,7 +30,9 @@ def test_agent_def_and_convenience_class_serialize_identically(runtime): } assert AgentConfigSerializer().serialize(definition) == expected assert ( - AgentConfigSerializer().serialize(JevAgent("ready", model="jev-1.13", questions=questions)) + AgentConfigSerializer().serialize( + DecisionAgent("ready", model="jev-1.13", questions=questions) + ) == expected ) runtime.plan(definition, "Ready to ship") @@ -38,13 +40,50 @@ def test_agent_def_and_convenience_class_serialize_identically(runtime): {"agentConfig": expected, "prompt": "Ready to ship"} ) runtime._agent_client.start_agent.assert_not_called() - runtime._ensure_models_for_agent(JevAgent("ready", model="jev-1.13")) + runtime._ensure_models_for_agent(DecisionAgent("ready", model="jev-1.13")) + + +def test_decision_agent_serializes_provider_and_metadata(): + expected = { + "name": "ready", + "kind": "decision", + "model": "jev-1.13", + "provider": "typesafe", + "questions": QUESTIONS, + "metadata": {"owner": "support"}, + } + serializer = AgentConfigSerializer() + assert ( + serializer.serialize( + DecisionAgent( + "ready", + model="jev-1.13", + provider="typesafe", + questions=QUESTIONS, + metadata={"owner": "support"}, + ) + ) + == expected + ) + assert ( + serializer.serialize( + AgentDef( + name="ready", + kind="decision", + model="jev-1.13", + provider="typesafe", + questions=QUESTIONS, + metadata={"owner": "support"}, + ) + ) + == expected + ) @pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"]) def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, status): - definition = AgentDef(name="ready", kind="jev", model="jev-1.13") + definition = AgentDef(name="ready", kind="decision", model="jev-1.13") raw = { "model": "jev-1.13", "answers": {"ready": {"type": "boolean", "probability": 0.8}}, @@ -81,7 +120,7 @@ def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, def test_async_start_and_dynamic_compile(runtime): - definition = AgentDef(name="ready", kind="jev", model="jev-1.13") + definition = AgentDef(name="ready", kind="decision", model="jev-1.13") context = {"questions": QUESTIONS} runtime.plan(definition, "Ready?", context=context) assert ( @@ -103,13 +142,13 @@ def test_async_start_and_dynamic_compile(runtime): ) -def test_nested_jev_example_routes_to_named_agents(monkeypatch): +def test_nested_decision_example_routes_to_named_agents(monkeypatch): import runpy from pathlib import Path examples = Path(__file__).resolve().parents[3] / "examples" / "agents" monkeypatch.syspath_prepend(str(examples)) - nested = runpy.run_path(str(examples / "jev_nested_triage.py"))["triage_agent"]() + nested = runpy.run_path(str(examples / "decision_nested_triage.py"))["triage_agent"]() serializer = AgentConfigSerializer() config = serializer.serialize(nested) assert config["external"] is False @@ -121,7 +160,9 @@ def test_nested_jev_example_routes_to_named_agents(monkeypatch): assert set(team["router"]["questions"]["agent"]["choices"]) == { child["name"] for child in team["agents"] } - luna = runpy.run_path(str(examples / "luna_jev_triage.py"))["triage_agent"]("configured/luna-6") + luna = runpy.run_path(str(examples / "luna_decision_triage.py"))["triage_agent"]( + "configured/luna-6" + ) config = serializer.serialize(luna) assert config["router"]["model"] == "configured/luna-6" assert len(config["agents"]) == 10 diff --git a/tests/unit/ai/test_tool_extraction.py b/tests/unit/ai/test_tool_extraction.py index 2588513d..0f411f2f 100644 --- a/tests/unit/ai/test_tool_extraction.py +++ b/tests/unit/ai/test_tool_extraction.py @@ -496,6 +496,7 @@ def test_absence_checks_can_still_fail(self, support_run): failed = {c.check for case in suite.cases for c in case.checks if not c.passed} assert failed == {"tool_not_used:lookup_order", "no_handoff_to:billing"} + # ── Agent-internal workers, and the tools that look like them ─────────── @@ -590,7 +591,7 @@ def test_survives_an_unreachable_execution(self, runtime): @pytest.mark.parametrize("status", ["COMPLETED", "IN_PROGRESS", "FAILED"]) -def test_jev_is_inference_not_a_tool(runtime, status): +def test_decision_is_inference_not_a_tool(runtime, status): output = { "model": "jev-1.13", "answers": {"department": {"type": "choice", "choice": "billing"}}, @@ -599,9 +600,9 @@ def test_jev_is_inference_not_a_tool(runtime, status): "requestId": "request-1", } task = FakeTask( - task_type="JEV_AGENT", - task_def_name="JEV_AGENT", - reference_task_name="support_jev", + task_type="DECISION_AGENT", + task_def_name="DECISION_AGENT", + reference_task_name="support_decision", output_data=output, status=status, ) @@ -611,10 +612,14 @@ def test_jev_is_inference_not_a_tool(runtime, status): assert not any(e.type in (EventType.TOOL_CALL, EventType.TOOL_RESULT) for e in events) if status == "COMPLETED": sse = runtime._sse_to_agent_event( - {"event": "jev", "data": {"content": "support_jev", "result": output}}, "wf-1" + { + "event": "decision", + "data": {"content": "support_decision", "result": output}, + }, + "wf-1", ) assert events[0] == sse - assert events[0].type == EventType.JEV + assert events[0].type == EventType.DECISION assert events[0].result == output elif status == "FAILED": assert any(e.type == EventType.ERROR for e in events) diff --git a/tests/unit/workflow/test_ai_decision_task.py b/tests/unit/workflow/test_ai_decision_task.py index cdcda5ea..0082eff3 100644 --- a/tests/unit/workflow/test_ai_decision_task.py +++ b/tests/unit/workflow/test_ai_decision_task.py @@ -1,19 +1,18 @@ from conductor.client.http.api_client import ApiClient from conductor.client.workflow.task.ai_decision_task import AiDecisionTask -from conductor.client.workflow.task.inline import InlineTask -from conductor.client.workflow.task.switch_task import SwitchTask from conductor.client.workflow.task.task_type import TaskType from examples.agentic_workflows.ai_decision_routing import create_workflow def test_ai_decision_references_and_serialization(): - choices = { - "billing": {"description": "Payments", "tasks": [InlineTask("billing", "'billing'")]}, - "technical": {"description": "Errors", "tasks": [InlineTask("technical", "'technical'")]}, + questions = { + "route": { + "type": "choice", + "instructions": "Choose a team.", + "choices": {"billing": "Payments", "technical": "Errors"}, + } } - task = AiDecisionTask( - "decision", "jev-1.13", "${workflow.input.request}", "Choose a team.", choices - ) + task = AiDecisionTask("decision", "jev-1.13", "${workflow.input.request}", questions) serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) assert task.task_type == TaskType.AI_DECISION assert serialized["type"] == "AI_DECISION" @@ -22,77 +21,55 @@ def test_ai_decision_references_and_serialization(): assert serialized["inputParameters"] == { "model": "jev-1.13", "state": "${workflow.input.request}", - "instructions": "Choose a team.", - "choices": { - key: { - "description": choice["description"], - "tasks": [ - ApiClient().sanitize_for_serialization(choice["tasks"][0].to_workflow_task()) - ], - } - for key, choice in choices.items() - }, + "questions": questions, } assert task.input("state") == "${decision.input.state}" assert task.output() == "${decision.output}" - assert task.output("answers") == "${decision.output.answers}" - assert task.output("result") == "${decision.output.result}" + assert task.output("answers.route.choice") == "${decision.output.answers.route.choice}" assert task.output("selectedCase") == "${decision.output.selectedCase}" def test_custom_name_and_input_reference(): task = AiDecisionTask( - "decision", "jev-1.13", "request", "Choose a team.", {}, task_name="choose_team" + "decision", + "jev-1.13", + "request", + {}, + "choose_team", + provider="typesafe", ) task.input_parameter("state", "${workflow.input.request}") + task.input_parameter("questions", "${workflow.input.questions}") definition = task.to_workflow_task() assert definition.name == "choose_team" assert definition.input_parameters["state"] == "${workflow.input.request}" + assert definition.input_parameters["questions"] == "${workflow.input.questions}" + assert definition.input_parameters["provider"] == "typesafe" -def test_routing_example_serializes_one_task_with_branches(): +def test_routing_example_preserves_decision_output(): definition = ApiClient().sanitize_for_serialization(create_workflow(None).to_workflow_def()) - assert len(definition["tasks"]) == 1 - decision = definition["tasks"][0] + decision, switch, result = definition["tasks"] assert decision["type"] == "AI_DECISION" assert decision["inputParameters"]["state"] == "${workflow.input.request}" - assert decision["taskReferenceName"] == "route_request" - choices = decision["inputParameters"]["choices"] - assert set(choices) == {"billing", "technical"} - for team, choice in choices.items(): - assert choice["description"] - tasks = choice["tasks"] + assert decision["taskReferenceName"] == "decision" + assert set(decision["inputParameters"]["questions"]["route"]["choices"]) == { + "billing", + "technical", + } + assert switch["type"] == "SWITCH" + assert switch["evaluatorType"] == "value-param" + assert switch["inputParameters"]["switchCaseValue"] == "${decision.output.selectedCase}" + assert set(switch["decisionCases"]) == {"billing", "technical"} + for team, tasks in switch["decisionCases"].items(): assert len(tasks) == 1 assert tasks[0]["type"] == "INLINE" assert tasks[0]["taskReferenceName"] == f"handle_{team}" assert tasks[0]["inputParameters"]["request"] == "${workflow.input.request}" + assert result["type"] == "INLINE" + assert result["inputParameters"]["billing"] == "${handle_billing.output.result}" + assert result["inputParameters"]["technical"] == "${handle_technical.output.result}" assert definition["outputParameters"] == { - "result": "${route_request.output.result}", + "decision": "${decision.output}", + "result": "${selected_result.output.result}", } - - -def test_nested_branch_serialization_preserves_task_references(): - branch = SwitchTask("nested", "${workflow.input.priority}") - branch.switch_case( - "high", [InlineTask("urgent", "$.request", {"request": "${workflow.input.request}"})] - ) - task = AiDecisionTask( - "route", - "jev-1.13", - "request", - "Choose a team.", - { - "billing": {"description": "Payments", "tasks": [branch]}, - "technical": { - "description": "Errors", - "tasks": [InlineTask("technical", "'technical'")], - }, - }, - ) - serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) - nested = serialized["inputParameters"]["choices"]["billing"]["tasks"][0] - assert nested["type"] == "SWITCH" - assert nested["inputParameters"]["switchCaseValue"] == "${workflow.input.priority}" - urgent = nested["decisionCases"]["high"][0] - assert urgent["taskReferenceName"] == "urgent" - assert urgent["inputParameters"]["request"] == "${workflow.input.request}" From 669045e8ad9835d602035b4a8b6b8c10d2006773 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Sat, 26 Sep 2026 00:57:16 -0700 Subject: [PATCH 10/14] feat(agents): streamline decision examples --- docs/agents/reference/agent-definition.md | 22 ++-- examples/agents/README.md | 12 +- examples/agents/decision_agent.py | 54 ++++++--- examples/agents/decision_nested_triage.py | 86 -------------- examples/agents/decision_specialists.py | 122 ------------------- examples/agents/decision_tool.py | 55 +++++++++ examples/agents/luna_decision_triage.py | 64 ---------- tests/unit/ai/test_decision_agent.py | 137 +++++----------------- 8 files changed, 138 insertions(+), 414 deletions(-) delete mode 100644 examples/agents/decision_nested_triage.py delete mode 100644 examples/agents/decision_specialists.py create mode 100644 examples/agents/decision_tool.py delete mode 100644 examples/agents/luna_decision_triage.py diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 4dc0283a..35c9dc11 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -13,24 +13,20 @@ maintained in [api-reference.md](../api-reference.md) and ## Decision agents -Use `DecisionAgent(name, model="jev-1.13", questions=questions)` or -`AgentDef(name=name, kind="decision", model="jev-1.13", questions=questions)`. +Use `DecisionAgent(name, model="jev-1.13", questions=questions)` as the +`router` of an `Agent(strategy="router", ...)`. A Decision configuration is a +selector marker; it cannot be compiled, deployed, or run as a standalone agent. Pass `provider="typesafe"` (or another configured provider) to override the server's default decision provider. Arbitrary agent metadata may be supplied with `metadata={...}`. Questions are dictionaries with `instructions` and a `type`: `choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` -returns a probability. Omit questions to supply `context={"questions": questions}`. - -Use `runtime.plan(agent, prompt)` to compile or `runtime.start(agent, prompt)` -to run. Call `handle.join()` and check `result.is_success` or `result.error`. -`result.output["result"]` preserves `model`, `answers`, `usage`, `latencyMs` -and optional `requestId`. Credentials and inference stay on Conductor. -No chat model or Python worker is needed. +returns a probability. Decision routers require exactly one fixed `choice` +question whose keys match the executable child-agent names. [Example](../../../examples/agents/decision_agent.py) -For server-side Decision routing, use `Agent(strategy="router", router=selector, -agents=children)`. The Decision selector must have one fixed choice question whose -choice keys match the child agent names. No parent chat model is needed. -Routing runs one child and preserves its structured result. +The parent router needs no chat model or Python worker. The selected child is a +normal executable agent and may use its own chat model and tools. Routing runs +one child and preserves its result. For structured decision inference without a +child agent, use an `AiDecisionTask` in a workflow or a decision tool. diff --git a/examples/agents/README.md b/examples/agents/README.md index c4879349..9b527c03 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -27,10 +27,12 @@ Framework-specific examples are in [ADK](adk/README.md), [LangGraph](langgraph/README.md), and [OpenAI Agents SDK](openai/README.md). Review tool side effects before using real credentials. -Decision: [decision_agent.py](decision_agent.py) compiles by default. Pass `--run` for inference. +Decision: [decision_agent.py](decision_agent.py) uses a `DecisionAgent` only as a +router selector, with normal chat agents as executable children. It compiles by +default; pass `--run` for inference. -- `decision_nested_triage.py`: Decision department selection, Decision specialist selection, then a Decision specialist. -- `luna_decision_triage.py --model INTEGRATION/luna-6`: Luna selects one of ten Decision specialists. +`decision_tool.py` shows the other agent pattern: a normal chat agent calls +Decision inference as a server-side tool. Run it directly; no tool worker is needed. -Both compile by default. Pass `--run` for inference. These require the server's -Decision router support and structured output for single-turn routers without synthesis. +For direct use in a traditional workflow, see +[`ai_decision_routing.py`](../agentic_workflows/ai_decision_routing.py). diff --git a/examples/agents/decision_agent.py b/examples/agents/decision_agent.py index cbc4e448..9ca42b32 100644 --- a/examples/agents/decision_agent.py +++ b/examples/agents/decision_agent.py @@ -1,36 +1,53 @@ -"""Compile a Decision agent. Pass --run for inference. Configure credentials on Conductor.""" +"""Route to a chat agent with a Decision selector. Pass --run for inference.""" import argparse import json import os -from conductor.ai.agents import AgentRuntime, DecisionAgent +from conductor.ai.agents import Agent, AgentRuntime, DecisionAgent, Strategy from conductor.client.configuration.configuration import Configuration PROMPT = "The customer reports a duplicate charge on the latest invoice." +CHAT_MODEL = os.getenv("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini") def support_agent(): - return DecisionAgent( - name="decision_support_agent", - model="jev-1.13", - questions={ - "department": { - "type": "choice", - "instructions": "Which team should handle this issue?", - "choices": { - "billing": "Payment and invoice issues", - "technical": "Bugs and software issues", - "other": "Other requests", - }, - } - }, + billing = Agent( + name="billing", + model=CHAT_MODEL, + instructions="Help the customer with billing and invoice questions.", + ) + technical = Agent( + name="technical", + model=CHAT_MODEL, + instructions="Help the customer troubleshoot product issues.", + ) + return Agent( + name="decision_support_router", + strategy=Strategy.ROUTER, + router=DecisionAgent( + name="support_selector", + model="jev-1.13", + questions={ + "agent": { + "type": "choice", + "instructions": "Choose the right support team.", + "choices": { + "billing": "Payments and invoices", + "technical": "Product errors and troubleshooting", + }, + } + }, + ), + agents=[billing, technical], + max_turns=1, + synthesize=False, ) def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--run", action="store_true", help="Start live Decision inference") + parser.add_argument("--run", action="store_true") args = parser.parse_args() config = Configuration( server_api_url=os.environ.get("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") @@ -41,12 +58,13 @@ def main(): print(json.dumps(runtime.plan(agent, PROMPT), indent=2)) return + runtime.deploy(agent) handle = runtime.start(agent, PROMPT) print("Execution:", handle.execution_id) result = handle.join(timeout=120) if not result.is_success: raise RuntimeError(f"{result.status}: {result.error}") - print(json.dumps(result.output["result"], indent=2)) + print(json.dumps(result.output, indent=2)) if __name__ == "__main__": diff --git a/examples/agents/decision_nested_triage.py b/examples/agents/decision_nested_triage.py deleted file mode 100644 index aefb4baa..00000000 --- a/examples/agents/decision_nested_triage.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Three levels of Decision selectors and specialists. - -Requires server support for Decision routers. Compile by default, --run for inference. -""" - -import argparse -import json -import os - -from conductor.ai.agents import Agent, AgentRuntime, DecisionAgent, Strategy -from conductor.client.configuration.configuration import Configuration -from decision_specialists import SPECIALTIES, specialists - -PROMPT = "Our latest invoice has two settled charges with different transaction IDs for the same purchase." - - -def routing_team(name, children, descriptions): - return Agent( - name=name, - strategy=Strategy.ROUTER, - router=DecisionAgent( - name=f"{name}_selector", - model="jev-1.13", - questions={ - "agent": { - "type": "choice", - "instructions": "Choose the agent best suited to handle this request.", - "choices": descriptions, - } - }, - ), - agents=children, - max_turns=1, - synthesize=False, - ) - - -def triage_agent(): - leaves = specialists() - departments = [] - for department in ("billing", "technical", "account"): - members = { - name: agent for name, agent in leaves.items() if SPECIALTIES[name][0] == department - } - departments.append( - routing_team( - f"decision_{department}_team", - list(members.values()), - {agent.name: SPECIALTIES[name][1] for name, agent in members.items()}, - ) - ) - return routing_team( - "decision_nested_triage", - departments, - { - "decision_billing_team": "Charges, refunds and subscriptions", - "decision_technical_team": "API errors, outages, integrations and setup", - "decision_account_team": "Access, security and privacy", - }, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--run", action="store_true") - parser.add_argument("--prompt", default=PROMPT) - args = parser.parse_args() - config = Configuration( - server_api_url=os.getenv("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") - ) - with AgentRuntime(config) as runtime: - agent = triage_agent() - if not args.run: - print(json.dumps(runtime.plan(agent, args.prompt), indent=2)) - return - runtime.deploy(agent) - handle = runtime.start(agent, args.prompt) - print("Execution:", handle.execution_id) - result = handle.join(timeout=180) - if not result.is_success: - raise RuntimeError(result.error) - print(json.dumps(result.output, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/examples/agents/decision_specialists.py b/examples/agents/decision_specialists.py deleted file mode 100644 index ec03acb1..00000000 --- a/examples/agents/decision_specialists.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Ten Decision specialists used by the routing examples.""" - -from conductor.ai.agents import DecisionAgent - -SPECIALTIES = { - "duplicate_charge": ( - "billing", - "Duplicate payments or repeated invoice charges", - { - "check_transactions": "Compare charge identifiers and payment status", - "refund_duplicate": "Refund a confirmed duplicate settled charge", - "explain_hold": "Explain a pending authorization hold", - }, - ), - "refund": ( - "billing", - "Refund eligibility and refund progress", - { - "review_eligibility": "Check purchase date and refund policy", - "approve_refund": "Approve a refund supported by the supplied policy and evidence", - "trace_refund": "Trace an already approved refund", - }, - ), - "subscription": ( - "billing", - "Subscription renewals, cancellation and plan changes", - { - "review_renewal": "Check renewal timing and notice", - "cancel_renewal": "Schedule cancellation at the end of the current term", - "change_plan": "Review the requested plan change", - }, - ), - "api_errors": ( - "technical", - "API request failures, rate limits and invalid payloads", - { - "inspect_request": "Collect request identifiers and validate the payload", - "backoff": "Apply retry backoff for documented rate limiting", - "escalate_bug": "Escalate a reproducible server defect", - }, - ), - "outage": ( - "technical", - "Service outages and widespread availability failures", - { - "check_scope": "Verify the affected services and customers", - "open_incident": "Open an incident for confirmed widespread impact", - "update_incident": "Attach evidence to an existing incident", - }, - ), - "integration": ( - "technical", - "Webhook delivery and third-party integration failures", - { - "inspect_delivery": "Inspect delivery logs and endpoint responses", - "repair_config": "Correct an identified integration configuration error", - "replay_delivery": "Replay a failed delivery after checking idempotency", - }, - ), - "onboarding": ( - "technical", - "Initial setup, configuration and first successful request", - { - "collect_requirements": "Clarify the intended setup and environment", - "provide_setup": "Provide the relevant setup steps", - "validate_setup": "Verify the completed configuration", - }, - ), - "access": ( - "account", - "Login problems, account recovery and permissions", - { - "verify_identity": "Verify identity before changing account access", - "recover_account": "Use the approved account recovery process", - "review_permissions": "Review the required role and authorization", - }, - ), - "security": ( - "account", - "Suspected compromise, leaked credentials and abuse", - { - "investigate": "Collect evidence and assess the scope of compromise", - "contain": "Recommend revoking confirmed compromised credentials", - "escalate_security": "Escalate an active compromise to security response", - }, - ), - "privacy": ( - "account", - "Personal data access, export and deletion requests", - { - "verify_request": "Verify the requester and scope of the request", - "export_data": "Route a verified data export request", - "delete_data": "Route a verified deletion request through policy review", - }, - ), -} - - -def specialists(): - return { - name: DecisionAgent( - name=f"decision_{name}", - model="jev-1.13", - questions={ - "action": { - "type": "choice", - "instructions": f"Recommend the next step for {description}. Use only supplied evidence. Do not claim any action was executed.", - "choices": actions, - }, - "urgency": { - "type": "score", - "instructions": "Rate urgency from low to critical based on customer impact.", - "scale": ["low", "normal", "high", "critical"], - }, - "needs_more_information": { - "type": "boolean", - "instructions": "Is more information needed before taking the recommended action?", - }, - }, - ) - for name, (_, description, actions) in SPECIALTIES.items() - } diff --git a/examples/agents/decision_tool.py b/examples/agents/decision_tool.py new file mode 100644 index 00000000..c3ab62c6 --- /dev/null +++ b/examples/agents/decision_tool.py @@ -0,0 +1,55 @@ +"""Use Decision inference as a tool inside a normal chat agent.""" + +import os + +from conductor.ai.agents import Agent, AgentRuntime, ToolDef +from conductor.client.configuration.configuration import Configuration + +classify_request = ToolDef( + name="classify_request", + description="Classify the customer request before answering.", + tool_type="decision", + input_schema={ + "type": "object", + "properties": {"state": {"type": "string"}}, + "required": ["state"], + }, + config={ + "model": "jev-1.13", + "questions": { + "department": { + "type": "choice", + "instructions": "Choose the correct support department.", + "choices": { + "billing": "Payments and invoices", + "technical": "Product errors and troubleshooting", + }, + } + }, + }, +) + +support_agent = Agent( + name="decision_tool_support", + model=os.getenv("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini"), + instructions="Call classify_request before answering the customer.", + tools=[classify_request], +) + + +def main(): + config = Configuration( + server_api_url=os.getenv("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") + ) + with AgentRuntime(config) as runtime: + result = runtime.run( + support_agent, + "I was charged twice on my latest invoice.", + ) + if not result.is_success: + raise RuntimeError(result.error) + print(result.output) + + +if __name__ == "__main__": + main() diff --git a/examples/agents/luna_decision_triage.py b/examples/agents/luna_decision_triage.py deleted file mode 100644 index 257d604d..00000000 --- a/examples/agents/luna_decision_triage.py +++ /dev/null @@ -1,64 +0,0 @@ -"""A Luna-6 chat router selects one of ten Decision specialists. - -Unlike a Decision router, this example requires a parent chat model. Pass ---model INTEGRATION/luna-6. Compile by default, --run for inference. -""" - -import argparse -import json -import os - -from conductor.ai.agents import Agent, AgentRuntime, Strategy -from conductor.client.configuration.configuration import Configuration -from decision_specialists import SPECIALTIES, specialists - - -def triage_agent(model): - candidates = specialists() - descriptions = "\n".join( - f"{agent.name}: {SPECIALTIES[name][1]}" for name, agent in candidates.items() - ) - return Agent( - name="luna_decision_triage", - model=model, - strategy=Strategy.ROUTER, - router=Agent( - name="luna_decision_selector", - model=model, - instructions=f"Select exactly one specialist for the request. Return only its agent name.\n{descriptions}", - ), - agents=list(candidates.values()), - max_turns=1, - synthesize=False, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--model", default=os.getenv("CONDUCTOR_AGENT_LLM_MODEL")) - parser.add_argument("--run", action="store_true") - parser.add_argument( - "--prompt", default="Our webhook endpoint returns 503 and order notifications are missing." - ) - args = parser.parse_args() - if not args.model: - parser.error("Pass --model with your server's integration/luna-6 identifier") - config = Configuration( - server_api_url=os.getenv("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") - ) - with AgentRuntime(config) as runtime: - agent = triage_agent(args.model) - if not args.run: - print(json.dumps(runtime.plan(agent, args.prompt), indent=2)) - return - runtime.deploy(agent) - handle = runtime.start(agent, args.prompt) - print("Execution:", handle.execution_id) - result = handle.join(timeout=180) - if not result.is_success: - raise RuntimeError(result.error) - print(json.dumps(result.output, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/tests/unit/ai/test_decision_agent.py b/tests/unit/ai/test_decision_agent.py index 34f0594e..bc5820bb 100644 --- a/tests/unit/ai/test_decision_agent.py +++ b/tests/unit/ai/test_decision_agent.py @@ -1,25 +1,13 @@ -from unittest.mock import AsyncMock, patch -import asyncio +import runpy +from pathlib import Path -import pytest - -from conductor.ai.agents import AgentDef, AgentRuntime, DecisionAgent +from conductor.ai.agents import AgentDef, DecisionAgent from conductor.ai.agents.config_serializer import AgentConfigSerializer -from conductor.ai.agents.runtime.config import AgentConfig QUESTIONS = {"ready": {"type": "boolean", "instructions": "Ready?"}} -@pytest.fixture -def runtime(): - - with patch("conductor.client.orkes_clients.OrkesClients"): - with AgentRuntime(settings=AgentConfig(auto_start_workers=False)) as runtime: - yield runtime - - -def test_agent_def_and_convenience_class_serialize_identically(runtime): - +def test_agent_def_and_convenience_class_serialize_identically(): questions = QUESTIONS definition = AgentDef(name="ready", kind="decision", model="jev-1.13", questions=questions) expected = { @@ -35,12 +23,6 @@ def test_agent_def_and_convenience_class_serialize_identically(runtime): ) == expected ) - runtime.plan(definition, "Ready to ship") - runtime._agent_client.compile_agent.assert_called_once_with( - {"agentConfig": expected, "prompt": "Ready to ship"} - ) - runtime._agent_client.start_agent.assert_not_called() - runtime._ensure_models_for_agent(DecisionAgent("ready", model="jev-1.13")) def test_decision_agent_serializes_provider_and_metadata(): @@ -80,92 +62,35 @@ def test_decision_agent_serializes_provider_and_metadata(): ) -@pytest.mark.parametrize("status", ["COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"]) -def test_start_and_poll_preserves_structured_result_and_failure_reason(runtime, status): +def test_decision_router_example_uses_decision_only_as_selector(monkeypatch): + examples = Path(__file__).resolve().parents[3] / "examples" / "agents" + monkeypatch.syspath_prepend(str(examples)) + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") + serializer = AgentConfigSerializer() - definition = AgentDef(name="ready", kind="decision", model="jev-1.13") - raw = { - "model": "jev-1.13", - "answers": {"ready": {"type": "boolean", "probability": 0.8}}, - "usage": {"inputTokens": 10}, - "latencyMs": 42, - "requestId": "request-1", - } - runtime._agent_client.start_agent.return_value = { - "executionId": "execution-1", - "requiredWorkers": [], + simple = runpy.run_path(str(examples / "decision_agent.py"))["support_agent"]() + config = serializer.serialize(simple) + assert config["router"]["kind"] == "decision" + assert set(config["router"]["questions"]["agent"]["choices"]) == { + child["name"] for child in config["agents"] } - runtime._agent_client.get_status.side_effect = [ - {"isComplete": False, "status": "RUNNING"}, - { - "isComplete": True, - "status": status, - "output": {"result": raw}, - "reasonForIncompletion": "provider failure" if status != "COMPLETED" else None, - }, - ] - handle = runtime.start(definition, "Ready?", context={"questions": QUESTIONS}) - assert handle.execution_id == "execution-1" - payload = runtime._agent_client.start_agent.call_args.args[0] - assert payload["agentConfig"] == {"name": "ready", "kind": "decision", "model": "jev-1.13"} - assert payload["context"]["questions"]["ready"] == {"type": "boolean", "instructions": "Ready?"} - assert not runtime._workers_started - with patch("time.sleep"): - result = handle.join(timeout=5) - assert runtime._agent_client.get_status.call_count == 2 - assert result.output["result"] == raw - assert result.is_success == (status == "COMPLETED") - assert result.error == (None if status == "COMPLETED" else "provider failure") - + assert all(child.get("kind") != "decision" for child in config["agents"]) + assert all(child["model"] == "configured/chat" for child in config["agents"]) -def test_async_start_and_dynamic_compile(runtime): - - definition = AgentDef(name="ready", kind="decision", model="jev-1.13") - context = {"questions": QUESTIONS} - runtime.plan(definition, "Ready?", context=context) - assert ( - runtime._agent_client.compile_agent.call_args.args[0]["context"]["questions"]["ready"][ - "type" - ] - == "boolean" - ) - runtime._agent_client.start_agent_async = AsyncMock( - return_value={"executionId": "async-1", "requiredWorkers": []} - ) - handle = asyncio.run(runtime.start_async(definition, "Ready?", context=context)) - assert handle.execution_id == "async-1" - assert ( - runtime._agent_client.start_agent_async.call_args.args[0]["context"]["questions"]["ready"][ - "type" - ] - == "boolean" - ) - - -def test_nested_decision_example_routes_to_named_agents(monkeypatch): - import runpy - from pathlib import Path +def test_decision_tool_example_serializes_as_server_side_tool(monkeypatch): examples = Path(__file__).resolve().parents[3] / "examples" / "agents" - monkeypatch.syspath_prepend(str(examples)) - nested = runpy.run_path(str(examples / "decision_nested_triage.py"))["triage_agent"]() - serializer = AgentConfigSerializer() - config = serializer.serialize(nested) - assert config["external"] is False - assert not config.get("model") - assert len(config["agents"]) == 3 - assert sum(len(team["agents"]) for team in config["agents"]) == 10 - for team in [config, *config["agents"]]: - assert team["router"]["kind"] == "decision" - assert set(team["router"]["questions"]["agent"]["choices"]) == { - child["name"] for child in team["agents"] - } - luna = runpy.run_path(str(examples / "luna_decision_triage.py"))["triage_agent"]( - "configured/luna-6" - ) - config = serializer.serialize(luna) - assert config["router"]["model"] == "configured/luna-6" - assert len(config["agents"]) == 10 - assert all(child["kind"] == "decision" for child in config["agents"]) - assert config["maxTurns"] == 1 - assert config["synthesize"] is False + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") + agent = runpy.run_path(str(examples / "decision_tool.py"))["support_agent"] + + config = AgentConfigSerializer().serialize(agent) + assert config["model"] == "configured/chat" + assert len(config["tools"]) == 1 + tool = config["tools"][0] + assert tool["name"] == "classify_request" + assert tool["toolType"] == "decision" + assert tool["config"]["model"] == "jev-1.13" + assert set(tool["config"]["questions"]["department"]["choices"]) == { + "billing", + "technical", + } From b01ea9fbd35d677d28555fc88a3a042fae851479 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Mon, 28 Sep 2026 16:26:44 -0700 Subject: [PATCH 11/14] feat(workflow): use decision-backed switch tasks --- docs/agents/reference/agent-definition.md | 3 +- examples/agentic_workflows/README.md | 19 ++--- .../agentic_workflows/ai_decision_routing.py | 57 +++++++------ examples/agents/README.md | 8 +- examples/agents/decision_agent.py | 6 +- examples/agents/decision_tool.py | 39 +++++---- .../client/http/models/workflow_task.py | 61 +++++++++++++- .../client/workflow/task/ai_decision_task.py | 35 -------- .../client/workflow/task/switch_task.py | 54 ++++++++++--- .../client/workflow/task/task_type.py | 1 - tests/unit/ai/test_decision_agent.py | 1 + tests/unit/workflow/test_ai_decision_task.py | 75 ----------------- .../workflow/test_decision_switch_task.py | 81 +++++++++++++++++++ 13 files changed, 267 insertions(+), 173 deletions(-) delete mode 100644 src/conductor/client/workflow/task/ai_decision_task.py delete mode 100644 tests/unit/workflow/test_ai_decision_task.py create mode 100644 tests/unit/workflow/test_decision_switch_task.py diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 35c9dc11..8b6583fc 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -29,4 +29,5 @@ question whose keys match the executable child-agent names. The parent router needs no chat model or Python worker. The selected child is a normal executable agent and may use its own chat model and tools. Routing runs one child and preserves its result. For structured decision inference without a -child agent, use an `AiDecisionTask` in a workflow or a decision tool. +child agent, use a `SwitchTask` with `evaluator_type="decision"` in a workflow or +a decision tool. diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index 15c60f63..b401cb46 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -6,7 +6,8 @@ Chat examples use inline ChatMessage objects for system prompts. No named prompt ## Prerequisites -The AI decision example requires server-side `AI_DECISION` support and Decision credentials. No chat model or Python worker is needed. The other examples require: +The decision routing example requires server-side decision-evaluator support and +Decision credentials. No chat model or Python worker is needed. The other examples require: - Conductor server with AI/LLM support running (e.g., `http://localhost:7001/api`) - LLM provider named `openai` configured with a valid API key @@ -16,7 +17,7 @@ The AI decision example requires server-side `AI_DECISION` support and Decision | Example | Description | Interactive? | Pattern | |---------|-------------|:------------:|---------| -| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | AI_DECISION + SwitchTask + InlineTask | +| [ai_decision_routing.py](ai_decision_routing.py) | Route requests and return the selected branch's result | No | Decision-backed SwitchTask + InlineTask | | [llm_chat.py](llm_chat.py) | Automated multi-turn science Q&A between two LLMs | No | LoopTask + LLM_CHAT_COMPLETE + worker for history | | [llm_chat_human_in_loop.py](llm_chat_human_in_loop.py) | Interactive chat with WAIT task pauses for user input | Yes | LoopTask + WaitTask + LLM_CHAT_COMPLETE | | [multiagent_chat.py](multiagent_chat.py) | Multi-agent debate with moderator routing between panelists | No | LoopTask + SwitchTask + SetVariableTask + JavaScript routing | @@ -46,14 +47,14 @@ python examples/agentic_workflows/function_calling_example.py ### AI decision routing -`AiDecisionTask` emits `model`, optional `provider`, `state`, and exactly one -choice question in `questions`. The server performs inference and returns the -structured decision plus `selectedCase`. +Use one `SwitchTask` with `evaluator_type="decision"`. Its input parameters contain +`model`, optional `provider`, `state`, and exactly one choice question in +`questions`; its `expression` names that question. The choice keys must match the +task's `decisionCases` keys. The server performs inference, writes the full +response and `selectedCase` to the SWITCH output, and runs the selected branch. -Add an explicit `SwitchTask` using `decision.output("selectedCase")`, define the -branch tasks on that switch, and collect the selected branch result separately. -Agent definitions with a `DecisionAgent` router are different: the server compiles -their selector, switch, and selected child workflow automatically. +Agent definitions with a `DecisionAgent` router compile the selector and selected +child workflow into this same decision-backed SWITCH shape automatically. ### Passing dynamic messages to LLM_CHAT_COMPLETE diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index a92a82f2..a60efb1d 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -1,6 +1,6 @@ -"""Route a request with AI_DECISION, SWITCH, and an inline branch. +"""Route a request with one decision-backed SWITCH and an inline branch. -Requires server-side AI_DECISION support and inference credentials. No worker needed. +The server evaluates the SWITCH and selects its branch. No Python worker is needed. Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing """ @@ -11,28 +11,36 @@ from conductor.client.configuration.configuration import Configuration from conductor.client.orkes_clients import OrkesClients from conductor.client.workflow.conductor_workflow import ConductorWorkflow -from conductor.client.workflow.task.ai_decision_task import AiDecisionTask from conductor.client.workflow.task.inline import InlineTask -from conductor.client.workflow.task.switch_task import SwitchTask +from conductor.client.workflow.task.switch_task import EvaluatorType, SwitchTask def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkflow: workflow = ConductorWorkflow(executor=executor, name="ai_decision_routing", version=1) - decision = AiDecisionTask( - task_ref_name="decision", - model=model, - provider=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.", - }, - } + decision = SwitchTask( + task_ref_name="route_request", + case_expression="route", + evaluator_type=EvaluatorType.DECISION, + input_parameters={ + "model": model, + **({"provider": provider} if provider is not None else {}), + "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.", + }, + } + }, }, + retry_count=3, + retry_logic="EXPONENTIAL_BACKOFF", + retry_delay_seconds=1, + backoff_scale_factor=2, + max_retry_delay_seconds=5, ) billing = InlineTask( "handle_billing", @@ -46,9 +54,8 @@ def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkf 'message: "Collect error logs and steps to reproduce.", request: $.request})', bindings={"request": workflow.input("request")}, ) - route = SwitchTask("route_request", decision.output("selectedCase")) - route.switch_case("billing", [billing]) - route.switch_case("technical", [technical]) + decision.switch_case("billing", [billing]) + decision.switch_case("technical", [technical]) result = InlineTask( "selected_result", script="$.billing || $.technical", @@ -57,10 +64,14 @@ def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkf "technical": technical.output("result"), }, ) - workflow >> decision >> route >> result + workflow >> decision >> result workflow.output_parameters( { - "decision": decision.output(), + "selectedCase": decision.output("selectedCase"), + "answers": decision.output("answers"), + "usage": decision.output("usage"), + "cost": decision.output("usage.cost"), + "latencyMs": decision.output("latencyMs"), "result": result.output("result"), } ) diff --git a/examples/agents/README.md b/examples/agents/README.md index 9b527c03..8772a14a 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -28,11 +28,13 @@ Framework-specific examples are in [ADK](adk/README.md), Review tool side effects before using real credentials. Decision: [decision_agent.py](decision_agent.py) uses a `DecisionAgent` only as a -router selector, with normal chat agents as executable children. It compiles by -default; pass `--run` for inference. +router selector, with normal chat agents as executable children. Deployment +compiles it into one decision-backed `SWITCH`; the selector is not deployed as a +standalone agent. It compiles by default; pass `--run` for inference. `decision_tool.py` shows the other agent pattern: a normal chat agent calls -Decision inference as a server-side tool. Run it directly; no tool worker is needed. +Decision inference as a server-side tool. The server maps the tool call to a +branchless decision-backed `SWITCH`; no tool worker is needed. For direct use in a traditional workflow, see [`ai_decision_routing.py`](../agentic_workflows/ai_decision_routing.py). diff --git a/examples/agents/decision_agent.py b/examples/agents/decision_agent.py index 9ca42b32..9774acbe 100644 --- a/examples/agents/decision_agent.py +++ b/examples/agents/decision_agent.py @@ -1,4 +1,8 @@ -"""Route to a chat agent with a Decision selector. Pass --run for inference.""" +"""Route to one of two chat agents with a DecisionAgent selector. + +Deployment compiles the router into one decision-backed SWITCH. DecisionAgent is +only the selector and is not deployed as a standalone agent. Pass --run for inference. +""" import argparse import json diff --git a/examples/agents/decision_tool.py b/examples/agents/decision_tool.py index c3ab62c6..34e0e93b 100644 --- a/examples/agents/decision_tool.py +++ b/examples/agents/decision_tool.py @@ -1,10 +1,33 @@ -"""Use Decision inference as a tool inside a normal chat agent.""" +"""Use Decision inference as a tool inside a normal chat agent. + +The server executes the tool as a branchless decision-backed SWITCH, so no Python +worker is needed. +""" import os from conductor.ai.agents import Agent, AgentRuntime, ToolDef from conductor.client.configuration.configuration import Configuration +decision_config = { + "model": "jev-1.13", + **( + {"provider": os.environ["CONDUCTOR_DECISION_PROVIDER"]} + if "CONDUCTOR_DECISION_PROVIDER" in os.environ + else {} + ), + "questions": { + "department": { + "type": "choice", + "instructions": "Choose the correct support department.", + "choices": { + "billing": "Payments and invoices", + "technical": "Product errors and troubleshooting", + }, + } + }, +} + classify_request = ToolDef( name="classify_request", description="Classify the customer request before answering.", @@ -14,19 +37,7 @@ "properties": {"state": {"type": "string"}}, "required": ["state"], }, - config={ - "model": "jev-1.13", - "questions": { - "department": { - "type": "choice", - "instructions": "Choose the correct support department.", - "choices": { - "billing": "Payments and invoices", - "technical": "Product errors and troubleshooting", - }, - } - }, - }, + config=decision_config, ) support_agent = Agent( diff --git a/src/conductor/client/http/models/workflow_task.py b/src/conductor/client/http/models/workflow_task.py index c135e479..9373bde8 100644 --- a/src/conductor/client/http/models/workflow_task.py +++ b/src/conductor/client/http/models/workflow_task.py @@ -78,6 +78,10 @@ class WorkflowTask: _loop_condition: str = field(default=None, repr=False) _loop_over: List['WorkflowTask'] = field(default=None, repr=False) _retry_count: int = field(default=None, repr=False) + _retry_logic: str = field(default=None, repr=False) + _retry_delay_seconds: int = field(default=None, repr=False) + _backoff_scale_factor: int = field(default=None, repr=False) + _max_retry_delay_seconds: int = field(default=None, repr=False) _evaluator_type: str = field(default=None, repr=False) _expression: str = field(default=None, repr=False) _workflow_task_type: str = field(default=None, repr=False) @@ -114,6 +118,10 @@ class WorkflowTask: 'loop_condition': 'str', 'loop_over': 'list[WorkflowTask]', 'retry_count': 'int', + 'retry_logic': 'str', + 'retry_delay_seconds': 'int', + 'backoff_scale_factor': 'int', + 'max_retry_delay_seconds': 'int', 'evaluator_type': 'str', 'expression': 'str', 'workflow_task_type': 'str', @@ -151,6 +159,10 @@ class WorkflowTask: 'loop_condition': 'loopCondition', 'loop_over': 'loopOver', 'retry_count': 'retryCount', + 'retry_logic': 'retryLogic', + 'retry_delay_seconds': 'retryDelaySeconds', + 'backoff_scale_factor': 'backoffScaleFactor', + 'max_retry_delay_seconds': 'maxRetryDelaySeconds', 'evaluator_type': 'evaluatorType', 'expression': 'expression', 'workflow_task_type': 'workflowTaskType', @@ -166,7 +178,8 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ dynamic_fork_tasks_input_param_name=None, default_case=None, fork_tasks=None, start_delay=None, sub_workflow_param=None, join_on=None, sink=None, optional=None, task_definition : 'TaskDef' =None, rate_limited=None, default_exclusive_join_task=None, async_complete=None, loop_condition=None, - loop_over=None, retry_count=None, evaluator_type=None, expression=None, + loop_over=None, retry_count=None, retry_logic=None, retry_delay_seconds=None, + backoff_scale_factor=None, max_retry_delay_seconds=None, evaluator_type=None, expression=None, workflow_task_type=None, on_state_change: Dict[str, StateChangeConfig] = None, cache_config: CacheConfig = None, join_status=None, permissive=None): # noqa: E501 """WorkflowTask - a model defined in Swagger""" # noqa: E501 @@ -197,6 +210,10 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ self._loop_condition = None self._loop_over = None self._retry_count = None + self._retry_logic = None + self._retry_delay_seconds = None + self._backoff_scale_factor = None + self._max_retry_delay_seconds = None self._evaluator_type = None self._expression = None self._workflow_task_type = None @@ -257,6 +274,14 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ self.loop_over = loop_over if retry_count is not None: self.retry_count = retry_count + if retry_logic is not None: + self.retry_logic = retry_logic + if retry_delay_seconds is not None: + self.retry_delay_seconds = retry_delay_seconds + if backoff_scale_factor is not None: + self.backoff_scale_factor = backoff_scale_factor + if max_retry_delay_seconds is not None: + self.max_retry_delay_seconds = max_retry_delay_seconds if evaluator_type is not None: self.evaluator_type = evaluator_type if expression is not None: @@ -839,6 +864,38 @@ def retry_count(self, retry_count): self._retry_count = retry_count + @property + def retry_logic(self): + return self._retry_logic + + @retry_logic.setter + def retry_logic(self, retry_logic): + self._retry_logic = retry_logic + + @property + def retry_delay_seconds(self): + return self._retry_delay_seconds + + @retry_delay_seconds.setter + def retry_delay_seconds(self, retry_delay_seconds): + self._retry_delay_seconds = retry_delay_seconds + + @property + def backoff_scale_factor(self): + return self._backoff_scale_factor + + @backoff_scale_factor.setter + def backoff_scale_factor(self, backoff_scale_factor): + self._backoff_scale_factor = backoff_scale_factor + + @property + def max_retry_delay_seconds(self): + return self._max_retry_delay_seconds + + @max_retry_delay_seconds.setter + def max_retry_delay_seconds(self, max_retry_delay_seconds): + self._max_retry_delay_seconds = max_retry_delay_seconds + @property def evaluator_type(self): """Gets the evaluator_type of this WorkflowTask. # noqa: E501 @@ -1027,4 +1084,4 @@ def __eq__(self, other): def __ne__(self, other): """Returns true if both objects are not equal""" - return not self == other \ No newline at end of file + return not self == other diff --git a/src/conductor/client/workflow/task/ai_decision_task.py b/src/conductor/client/workflow/task/ai_decision_task.py deleted file mode 100644 index ccae1d93..00000000 --- a/src/conductor/client/workflow/task/ai_decision_task.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Any, Dict, Optional - -from conductor.client.workflow.task.task import TaskInterface -from conductor.client.workflow.task.task_type import TaskType - - -class AiDecisionTask(TaskInterface): - """Run one choice decision using server-managed inference credentials. - - The server returns the structured decision and ``selectedCase``. Compose - this task with :class:`SwitchTask` in the workflow definition to run the - selected branch. - """ - - def __init__( - self, - task_ref_name: str, - model: str, - state: str, - questions: Dict[str, Any], - task_name: Optional[str] = None, - *, - provider: Optional[str] = None, - ) -> None: - super().__init__( - task_reference_name=task_ref_name, - task_type=TaskType.AI_DECISION, - task_name=task_name or "ai_decision", - input_parameters={ - "model": model, - **({"provider": provider} if provider is not None else {}), - "state": state, - "questions": questions, - }, - ) diff --git a/src/conductor/client/workflow/task/switch_task.py b/src/conductor/client/workflow/task/switch_task.py index a85fce91..f56e1ad6 100644 --- a/src/conductor/client/workflow/task/switch_task.py +++ b/src/conductor/client/workflow/task/switch_task.py @@ -1,30 +1,56 @@ from copy import deepcopy from enum import Enum -from typing import List +from typing import Any, Dict, List, Optional, Union from typing_extensions import Self from conductor.client.http.models.workflow_task import WorkflowTask -from conductor.client.workflow.task.task import TaskInterface, get_task_interface_list_as_workflow_task_list +from conductor.client.workflow.task.task import ( + TaskInterface, + get_task_interface_list_as_workflow_task_list, +) from conductor.client.workflow.task.task_type import TaskType class EvaluatorType(str, Enum): - JAVASCRIPT = "javascript", - ECMASCRIPT = "graaljs", + JAVASCRIPT = ("javascript",) + ECMASCRIPT = ("graaljs",) VALUE_PARAM = "value-param" + DECISION = "decision" class SwitchTask(TaskInterface): - def __init__(self, task_ref_name: str, case_expression: str, use_javascript: bool = False) -> Self: + def __init__( + self, + task_ref_name: str, + case_expression: str, + use_javascript: bool = False, + *, + evaluator_type: Optional[Union[EvaluatorType, str]] = None, + input_parameters: Optional[Dict[str, Any]] = None, + retry_count: Optional[int] = None, + retry_logic: Optional[str] = None, + retry_delay_seconds: Optional[int] = None, + backoff_scale_factor: Optional[int] = None, + max_retry_delay_seconds: Optional[int] = None, + ) -> Self: super().__init__( task_reference_name=task_ref_name, task_type=TaskType.SWITCH, + input_parameters=input_parameters, ) + if use_javascript and evaluator_type is not None: + raise ValueError("use_javascript and evaluator_type cannot both be set") self._default_case = None self._decision_cases = {} self._expression = deepcopy(case_expression) self._use_javascript = deepcopy(use_javascript) + self._switch_evaluator_type = deepcopy(evaluator_type) + self._retry_count = deepcopy(retry_count) + self._retry_logic = deepcopy(retry_logic) + self._retry_delay_seconds = deepcopy(retry_delay_seconds) + self._backoff_scale_factor = deepcopy(backoff_scale_factor) + self._max_retry_delay_seconds = deepcopy(max_retry_delay_seconds) def switch_case(self, case_name: str, tasks: List[TaskInterface]) -> Self: if isinstance(tasks, List): @@ -42,7 +68,14 @@ def default_case(self, tasks: List[TaskInterface]) -> Self: def to_workflow_task(self) -> WorkflowTask: workflow = super().to_workflow_task() - if self._use_javascript: + if self._switch_evaluator_type is not None: + workflow.evaluator_type = ( + self._switch_evaluator_type.value + if isinstance(self._switch_evaluator_type, EvaluatorType) + else self._switch_evaluator_type + ) + workflow.expression = self._expression + elif self._use_javascript: workflow.evaluator_type = EvaluatorType.ECMASCRIPT workflow.expression = self._expression else: @@ -56,7 +89,10 @@ def to_workflow_task(self) -> WorkflowTask: ) if self._default_case is None: self._default_case = [] - workflow.default_case = get_task_interface_list_as_workflow_task_list( - *self._default_case - ) + workflow.default_case = get_task_interface_list_as_workflow_task_list(*self._default_case) + workflow.retry_count = self._retry_count + workflow.retry_logic = self._retry_logic + workflow.retry_delay_seconds = self._retry_delay_seconds + workflow.backoff_scale_factor = self._backoff_scale_factor + workflow.max_retry_delay_seconds = self._max_retry_delay_seconds return workflow diff --git a/src/conductor/client/workflow/task/task_type.py b/src/conductor/client/workflow/task/task_type.py index 8628ffb3..36108f72 100644 --- a/src/conductor/client/workflow/task/task_type.py +++ b/src/conductor/client/workflow/task/task_type.py @@ -26,7 +26,6 @@ class TaskType(str, Enum): JSON_JQ_TRANSFORM = "JSON_JQ_TRANSFORM" SET_VARIABLE = "SET_VARIABLE" GET_DOCUMENT = "GET_DOCUMENT" - AI_DECISION = "AI_DECISION" LLM_GENERATE_EMBEDDINGS = "LLM_GENERATE_EMBEDDINGS" LLM_GET_EMBEDDINGS = "LLM_GET_EMBEDDINGS" LLM_TEXT_COMPLETE = "LLM_TEXT_COMPLETE" diff --git a/tests/unit/ai/test_decision_agent.py b/tests/unit/ai/test_decision_agent.py index bc5820bb..aed72916 100644 --- a/tests/unit/ai/test_decision_agent.py +++ b/tests/unit/ai/test_decision_agent.py @@ -81,6 +81,7 @@ def test_decision_router_example_uses_decision_only_as_selector(monkeypatch): def test_decision_tool_example_serializes_as_server_side_tool(monkeypatch): examples = Path(__file__).resolve().parents[3] / "examples" / "agents" monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") + monkeypatch.delenv("CONDUCTOR_DECISION_PROVIDER", raising=False) agent = runpy.run_path(str(examples / "decision_tool.py"))["support_agent"] config = AgentConfigSerializer().serialize(agent) diff --git a/tests/unit/workflow/test_ai_decision_task.py b/tests/unit/workflow/test_ai_decision_task.py deleted file mode 100644 index 0082eff3..00000000 --- a/tests/unit/workflow/test_ai_decision_task.py +++ /dev/null @@ -1,75 +0,0 @@ -from conductor.client.http.api_client import ApiClient -from conductor.client.workflow.task.ai_decision_task import AiDecisionTask -from conductor.client.workflow.task.task_type import TaskType -from examples.agentic_workflows.ai_decision_routing import create_workflow - - -def test_ai_decision_references_and_serialization(): - questions = { - "route": { - "type": "choice", - "instructions": "Choose a team.", - "choices": {"billing": "Payments", "technical": "Errors"}, - } - } - task = AiDecisionTask("decision", "jev-1.13", "${workflow.input.request}", questions) - serialized = ApiClient().sanitize_for_serialization(task.to_workflow_task()) - assert task.task_type == TaskType.AI_DECISION - assert serialized["type"] == "AI_DECISION" - assert serialized["name"] == "ai_decision" - assert serialized["taskReferenceName"] == "decision" - assert serialized["inputParameters"] == { - "model": "jev-1.13", - "state": "${workflow.input.request}", - "questions": questions, - } - assert task.input("state") == "${decision.input.state}" - assert task.output() == "${decision.output}" - assert task.output("answers.route.choice") == "${decision.output.answers.route.choice}" - assert task.output("selectedCase") == "${decision.output.selectedCase}" - - -def test_custom_name_and_input_reference(): - task = AiDecisionTask( - "decision", - "jev-1.13", - "request", - {}, - "choose_team", - provider="typesafe", - ) - task.input_parameter("state", "${workflow.input.request}") - task.input_parameter("questions", "${workflow.input.questions}") - definition = task.to_workflow_task() - assert definition.name == "choose_team" - assert definition.input_parameters["state"] == "${workflow.input.request}" - assert definition.input_parameters["questions"] == "${workflow.input.questions}" - assert definition.input_parameters["provider"] == "typesafe" - - -def test_routing_example_preserves_decision_output(): - definition = ApiClient().sanitize_for_serialization(create_workflow(None).to_workflow_def()) - decision, switch, result = definition["tasks"] - assert decision["type"] == "AI_DECISION" - assert decision["inputParameters"]["state"] == "${workflow.input.request}" - assert decision["taskReferenceName"] == "decision" - assert set(decision["inputParameters"]["questions"]["route"]["choices"]) == { - "billing", - "technical", - } - assert switch["type"] == "SWITCH" - assert switch["evaluatorType"] == "value-param" - assert switch["inputParameters"]["switchCaseValue"] == "${decision.output.selectedCase}" - assert set(switch["decisionCases"]) == {"billing", "technical"} - for team, tasks in switch["decisionCases"].items(): - assert len(tasks) == 1 - assert tasks[0]["type"] == "INLINE" - assert tasks[0]["taskReferenceName"] == f"handle_{team}" - assert tasks[0]["inputParameters"]["request"] == "${workflow.input.request}" - assert result["type"] == "INLINE" - assert result["inputParameters"]["billing"] == "${handle_billing.output.result}" - assert result["inputParameters"]["technical"] == "${handle_technical.output.result}" - assert definition["outputParameters"] == { - "decision": "${decision.output}", - "result": "${selected_result.output.result}", - } diff --git a/tests/unit/workflow/test_decision_switch_task.py b/tests/unit/workflow/test_decision_switch_task.py new file mode 100644 index 00000000..3068129e --- /dev/null +++ b/tests/unit/workflow/test_decision_switch_task.py @@ -0,0 +1,81 @@ +from conductor.client.http.api_client import ApiClient +from conductor.client.workflow.task.switch_task import SwitchTask +from examples.agentic_workflows.ai_decision_routing import create_workflow + + +def serialize(value): + return ApiClient().sanitize_for_serialization(value) + + +def test_direct_example_serializes_one_decision_backed_switch(): + definition = serialize(create_workflow(None, provider="typesafe").to_workflow_def()) + decision_tasks = [ + task for task in definition["tasks"] if task.get("evaluatorType") == "decision" + ] + + assert len(decision_tasks) == 1 + decision = decision_tasks[0] + assert decision["type"] == "SWITCH" + assert "AI_DECISION" not in str(definition) + assert decision["expression"] == "route" + + inputs = decision["inputParameters"] + assert inputs["model"] == "jev-1.13" + assert inputs["provider"] == "typesafe" + assert inputs["state"] == "${workflow.input.request}" + assert set(inputs["questions"]) == {"route"} + choice_keys = set(inputs["questions"][decision["expression"]]["choices"]) + assert choice_keys == set(decision["decisionCases"]) + + retry_fields = { + "retryCount": 3, + "retryLogic": "EXPONENTIAL_BACKOFF", + "retryDelaySeconds": 1, + "backoffScaleFactor": 2, + "maxRetryDelaySeconds": 5, + } + assert {key: decision[key] for key in retry_fields} == retry_fields + + assert definition["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}", + } + + +def test_direct_example_omits_unspecified_provider(): + definition = serialize(create_workflow(None).to_workflow_def()) + assert "provider" not in definition["tasks"][0]["inputParameters"] + + +def test_existing_value_param_switch_serialization_is_unchanged(): + task = SwitchTask("route", "${workflow.input.route}") + + assert serialize(task.to_workflow_task()) == { + "name": "route", + "taskReferenceName": "route", + "inputParameters": {"switchCaseValue": "${workflow.input.route}"}, + "type": "SWITCH", + "decisionCases": {}, + "defaultCase": [], + "evaluatorType": "value-param", + "expression": "switchCaseValue", + } + + +def test_existing_javascript_switch_serialization_is_unchanged(): + task = SwitchTask("route", "return $.route;", use_javascript=True) + + assert serialize(task.to_workflow_task()) == { + "name": "route", + "taskReferenceName": "route", + "inputParameters": {}, + "type": "SWITCH", + "decisionCases": {}, + "defaultCase": [], + "evaluatorType": "graaljs", + "expression": "return $.route;", + } From 20b6087a4969c4ca2409cb7643d650ef448cd2a0 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Mon, 28 Sep 2026 16:33:03 -0700 Subject: [PATCH 12/14] refactor(agents): use decision tools for routing --- CHANGELOG.md | 4 +- docs/agents/reference/agent-definition.md | 16 +-- examples/agentic_workflows/README.md | 4 +- examples/agents/README.md | 4 +- .../{decision_agent.py => decision_router.py} | 39 +++++--- src/conductor/ai/agents/__init__.py | 2 - src/conductor/ai/agents/agent.py | 51 +++------- src/conductor/ai/agents/config_serializer.py | 32 +++--- src/conductor/ai/agents/decision.py | 25 ----- src/conductor/ai/agents/runtime/runtime.py | 10 +- tests/unit/ai/test_agent_schema_contract.py | 26 ++--- tests/unit/ai/test_decision_agent.py | 97 ------------------ tests/unit/ai/test_decision_tool.py | 98 +++++++++++++++++++ tests/unit/ai/test_tool_extraction.py | 6 +- 14 files changed, 191 insertions(+), 223 deletions(-) rename examples/agents/{decision_agent.py => decision_router.py} (68%) delete mode 100644 src/conductor/ai/agents/decision.py delete mode 100644 tests/unit/ai/test_decision_agent.py create mode 100644 tests/unit/ai/test_decision_tool.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a57a5b8..79e8034b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `DecisionAgent` provides server-backed structured decisions with optional provider selection +- Decision `ToolDef` instances can be used as router selectors or as server-side agent tools - Worker isolation mode: `CONDUCTOR_WORKER_ISOLATION=thread` runs every worker as a thread instead of a `multiprocessing.Process` (default `process` is unchanged — `spawn` remains the start method). For environments where multiprocessing's fork+exec bootstraps (spawn children, `resource_tracker`) fail, e.g. Firecracker microVM guests. Thread-mode tradeoffs: no per-worker force-kill (shutdown is cooperative), CPU-bound workers share the GIL, and `signal.signal` becomes a no-op off the main thread. Implementation: the Windows-only Process→Thread shim moved to `conductor.client.automator.worker_isolation` (the private `worker_manager._patch_conductor_use_threads_on_windows` helper is removed — the Windows gate calls `apply_thread_isolation()` directly) and now also swaps the logging-relay `Queue` for a plain `queue.Queue` - Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased) @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Decision agents and events use the canonical wire name `decision`; agent configurations use `kind: "decision"` and compiled inference tasks use `DECISION_AGENT` +- Decision router tools serialize to the server's `kind: "decision"` selector marker, and all decision inference compiles to `SWITCH` tasks with `evaluatorType: "decision"` - **Multiprocessing start method now defaults to `spawn` on all platforms** (was `fork` everywhere except Windows). `fork` caused silently-restarting `SIGSEGV` worker subprocesses on macOS (exitcode `-11`) and fork-with-held-lock deadlocks on POSIX. - Legacy metrics emit unchanged by default; no env var required - `metrics_collector.py` is now a compatibility shim; `from conductor.client.telemetry.metrics_collector import MetricsCollector` continues to work diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 8b6583fc..9752499a 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -11,20 +11,20 @@ external-agent behavior. The complete constructor and serialization semantics ar maintained in [api-reference.md](../api-reference.md) and `AgentConfigSerializer`; use those sources when adding a newly supported field. -## Decision agents +## Decision routers -Use `DecisionAgent(name, model="jev-1.13", questions=questions)` as the -`router` of an `Agent(strategy="router", ...)`. A Decision configuration is a -selector marker; it cannot be compiled, deployed, or run as a standalone agent. -Pass `provider="typesafe"` (or another configured provider) to override the -server's default decision provider. Arbitrary agent metadata may be supplied with -`metadata={...}`. +Create a `ToolDef(tool_type="decision", config={...})` and use it as the +`router` of an `Agent(strategy="router", ...)`. The same decision tool primitive +can instead be included in a normal agent's `tools` list. It is never compiled, +deployed, or run as a standalone agent, and it needs no Python worker. +Set `config["provider"]` to override the server's default decision provider. Questions are dictionaries with `instructions` and a `type`: `choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` returns a probability. Decision routers require exactly one fixed `choice` question whose keys match the executable child-agent names. -[Example](../../../examples/agents/decision_agent.py) +[Router example](../../../examples/agents/decision_router.py) · +[Tool example](../../../examples/agents/decision_tool.py) The parent router needs no chat model or Python worker. The selected child is a normal executable agent and may use its own chat model and tools. Routing runs diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index b401cb46..4c9f8c26 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -53,8 +53,8 @@ Use one `SwitchTask` with `evaluator_type="decision"`. Its input parameters cont task's `decisionCases` keys. The server performs inference, writes the full response and `selectedCase` to the SWITCH output, and runs the selected branch. -Agent definitions with a `DecisionAgent` router compile the selector and selected -child workflow into this same decision-backed SWITCH shape automatically. +Agent definitions with a decision `ToolDef` in `router=` compile the selector and +selected child workflow into this same decision-backed SWITCH shape automatically. ### Passing dynamic messages to LLM_CHAT_COMPLETE diff --git a/examples/agents/README.md b/examples/agents/README.md index 8772a14a..40ec3d48 100644 --- a/examples/agents/README.md +++ b/examples/agents/README.md @@ -27,9 +27,9 @@ Framework-specific examples are in [ADK](adk/README.md), [LangGraph](langgraph/README.md), and [OpenAI Agents SDK](openai/README.md). Review tool side effects before using real credentials. -Decision: [decision_agent.py](decision_agent.py) uses a `DecisionAgent` only as a +Decision: [decision_router.py](decision_router.py) uses a decision `ToolDef` as a router selector, with normal chat agents as executable children. Deployment -compiles it into one decision-backed `SWITCH`; the selector is not deployed as a +compiles it into one decision-backed `SWITCH`; the tool is not deployed as a standalone agent. It compiles by default; pass `--run` for inference. `decision_tool.py` shows the other agent pattern: a normal chat agent calls diff --git a/examples/agents/decision_agent.py b/examples/agents/decision_router.py similarity index 68% rename from examples/agents/decision_agent.py rename to examples/agents/decision_router.py index 9774acbe..a7d0a77b 100644 --- a/examples/agents/decision_agent.py +++ b/examples/agents/decision_router.py @@ -1,21 +1,22 @@ -"""Route to one of two chat agents with a DecisionAgent selector. +"""Route to one of two chat agents with a decision tool. -Deployment compiles the router into one decision-backed SWITCH. DecisionAgent is -only the selector and is not deployed as a standalone agent. Pass --run for inference. +When a decision ToolDef is supplied as ``router=``, deployment compiles it into +one decision-backed SWITCH. It is a selector configuration, not a standalone +agent, and needs no Python worker. Pass --run for inference. """ import argparse import json import os -from conductor.ai.agents import Agent, AgentRuntime, DecisionAgent, Strategy +from conductor.ai.agents import Agent, AgentRuntime, Strategy, ToolDef from conductor.client.configuration.configuration import Configuration PROMPT = "The customer reports a duplicate charge on the latest invoice." CHAT_MODEL = os.getenv("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini") -def support_agent(): +def support_router(): billing = Agent( name="billing", model=CHAT_MODEL, @@ -26,13 +27,18 @@ def support_agent(): model=CHAT_MODEL, instructions="Help the customer troubleshoot product issues.", ) - return Agent( - name="decision_support_router", - strategy=Strategy.ROUTER, - router=DecisionAgent( - name="support_selector", - model="jev-1.13", - questions={ + selector = ToolDef( + name="support_selector", + description="Choose the support agent that should handle the request.", + tool_type="decision", + config={ + "model": "jev-1.13", + **( + {"provider": os.environ["CONDUCTOR_DECISION_PROVIDER"]} + if "CONDUCTOR_DECISION_PROVIDER" in os.environ + else {} + ), + "questions": { "agent": { "type": "choice", "instructions": "Choose the right support team.", @@ -42,7 +48,12 @@ def support_agent(): }, } }, - ), + }, + ) + return Agent( + name="decision_support_router", + strategy=Strategy.ROUTER, + router=selector, agents=[billing, technical], max_turns=1, synthesize=False, @@ -57,7 +68,7 @@ def main(): server_api_url=os.environ.get("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") ) with AgentRuntime(config) as runtime: - agent = support_agent() + agent = support_router() if not args.run: print(json.dumps(runtime.plan(agent, PROMPT), indent=2)) return diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index f8296ab4..fce5b308 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -50,7 +50,6 @@ def get_weather(city: str) -> str: LocalCodeExecutor, ServerlessCodeExecutor, ) -from conductor.ai.agents.decision import DecisionAgent # Exceptions from conductor.ai.agents.exceptions import ( @@ -233,7 +232,6 @@ def resolve_credentials(task: object, names: list) -> dict: from conductor.ai.agents.tracing import is_tracing_enabled __all__ = [ - "DecisionAgent", # OpenAI Agents SDK compatibility "Runner", "RunResult", diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index 3519c1f3..b13f6699 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -75,8 +75,6 @@ class AgentDef: name: Agent name (becomes the Conductor workflow name). model: LLM model in ``"provider/model"`` format. Empty string means "inherit from parent agent at resolution time". - kind: Set to "decision" for a decision agent. Omit for chat agents. - questions: Fixed decision questions, or omit and supply context.questions at runtime. instructions: System prompt — a string or the decorated callable. tools: List of tools for the agent. guardrails: List of guardrails for the agent. @@ -111,9 +109,6 @@ class AgentDef: credentials: List[Any] = field(default_factory=list) context_window_budget: Optional[int] = None prefill_tools: List[Any] = field(default_factory=list) - kind: Optional[str] = None - questions: Optional[Dict[str, Any]] = None - provider: Optional[str] = None # ── @agent decorator ──────────────────────────────────────────────────── @@ -226,33 +221,6 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": return obj if isinstance(obj, AgentDef) or (callable(obj) and hasattr(obj, "_agent_def")): ad: AgentDef = obj if isinstance(obj, AgentDef) else obj._agent_def - if ad.kind == "decision": - from conductor.ai.agents.decision import DecisionAgent - - if ( - ad.tools - or ad.agents - or ad.guardrails - or ad.instructions - or ad.func - or ad.local_code_execution - or ad.code_execution - or ad.cli_commands - or ad.credentials - or ad.prefill_tools - or ad.max_tokens is not None - or ad.temperature is not None - ): - raise ValueError("Decision AgentDef does not support chat configuration") - return DecisionAgent( - ad.name, - model=ad.model, - provider=ad.provider, - questions=ad.questions, - metadata=ad.metadata, - ) - if ad.kind is not None: - raise ValueError(f"Unsupported agent kind: {ad.kind}") # Handle ClaudeCode: don't inherit parent model for claude-code agents if isinstance(ad.model, ClaudeCode): resolved_model = ad.model @@ -530,8 +498,8 @@ class Agent: ``"handoff"``). Valid values: ``handoff``, ``sequential``, ``parallel``, ``router``, ``round_robin``, ``random``, ``swarm``, ``manual``. - router: For ``strategy="router"``, an :class:`Agent` or callable that - selects which sub-agent runs each turn. + router: For ``strategy="router"``, an :class:`Agent`, callable, or + decision :class:`ToolDef` that selects which sub-agent runs each turn. output_type: A Pydantic model or dataclass for structured output. guardrails: List of :class:`Guardrail` instances for input/output validation. memory: Optional :class:`ConversationMemory` for session management. @@ -589,7 +557,7 @@ def __init__( tools: Optional[List[Any]] = None, agents: Optional[List[Any]] = None, strategy: Union[str, Strategy] = Strategy.HANDOFF, - router: Optional[Union["Agent", Callable[..., Any]]] = None, + router: Optional[Any] = None, output_type: Optional[type] = None, guardrails: Optional[List[Any]] = None, memory: Optional[Any] = None, @@ -948,11 +916,16 @@ def external(self) -> bool: """``True`` if this agent references an external workflow (no local definition). An agent with no model references an existing workflow, except a - ROUTER with a Decision selector, which is compiled locally by the server. + ROUTER with a decision tool, which is compiled locally by the server. """ - return not self.model and not ( - self.strategy == Strategy.ROUTER and getattr(self.router, "kind", None) == "decision" - ) + decision_router = False + if self.strategy == Strategy.ROUTER and self.router is not None: + from conductor.ai.agents.tool import ToolDef + + decision_router = ( + isinstance(self.router, ToolDef) and self.router.tool_type == "decision" + ) + return not self.model and not decision_router # ── Instance-method resolution ────────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index c0c0108e..c4e3bd43 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -40,20 +40,6 @@ def _serialize_agent(self, agent: "Agent") -> dict: if isinstance(agent, AgentDef): agent = _resolve_agent(agent) - if getattr(agent, "kind", None) == "decision": - if agent.tools or agent.agents or agent.memory or agent.guardrails or agent.output_type: - raise ValueError( - "Decision agents cannot contain chat tools, agents, memory, output schemas or guardrails" - ) - config = {"name": agent.name, "kind": "decision", "model": agent.model} - if getattr(agent, "provider", None) is not None: - config["provider"] = agent.provider - if agent.questions is not None: - config["questions"] = agent.questions - if agent.metadata: - config["metadata"] = agent.metadata - return config - # Skill agents — emit the raw skill config so the server's # SkillNormalizer can compile sub-agents (e.g. gilfoyle, dinesh) # and tools (scripts, read_skill_file) into the workflow. @@ -471,8 +457,26 @@ def _serialize_handoff(self, handoff: Any, agent_name: str) -> dict: def _serialize_router(self, agent: "Agent") -> Any: """Serialize a router to either an AgentConfig or a WorkerRef.""" from conductor.ai.agents.agent import Agent as AgentClass + from conductor.ai.agents.tool import ToolDef router = agent.router + if isinstance(router, ToolDef): + if router.tool_type != "decision": + raise ValueError("Only a decision ToolDef can be used as a router") + config = router.config + if not config.get("model"): + raise ValueError("Decision router tool config requires 'model'") + if not config.get("questions"): + raise ValueError("Decision router tool config requires 'questions'") + result = { + "name": router.name, + "kind": "decision", + "model": config["model"], + "questions": config["questions"], + } + if config.get("provider") is not None: + result["provider"] = config["provider"] + return result if isinstance(router, AgentClass) or (hasattr(router, "model") and router.model): return self._serialize_agent(router) elif callable(router): diff --git a/src/conductor/ai/agents/decision.py b/src/conductor/ai/agents/decision.py deleted file mode 100644 index 698cab15..00000000 --- a/src/conductor/ai/agents/decision.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Decision agent definitions. Inference and credentials stay on Conductor.""" - -from copy import deepcopy -from typing import Any, Dict, Optional - -from conductor.ai.agents.agent import Agent - - -class DecisionAgent(Agent): - """Supply questions here or through context.questions at runtime.""" - - kind = "decision" - - def __init__( - self, - name: str, - *, - model: str, - provider: Optional[str] = None, - questions: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, - ): - super().__init__(name=name, model=model, metadata=metadata) - self.provider = provider - self.questions = deepcopy(questions) diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index bc73cc40..96795881 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -292,7 +292,6 @@ def _normalize_handoff_target(task_ref: str) -> str: _NON_TOOL_TASK_TYPES = frozenset( { "LLM_CHAT_COMPLETE", - "DECISION_AGENT", "SWITCH", "DO_WHILE", "INLINE", @@ -441,7 +440,12 @@ def _task_events(task: Any, execution_id: str) -> Iterator[AgentEvent]: task_status = str(getattr(task, "status", "") or "").upper() output_data = getattr(task, "output_data", None) or {} - if task_type == "DECISION_AGENT" and task_status == "COMPLETED": + if ( + task_type == "SWITCH" + and task_status == "COMPLETED" + and isinstance(output_data, dict) + and "answers" in output_data + ): yield AgentEvent( type=EventType.DECISION, content=task_ref, @@ -2245,7 +2249,7 @@ def _ensure_models_for_agent(self, agent: Agent) -> None: def _collect(a: Agent) -> None: if not isinstance(a, Agent): return - if a.model and a.model not in seen and getattr(a, "kind", None) != "decision": + if a.model and a.model not in seen: seen.add(a.model) for sub in a.agents: _collect(sub) diff --git a/tests/unit/ai/test_agent_schema_contract.py b/tests/unit/ai/test_agent_schema_contract.py index a3881e62..d19397ae 100644 --- a/tests/unit/ai/test_agent_schema_contract.py +++ b/tests/unit/ai/test_agent_schema_contract.py @@ -12,7 +12,7 @@ from conductor.ai.agents.config_serializer import AgentConfigSerializer from conductor.ai.agents.guardrail import RegexGuardrail from conductor.ai.agents.termination import TextMentionTermination -from conductor.ai.agents.tool import tool +from conductor.ai.agents.tool import ToolDef, tool ROOT = Path(__file__).resolve().parents[3] @@ -51,15 +51,17 @@ def test_agent_schema_rejects_unknown_root_fields(): jsonschema.validate({"name": "valid_name", "unknownField": True}, schema) -def test_agent_schema_accepts_decision_definition(): - from conductor.ai.agents import DecisionAgent - - questions = { - "team": {"type": "choice", "instructions": "Choose", "choices": {"a": "A", "b": "B"}}, - "priority": {"type": "score", "instructions": "Score", "scale": ["low", "high"]}, - "ready": {"type": "boolean", "instructions": "Ready?"}, - } +def test_agent_schema_accepts_decision_tool_router(): + questions = {"team": {"type": "choice", "choices": {"a": "A", "b": "B"}}} schema = json.loads(SCHEMA_PATH.read_text()) - for fixed_questions in (questions, None): - agent = DecisionAgent("decision", model="jev-1.13", questions=fixed_questions) - jsonschema.validate(AgentConfigSerializer().serialize(agent), schema) + agent = Agent( + name="router", + strategy="router", + router=ToolDef( + name="selector", + tool_type="decision", + config={"model": "jev-1.13", "questions": questions}, + ), + agents=[Agent(name="a", model="openai/gpt-4o-mini")], + ) + jsonschema.validate(AgentConfigSerializer().serialize(agent), schema) diff --git a/tests/unit/ai/test_decision_agent.py b/tests/unit/ai/test_decision_agent.py deleted file mode 100644 index aed72916..00000000 --- a/tests/unit/ai/test_decision_agent.py +++ /dev/null @@ -1,97 +0,0 @@ -import runpy -from pathlib import Path - -from conductor.ai.agents import AgentDef, DecisionAgent -from conductor.ai.agents.config_serializer import AgentConfigSerializer - -QUESTIONS = {"ready": {"type": "boolean", "instructions": "Ready?"}} - - -def test_agent_def_and_convenience_class_serialize_identically(): - questions = QUESTIONS - definition = AgentDef(name="ready", kind="decision", model="jev-1.13", questions=questions) - expected = { - "name": "ready", - "kind": "decision", - "model": "jev-1.13", - "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, - } - assert AgentConfigSerializer().serialize(definition) == expected - assert ( - AgentConfigSerializer().serialize( - DecisionAgent("ready", model="jev-1.13", questions=questions) - ) - == expected - ) - - -def test_decision_agent_serializes_provider_and_metadata(): - expected = { - "name": "ready", - "kind": "decision", - "model": "jev-1.13", - "provider": "typesafe", - "questions": QUESTIONS, - "metadata": {"owner": "support"}, - } - serializer = AgentConfigSerializer() - assert ( - serializer.serialize( - DecisionAgent( - "ready", - model="jev-1.13", - provider="typesafe", - questions=QUESTIONS, - metadata={"owner": "support"}, - ) - ) - == expected - ) - assert ( - serializer.serialize( - AgentDef( - name="ready", - kind="decision", - model="jev-1.13", - provider="typesafe", - questions=QUESTIONS, - metadata={"owner": "support"}, - ) - ) - == expected - ) - - -def test_decision_router_example_uses_decision_only_as_selector(monkeypatch): - examples = Path(__file__).resolve().parents[3] / "examples" / "agents" - monkeypatch.syspath_prepend(str(examples)) - monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") - serializer = AgentConfigSerializer() - - simple = runpy.run_path(str(examples / "decision_agent.py"))["support_agent"]() - config = serializer.serialize(simple) - assert config["router"]["kind"] == "decision" - assert set(config["router"]["questions"]["agent"]["choices"]) == { - child["name"] for child in config["agents"] - } - assert all(child.get("kind") != "decision" for child in config["agents"]) - assert all(child["model"] == "configured/chat" for child in config["agents"]) - - -def test_decision_tool_example_serializes_as_server_side_tool(monkeypatch): - examples = Path(__file__).resolve().parents[3] / "examples" / "agents" - monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") - monkeypatch.delenv("CONDUCTOR_DECISION_PROVIDER", raising=False) - agent = runpy.run_path(str(examples / "decision_tool.py"))["support_agent"] - - config = AgentConfigSerializer().serialize(agent) - assert config["model"] == "configured/chat" - assert len(config["tools"]) == 1 - tool = config["tools"][0] - assert tool["name"] == "classify_request" - assert tool["toolType"] == "decision" - assert tool["config"]["model"] == "jev-1.13" - assert set(tool["config"]["questions"]["department"]["choices"]) == { - "billing", - "technical", - } diff --git a/tests/unit/ai/test_decision_tool.py b/tests/unit/ai/test_decision_tool.py new file mode 100644 index 00000000..7f28d899 --- /dev/null +++ b/tests/unit/ai/test_decision_tool.py @@ -0,0 +1,98 @@ +import runpy +from pathlib import Path + +import pytest + +from conductor.ai.agents import Agent, Strategy, ToolDef +from conductor.ai.agents.config_serializer import AgentConfigSerializer + + +def test_decision_tool_serializes_as_router_selector(): + selector = ToolDef( + name="ready", + tool_type="decision", + config={ + "model": "jev-1.13", + "provider": "typesafe", + "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, + }, + ) + agent = Agent( + name="router", + strategy=Strategy.ROUTER, + router=selector, + agents=[Agent(name="yes", model="configured/chat")], + ) + + assert AgentConfigSerializer().serialize(agent)["router"] == { + "name": "ready", + "kind": "decision", + "model": "jev-1.13", + "provider": "typesafe", + "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, + } + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ({"questions": {"route": {"type": "choice"}}}, "requires 'model'"), + ({"model": "jev-1.13"}, "requires 'questions'"), + ], +) +def test_decision_router_requires_model_and_questions(config, message): + agent = Agent( + name="router", + strategy=Strategy.ROUTER, + router=ToolDef(name="selector", tool_type="decision", config=config), + agents=[Agent(name="child", model="configured/chat")], + ) + with pytest.raises(ValueError, match=message): + AgentConfigSerializer().serialize(agent) + + +def test_non_decision_tool_cannot_be_a_router(): + agent = Agent( + name="router", + strategy=Strategy.ROUTER, + router=ToolDef(name="http", tool_type="http"), + agents=[Agent(name="child", model="configured/chat")], + ) + with pytest.raises(ValueError, match="Only a decision ToolDef"): + AgentConfigSerializer().serialize(agent) + + +def test_decision_router_example_uses_tool_only_as_selector(monkeypatch): + examples = Path(__file__).resolve().parents[3] / "examples" / "agents" + monkeypatch.syspath_prepend(str(examples)) + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") + + router = runpy.run_path(str(examples / "decision_router.py"))["support_router"]() + config = AgentConfigSerializer().serialize(router) + + assert isinstance(router.router, ToolDef) + assert router.router.tool_type == "decision" + assert config["router"]["kind"] == "decision" + assert set(config["router"]["questions"]["agent"]["choices"]) == { + child["name"] for child in config["agents"] + } + assert all(child["model"] == "configured/chat" for child in config["agents"]) + + +def test_decision_tool_example_serializes_as_server_side_tool(monkeypatch): + examples = Path(__file__).resolve().parents[3] / "examples" / "agents" + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") + monkeypatch.delenv("CONDUCTOR_DECISION_PROVIDER", raising=False) + agent = runpy.run_path(str(examples / "decision_tool.py"))["support_agent"] + + config = AgentConfigSerializer().serialize(agent) + assert config["model"] == "configured/chat" + assert len(config["tools"]) == 1 + tool = config["tools"][0] + assert tool["name"] == "classify_request" + assert tool["toolType"] == "decision" + assert tool["config"]["model"] == "jev-1.13" + assert set(tool["config"]["questions"]["department"]["choices"]) == { + "billing", + "technical", + } diff --git a/tests/unit/ai/test_tool_extraction.py b/tests/unit/ai/test_tool_extraction.py index 0f411f2f..a31e4776 100644 --- a/tests/unit/ai/test_tool_extraction.py +++ b/tests/unit/ai/test_tool_extraction.py @@ -591,7 +591,7 @@ def test_survives_an_unreachable_execution(self, runtime): @pytest.mark.parametrize("status", ["COMPLETED", "IN_PROGRESS", "FAILED"]) -def test_decision_is_inference_not_a_tool(runtime, status): +def test_decision_switch_is_inference_not_a_tool(runtime, status): output = { "model": "jev-1.13", "answers": {"department": {"type": "choice", "choice": "billing"}}, @@ -600,8 +600,8 @@ def test_decision_is_inference_not_a_tool(runtime, status): "requestId": "request-1", } task = FakeTask( - task_type="DECISION_AGENT", - task_def_name="DECISION_AGENT", + task_type="SWITCH", + task_def_name="SWITCH", reference_task_name="support_decision", output_data=output, status=status, From 3b7f6d8b919fb39099a2590e68ff9c1423d88bd5 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Mon, 12 Oct 2026 10:16:36 -0700 Subject: [PATCH 13/14] fix(workflow): align decision switches with synchronous execution --- examples/agentic_workflows/README.md | 10 + .../agentic_workflows/ai_decision_routing.py | 8 +- .../client/http/models/workflow_task.py | 61 +--- .../client/workflow/task/switch_task.py | 15 - tests/integration/test_decision_switch_e2e.py | 324 ++++++++++++++++++ .../workflow/test_decision_switch_task.py | 16 +- 6 files changed, 346 insertions(+), 88 deletions(-) create mode 100644 tests/integration/test_decision_switch_e2e.py diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index 4c9f8c26..99fa9758 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -52,6 +52,16 @@ Use one `SwitchTask` with `evaluator_type="decision"`. Its input parameters cont `questions`; its `expression` names that question. The choice keys must match the task's `decisionCases` keys. The server performs inference, writes the full response and `selectedCase` to the SWITCH output, and runs the selected branch. +Evaluation runs synchronously during task mapping. Provider failures fail the +workflow without task retries. + +Run the live [JEV integration cases](../../tests/integration/test_decision_switch_e2e.py) +against a dedicated server with OpenRouter decision and OpenAI chat credentials: + +```bash +RUN_JEV_E2E=1 CONDUCTOR_SERVER_URL=http://localhost:8081/api \ + python -m pytest tests/integration/test_decision_switch_e2e.py -v -s +``` Agent definitions with a decision `ToolDef` in `router=` compile the selector and selected child workflow into this same decision-backed SWITCH shape automatically. diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index a60efb1d..602502c0 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -1,6 +1,7 @@ """Route a request with one decision-backed SWITCH and an inline branch. -The server evaluates the SWITCH and selects its branch. No Python worker is needed. +The server evaluates the SWITCH synchronously and selects its branch. Provider +failures fail the workflow without task retries. No Python worker is needed. Run: CONDUCTOR_SERVER_URL=http://localhost:8080/api python -m examples.agentic_workflows.ai_decision_routing """ @@ -36,11 +37,6 @@ def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkf } }, }, - retry_count=3, - retry_logic="EXPONENTIAL_BACKOFF", - retry_delay_seconds=1, - backoff_scale_factor=2, - max_retry_delay_seconds=5, ) billing = InlineTask( "handle_billing", diff --git a/src/conductor/client/http/models/workflow_task.py b/src/conductor/client/http/models/workflow_task.py index 9373bde8..c135e479 100644 --- a/src/conductor/client/http/models/workflow_task.py +++ b/src/conductor/client/http/models/workflow_task.py @@ -78,10 +78,6 @@ class WorkflowTask: _loop_condition: str = field(default=None, repr=False) _loop_over: List['WorkflowTask'] = field(default=None, repr=False) _retry_count: int = field(default=None, repr=False) - _retry_logic: str = field(default=None, repr=False) - _retry_delay_seconds: int = field(default=None, repr=False) - _backoff_scale_factor: int = field(default=None, repr=False) - _max_retry_delay_seconds: int = field(default=None, repr=False) _evaluator_type: str = field(default=None, repr=False) _expression: str = field(default=None, repr=False) _workflow_task_type: str = field(default=None, repr=False) @@ -118,10 +114,6 @@ class WorkflowTask: 'loop_condition': 'str', 'loop_over': 'list[WorkflowTask]', 'retry_count': 'int', - 'retry_logic': 'str', - 'retry_delay_seconds': 'int', - 'backoff_scale_factor': 'int', - 'max_retry_delay_seconds': 'int', 'evaluator_type': 'str', 'expression': 'str', 'workflow_task_type': 'str', @@ -159,10 +151,6 @@ class WorkflowTask: 'loop_condition': 'loopCondition', 'loop_over': 'loopOver', 'retry_count': 'retryCount', - 'retry_logic': 'retryLogic', - 'retry_delay_seconds': 'retryDelaySeconds', - 'backoff_scale_factor': 'backoffScaleFactor', - 'max_retry_delay_seconds': 'maxRetryDelaySeconds', 'evaluator_type': 'evaluatorType', 'expression': 'expression', 'workflow_task_type': 'workflowTaskType', @@ -178,8 +166,7 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ dynamic_fork_tasks_input_param_name=None, default_case=None, fork_tasks=None, start_delay=None, sub_workflow_param=None, join_on=None, sink=None, optional=None, task_definition : 'TaskDef' =None, rate_limited=None, default_exclusive_join_task=None, async_complete=None, loop_condition=None, - loop_over=None, retry_count=None, retry_logic=None, retry_delay_seconds=None, - backoff_scale_factor=None, max_retry_delay_seconds=None, evaluator_type=None, expression=None, + loop_over=None, retry_count=None, evaluator_type=None, expression=None, workflow_task_type=None, on_state_change: Dict[str, StateChangeConfig] = None, cache_config: CacheConfig = None, join_status=None, permissive=None): # noqa: E501 """WorkflowTask - a model defined in Swagger""" # noqa: E501 @@ -210,10 +197,6 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ self._loop_condition = None self._loop_over = None self._retry_count = None - self._retry_logic = None - self._retry_delay_seconds = None - self._backoff_scale_factor = None - self._max_retry_delay_seconds = None self._evaluator_type = None self._expression = None self._workflow_task_type = None @@ -274,14 +257,6 @@ def __init__(self, name=None, task_reference_name=None, description=None, input_ self.loop_over = loop_over if retry_count is not None: self.retry_count = retry_count - if retry_logic is not None: - self.retry_logic = retry_logic - if retry_delay_seconds is not None: - self.retry_delay_seconds = retry_delay_seconds - if backoff_scale_factor is not None: - self.backoff_scale_factor = backoff_scale_factor - if max_retry_delay_seconds is not None: - self.max_retry_delay_seconds = max_retry_delay_seconds if evaluator_type is not None: self.evaluator_type = evaluator_type if expression is not None: @@ -864,38 +839,6 @@ def retry_count(self, retry_count): self._retry_count = retry_count - @property - def retry_logic(self): - return self._retry_logic - - @retry_logic.setter - def retry_logic(self, retry_logic): - self._retry_logic = retry_logic - - @property - def retry_delay_seconds(self): - return self._retry_delay_seconds - - @retry_delay_seconds.setter - def retry_delay_seconds(self, retry_delay_seconds): - self._retry_delay_seconds = retry_delay_seconds - - @property - def backoff_scale_factor(self): - return self._backoff_scale_factor - - @backoff_scale_factor.setter - def backoff_scale_factor(self, backoff_scale_factor): - self._backoff_scale_factor = backoff_scale_factor - - @property - def max_retry_delay_seconds(self): - return self._max_retry_delay_seconds - - @max_retry_delay_seconds.setter - def max_retry_delay_seconds(self, max_retry_delay_seconds): - self._max_retry_delay_seconds = max_retry_delay_seconds - @property def evaluator_type(self): """Gets the evaluator_type of this WorkflowTask. # noqa: E501 @@ -1084,4 +1027,4 @@ def __eq__(self, other): def __ne__(self, other): """Returns true if both objects are not equal""" - return not self == other + return not self == other \ No newline at end of file diff --git a/src/conductor/client/workflow/task/switch_task.py b/src/conductor/client/workflow/task/switch_task.py index f56e1ad6..4ee7fe40 100644 --- a/src/conductor/client/workflow/task/switch_task.py +++ b/src/conductor/client/workflow/task/switch_task.py @@ -28,11 +28,6 @@ def __init__( *, evaluator_type: Optional[Union[EvaluatorType, str]] = None, input_parameters: Optional[Dict[str, Any]] = None, - retry_count: Optional[int] = None, - retry_logic: Optional[str] = None, - retry_delay_seconds: Optional[int] = None, - backoff_scale_factor: Optional[int] = None, - max_retry_delay_seconds: Optional[int] = None, ) -> Self: super().__init__( task_reference_name=task_ref_name, @@ -46,11 +41,6 @@ def __init__( self._expression = deepcopy(case_expression) self._use_javascript = deepcopy(use_javascript) self._switch_evaluator_type = deepcopy(evaluator_type) - self._retry_count = deepcopy(retry_count) - self._retry_logic = deepcopy(retry_logic) - self._retry_delay_seconds = deepcopy(retry_delay_seconds) - self._backoff_scale_factor = deepcopy(backoff_scale_factor) - self._max_retry_delay_seconds = deepcopy(max_retry_delay_seconds) def switch_case(self, case_name: str, tasks: List[TaskInterface]) -> Self: if isinstance(tasks, List): @@ -90,9 +80,4 @@ def to_workflow_task(self) -> WorkflowTask: if self._default_case is None: self._default_case = [] workflow.default_case = get_task_interface_list_as_workflow_task_list(*self._default_case) - workflow.retry_count = self._retry_count - workflow.retry_logic = self._retry_logic - workflow.retry_delay_seconds = self._retry_delay_seconds - workflow.backoff_scale_factor = self._backoff_scale_factor - workflow.max_retry_delay_seconds = self._max_retry_delay_seconds return workflow diff --git a/tests/integration/test_decision_switch_e2e.py b/tests/integration/test_decision_switch_e2e.py new file mode 100644 index 00000000..9ebc6657 --- /dev/null +++ b/tests/integration/test_decision_switch_e2e.py @@ -0,0 +1,324 @@ +"""Opt-in JEV cases against a real, dedicated Conductor server. + +RUN_JEV_E2E=1 CONDUCTOR_SERVER_URL=http://localhost:8081/api pytest -v -s \ + tests/integration/test_decision_switch_e2e.py + +The server needs DECISION_API_KEY for OpenRouter and OPENAI_API_KEY for chat +specialists. Set JEV_EVIDENCE_DIR to save actual workflow histories. Optional +controlled cases require provider 'controlled' to point to localhost:18081 +with a test API key; enable with RUN_JEV_CONTROLLED=1. +""" + +import asyncio +import copy +import json +import os +import re +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from conductor.ai.agents import AgentRuntime +from conductor.client.configuration.configuration import Configuration +from conductor.client.http.api_client import ApiClient +from conductor.client.orkes_clients import OrkesClients +from examples.agentic_workflows.ai_decision_routing import create_workflow +from examples.agents.decision_router import support_router +from examples.agents.decision_tool import support_agent + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_JEV_E2E") != "1", reason="Set RUN_JEV_E2E=1 for live JEV cases" +) + +BILLING = "I was charged twice on my latest invoice. Please review the duplicate charge." +TECHNICAL = "The product crashes with error 503 whenever I log in. Please troubleshoot." +REQUESTED_RETRIES = 3 + + +@pytest.fixture +def clients(): + return OrkesClients(configuration=Configuration()) + + +def snapshot(clients, workflow_id): + """Include child histories so selected specialists and tools are verifiable.""" + workflow = clients.get_workflow_client().get_workflow(workflow_id, include_tasks=True) + data = ApiClient().sanitize_for_serialization(workflow) + children = [] + for task in data["tasks"]: + child_id = task.get("outputData", {}).get("subWorkflowId") + if child_id: + children.append(snapshot(clients, child_id)) + return {"workflow": data, "children": children} + + +def record(request, data): + directory = os.getenv("JEV_EVIDENCE_DIR") + if directory: + path = Path(directory) + path.mkdir(parents=True, exist_ok=True) + name = re.sub(r"[^a-zA-Z0-9_-]", "_", request.node.name) + (path / f"{name}.json").write_text(json.dumps(data, indent=2)) + workflow = data["history"]["workflow"] + print(f"{request.node.name}: {workflow['status']} {workflow['workflowId']}") + + +def wait(clients, workflow_id): + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + workflow = clients.get_workflow_client().get_workflow(workflow_id, include_tasks=True) + if workflow.status in {"COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"}: + return workflow + time.sleep(0.25) + raise TimeoutError(f"Workflow {workflow_id} did not finish within 120s") + + +def decision_tasks(history): + tasks = [ + t + for t in history["workflow"]["tasks"] + if t.get("workflowTask", {}).get("evaluatorType") == "decision" + ] + for child in history["children"]: + tasks.extend(decision_tasks(child)) + return tasks + + +def run_workflow( + clients, request, prompt, *, provider="openrouter", configure=None, branch_only=False +): + workflow = create_workflow(clients.get_workflow_executor(), provider=provider) + workflow.name = "jev_e2e_" + uuid.uuid4().hex[:12] + definition = workflow.to_workflow_def() + if configure: + configure(definition.tasks[0]) + if branch_only: + definition.tasks = definition.tasks[:1] + definition.output_parameters = {"selectedCase": "${route_request.output.selectedCase}"} + clients.get_metadata_client().register_workflow_def(definition, overwrite=True) + workflow_id = clients.get_workflow_client().start_workflow_by_name( + workflow.name, version=1, input={"request": prompt} + ) + result = wait(clients, workflow_id) + evidence = { + "definition": ApiClient().sanitize_for_serialization(definition), + "history": snapshot(clients, workflow_id), + } + record(request, evidence) + return result, evidence + + +@pytest.mark.parametrize(("prompt", "team"), [(BILLING, "billing"), (TECHNICAL, "technical")]) +def test_live_direct_routing(clients, request, prompt, team): + result, evidence = run_workflow(clients, request, prompt) + assert result.status == "COMPLETED", result.reason_for_incompletion + assert result.output["selectedCase"] == team + assert result.output["result"]["team"] == team + tasks = decision_tasks(evidence["history"]) + assert len(tasks) == 1 + assert tasks[0]["status"] == "COMPLETED" + assert tasks[0]["outputData"]["provider"] == "openrouter" + assert "jev" in tasks[0]["outputData"]["model"] + assert result.output["usage"]["inputTokens"] > 0 + assert result.output["latencyMs"] >= 0 + assert result.output["cost"] >= 0 + branches = [t["referenceTaskName"] for t in evidence["history"]["workflow"]["tasks"]] + assert f"handle_{team}" in branches + assert f"handle_{'technical' if team == 'billing' else 'billing'}" not in branches + + +def test_live_single_question_infers_expression(clients, request): + result, _ = run_workflow( + clients, request, BILLING, configure=lambda task: setattr(task, "expression", "") + ) + assert result.status == "COMPLETED", result.reason_for_incompletion + assert result.output["selectedCase"] == "billing" + + +def test_live_multiple_question_types(clients, request): + def configure(task): + task.input_parameters["questions"].update( + { + "urgent": { + "type": "boolean", + "instructions": "Does this request describe an outage?", + }, + "priority": { + "type": "score", + "instructions": "How urgent is this request?", + "scale": ["routine", "moderate", "urgent"], + }, + } + ) + + result, evidence = run_workflow(clients, request, TECHNICAL, configure=configure) + assert result.status == "COMPLETED", result.reason_for_incompletion + assert result.output["selectedCase"] == "technical" + answers = result.output["answers"] + assert set(answers) == {"route", "urgent", "priority"} + assert 0 <= answers["urgent"]["probability"] <= 1 + scale = evidence["definition"]["tasks"][0]["inputParameters"]["questions"]["priority"]["scale"] + assert 0 <= answers["priority"]["score"] < len(scale) + + +@pytest.mark.parametrize(("prompt", "team"), [(BILLING, "billing"), (TECHNICAL, "technical")]) +def test_live_agent_router(clients, request, prompt, team): + agent = support_router() + agent.name += "_" + uuid.uuid4().hex[:8] + for child in agent.agents: + child.max_tokens = 256 + with AgentRuntime(Configuration()) as runtime: + plan = runtime.plan(agent, prompt) + runtime.deploy(agent) + result = runtime.start(agent, prompt).join(timeout=120) + evidence = {"plan": plan, "history": snapshot(clients, result.execution_id)} + record(request, evidence) + assert result.is_success, result.error + tasks = decision_tasks(evidence["history"]) + assert len(tasks) == 1 + assert tasks[0]["outputData"]["selectedCase"] == team + assert evidence["history"]["children"], "The selected specialist must execute" + assert result.output["result"] + + +def test_live_async_agent_router(clients, request): + async def run(): + agent = support_router() + agent.name += "_async_" + uuid.uuid4().hex[:8] + for child in agent.agents: + child.max_tokens = 256 + async with AgentRuntime(Configuration()) as runtime: + await runtime.deploy_async(agent) + handle = await runtime.start_async(agent, BILLING) + return await handle.join_async(timeout=120) + + result = asyncio.run(run()) + evidence = {"history": snapshot(clients, result.execution_id)} + record(request, evidence) + assert result.is_success, result.error + assert decision_tasks(evidence["history"])[0]["outputData"]["selectedCase"] == "billing" + assert result.output["result"] + + +def test_live_decision_tool(clients, request): + agent = copy.deepcopy(support_agent) + agent.name += "_" + uuid.uuid4().hex[:8] + agent.max_tokens = 256 + agent.max_turns = 3 + agent.instructions = "Call classify_request exactly once, then report its department choice." + with AgentRuntime(Configuration()) as runtime: + result = runtime.run(agent, BILLING, timeout=120) + evidence = {"history": snapshot(clients, result.execution_id)} + record(request, evidence) + assert result.is_success, result.error + tasks = decision_tasks(evidence["history"]) + assert len(tasks) == 1, "The chat agent must actually invoke the decision tool" + assert tasks[0]["outputData"]["answers"]["department"]["choice"] == "billing" + assert tasks[0]["status"] == "COMPLETED" + assert result.output["result"] + + +@pytest.fixture(scope="module") +def controlled_server(): + if os.getenv("RUN_JEV_CONTROLLED") != "1": + pytest.skip("Set RUN_JEV_CONTROLLED=1 and configure the dedicated test provider") + calls = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + calls.append(payload) + state = payload["state"] + status = int(state) if state in {"400", "503"} else 200 + response = { + "model": "typesafe/jev-test", + "id": "controlled-response", + "answers": {"route": {"type": "choice", "choice": "billing", "confidence": 1}}, + "usage": {"input_tokens": 10, "output_tokens": 1, "cost": 0.00001}, + } + if state == "invalid_choice": + response["answers"]["route"]["choice"] = "unknown" + body = b"{" if state == "malformed" else json.dumps(response).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Connection", "close") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("localhost", 18081), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield calls + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.fixture +def controlled_provider(controlled_server): + controlled_server.clear() + return controlled_server + + +@pytest.mark.parametrize( + ("state", "error"), + [ + ("503", "Decision HTTP status 503"), + ("400", "Decision HTTP status 400"), + ("malformed", "Invalid Decision JSON response"), + ("invalid_choice", "invalid choice answer"), + ], +) +def test_provider_failure_has_no_task_retries(clients, request, controlled_provider, state, error): + result, evidence = run_workflow( + clients, + request, + state, + provider="controlled", + configure=lambda task: setattr(task, "retry_count", REQUESTED_RETRIES), + ) + assert result.status == "FAILED" + assert len(result.tasks) == 1 + task = evidence["history"]["workflow"]["tasks"][0] + assert task["status"] == "FAILED" + assert task["workflowTask"]["retryCount"] == REQUESTED_RETRIES + assert error in task["reasonForIncompletion"] + assert not task.get("retried", False) + assert task.get("retryCount", 0) == 0 + assert len(controlled_provider) == 1 + + +@pytest.mark.parametrize("case", ["empty_match", "default"]) +def test_empty_and_default_branches(clients, request, controlled_provider, case): + def configure(task): + if case == "empty_match": + task.decision_cases["billing"] = [] + task.default_case = copy.deepcopy(task.decision_cases["technical"]) + task.default_case[0].task_reference_name = "default_branch" + else: + task.default_case = task.decision_cases.pop("billing") + + result, evidence = run_workflow( + clients, + request, + BILLING, + provider="controlled", + configure=configure, + branch_only=case == "empty_match", + ) + assert result.status == "COMPLETED", result.reason_for_incompletion + names = [t["referenceTaskName"] for t in evidence["history"]["workflow"]["tasks"]] + assert "handle_technical" not in names + assert "default_branch" not in names + assert ("handle_billing" in names) == (case == "default") + assert len(controlled_provider) == 1 diff --git a/tests/unit/workflow/test_decision_switch_task.py b/tests/unit/workflow/test_decision_switch_task.py index 3068129e..3ab65f3e 100644 --- a/tests/unit/workflow/test_decision_switch_task.py +++ b/tests/unit/workflow/test_decision_switch_task.py @@ -27,14 +27,14 @@ def test_direct_example_serializes_one_decision_backed_switch(): choice_keys = set(inputs["questions"][decision["expression"]]["choices"]) assert choice_keys == set(decision["decisionCases"]) - retry_fields = { - "retryCount": 3, - "retryLogic": "EXPONENTIAL_BACKOFF", - "retryDelaySeconds": 1, - "backoffScaleFactor": 2, - "maxRetryDelaySeconds": 5, - } - assert {key: decision[key] for key in retry_fields} == retry_fields + assert not { + "retryCount", + "retryLogic", + "retryDelaySeconds", + "backoffScaleFactor", + "maxRetryDelaySeconds", + "asyncComplete", + }.intersection(decision) assert definition["outputParameters"] == { "selectedCase": "${route_request.output.selectedCase}", From 6a4e1f58f1159908bc9a963bbc8210a3220d72fe Mon Sep 17 00:00:00 2001 From: nicholascole Date: Mon, 12 Oct 2026 11:43:18 -0700 Subject: [PATCH 14/14] refactor(jev): keep examples and minimal routing support --- CHANGELOG.md | 2 - docs/agents/reference/agent-definition.md | 21 -- examples/agentic_workflows/README.md | 14 +- .../agentic_workflows/ai_decision_routing.py | 32 +- examples/agents/decision_router.py | 2 +- src/conductor/ai/agents/agent.py | 32 +- src/conductor/ai/agents/config_serializer.py | 14 +- src/conductor/ai/agents/result.py | 7 +- src/conductor/ai/agents/runtime/runtime.py | 71 +--- .../client/workflow/task/switch_task.py | 36 +- tests/integration/test_decision_switch_e2e.py | 324 ------------------ tests/unit/ai/test_decision_tool.py | 95 ++--- tests/unit/ai/test_tool_extraction.py | 36 -- .../workflow/test_decision_switch_task.py | 105 +++--- 14 files changed, 122 insertions(+), 669 deletions(-) delete mode 100644 tests/integration/test_decision_switch_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e8034b..6a98406b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Decision `ToolDef` instances can be used as router selectors or as server-side agent tools - Worker isolation mode: `CONDUCTOR_WORKER_ISOLATION=thread` runs every worker as a thread instead of a `multiprocessing.Process` (default `process` is unchanged — `spawn` remains the start method). For environments where multiprocessing's fork+exec bootstraps (spawn children, `resource_tracker`) fail, e.g. Firecracker microVM guests. Thread-mode tradeoffs: no per-worker force-kill (shutdown is cooperative), CPU-bound workers share the GIL, and `signal.signal` becomes a no-op off the main thread. Implementation: the Windows-only Process→Thread shim moved to `conductor.client.automator.worker_isolation` (the private `worker_manager._patch_conductor_use_threads_on_windows` helper is removed — the Windows gate calls `apply_thread_isolation()` directly) and now also swaps the logging-relay `Queue` for a plain `queue.Queue` - Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased) @@ -18,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Decision router tools serialize to the server's `kind: "decision"` selector marker, and all decision inference compiles to `SWITCH` tasks with `evaluatorType: "decision"` - **Multiprocessing start method now defaults to `spawn` on all platforms** (was `fork` everywhere except Windows). `fork` caused silently-restarting `SIGSEGV` worker subprocesses on macOS (exitcode `-11`) and fork-with-held-lock deadlocks on POSIX. - Legacy metrics emit unchanged by default; no env var required - `metrics_collector.py` is now a compatibility shim; `from conductor.client.telemetry.metrics_collector import MetricsCollector` continues to work diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md index 9752499a..fe630aa9 100644 --- a/docs/agents/reference/agent-definition.md +++ b/docs/agents/reference/agent-definition.md @@ -10,24 +10,3 @@ Names must match `^[a-zA-Z_][a-zA-Z0-9_-]*$`. Empty models represent inherited o external-agent behavior. The complete constructor and serialization semantics are maintained in [api-reference.md](../api-reference.md) and `AgentConfigSerializer`; use those sources when adding a newly supported field. - -## Decision routers - -Create a `ToolDef(tool_type="decision", config={...})` and use it as the -`router` of an `Agent(strategy="router", ...)`. The same decision tool primitive -can instead be included in a normal agent's `tools` list. It is never compiled, -deployed, or run as a standalone agent, and it needs no Python worker. -Set `config["provider"]` to override the server's default decision provider. -Questions are dictionaries with `instructions` and a `type`: -`choice` uses a `choices` map, `score` uses an ordered `scale`, and `boolean` -returns a probability. Decision routers require exactly one fixed `choice` -question whose keys match the executable child-agent names. - -[Router example](../../../examples/agents/decision_router.py) · -[Tool example](../../../examples/agents/decision_tool.py) - -The parent router needs no chat model or Python worker. The selected child is a -normal executable agent and may use its own chat model and tools. Routing runs -one child and preserves its result. For structured decision inference without a -child agent, use a `SwitchTask` with `evaluator_type="decision"` in a workflow or -a decision tool. diff --git a/examples/agentic_workflows/README.md b/examples/agentic_workflows/README.md index 99fa9758..27fe5e23 100644 --- a/examples/agentic_workflows/README.md +++ b/examples/agentic_workflows/README.md @@ -1,8 +1,8 @@ # Agentic Workflow Examples -AI workflow examples using Conductor's built-in system tasks, with Python workers where needed. +AI/LLM workflow examples using Conductor's built-in system tasks (`LLM_CHAT_COMPLETE`, `LLM_INDEX_TEXT`, `LLM_SEARCH_INDEX`, MCP tools) combined with Python workers. -Chat examples use inline ChatMessage objects for system prompts. No named prompt templates or AIOrchestrator required. +Chat examples use **inline ChatMessage objects** for system prompts -- no named prompt templates or AIOrchestrator required. They work with OSS Conductor with AI/LLM support. ## Prerequisites @@ -47,7 +47,7 @@ python examples/agentic_workflows/function_calling_example.py ### AI decision routing -Use one `SwitchTask` with `evaluator_type="decision"`. Its input parameters contain +Set `SwitchTask.evaluator_type = "decision"`. Its input parameters contain `model`, optional `provider`, `state`, and exactly one choice question in `questions`; its `expression` names that question. The choice keys must match the task's `decisionCases` keys. The server performs inference, writes the full @@ -55,14 +55,6 @@ response and `selectedCase` to the SWITCH output, and runs the selected branch. Evaluation runs synchronously during task mapping. Provider failures fail the workflow without task retries. -Run the live [JEV integration cases](../../tests/integration/test_decision_switch_e2e.py) -against a dedicated server with OpenRouter decision and OpenAI chat credentials: - -```bash -RUN_JEV_E2E=1 CONDUCTOR_SERVER_URL=http://localhost:8081/api \ - python -m pytest tests/integration/test_decision_switch_e2e.py -v -s -``` - Agent definitions with a decision `ToolDef` in `router=` compile the selector and selected child workflow into this same decision-backed SWITCH shape automatically. diff --git a/examples/agentic_workflows/ai_decision_routing.py b/examples/agentic_workflows/ai_decision_routing.py index 602502c0..3f90093a 100644 --- a/examples/agentic_workflows/ai_decision_routing.py +++ b/examples/agentic_workflows/ai_decision_routing.py @@ -21,23 +21,23 @@ def create_workflow(executor, model="jev-1.13", provider=None) -> ConductorWorkf decision = SwitchTask( task_ref_name="route_request", case_expression="route", - evaluator_type=EvaluatorType.DECISION, - input_parameters={ - "model": model, - **({"provider": provider} if provider is not None else {}), - "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.", - }, - } - }, - }, ) + decision.evaluator_type = EvaluatorType.DECISION.value + decision.input_parameters = { + "model": model, + **({"provider": provider} if provider is not None else {}), + "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.", + }, + } + }, + } billing = InlineTask( "handle_billing", script='({team: "billing", nextAction: "review_invoice", ' diff --git a/examples/agents/decision_router.py b/examples/agents/decision_router.py index a7d0a77b..12d4c3ca 100644 --- a/examples/agents/decision_router.py +++ b/examples/agents/decision_router.py @@ -70,7 +70,7 @@ def main(): with AgentRuntime(config) as runtime: agent = support_router() if not args.run: - print(json.dumps(runtime.plan(agent, PROMPT), indent=2)) + print(json.dumps(runtime.plan(agent), indent=2)) return runtime.deploy(agent) diff --git a/src/conductor/ai/agents/agent.py b/src/conductor/ai/agents/agent.py index b13f6699..40ef6382 100644 --- a/src/conductor/ai/agents/agent.py +++ b/src/conductor/ai/agents/agent.py @@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, Union from conductor.ai.agents.claude_code import ClaudeCode +from conductor.ai.agents.tool import ToolDef _VALID_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$") @@ -207,7 +208,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": - """Convert an AgentDef or ``@agent``-decorated function into an Agent. + """Convert an ``@agent``-decorated function into an :class:`Agent` instance. If *obj* is already an :class:`Agent`, it is returned as-is. @@ -215,12 +216,12 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": *parent_model* is provided, the parent's model is inherited. Raises: - TypeError: If *obj* is not an Agent, AgentDef or ``@agent``-decorated function. + TypeError: If *obj* is not an Agent or ``@agent``-decorated function. """ if isinstance(obj, Agent): return obj - if isinstance(obj, AgentDef) or (callable(obj) and hasattr(obj, "_agent_def")): - ad: AgentDef = obj if isinstance(obj, AgentDef) else obj._agent_def + if callable(obj) and hasattr(obj, "_agent_def"): + ad: AgentDef = obj._agent_def # Handle ClaudeCode: don't inherit parent model for claude-code agents if isinstance(ad.model, ClaudeCode): resolved_model = ad.model @@ -229,7 +230,7 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": return Agent( name=ad.name, model=resolved_model, - instructions=ad.func or ad.instructions, + instructions=ad.func, tools=ad.tools, guardrails=ad.guardrails, agents=ad.agents, @@ -249,9 +250,7 @@ def _resolve_agent(obj: Any, parent_model: str = "") -> "Agent": context_window_budget=ad.context_window_budget, prefill_tools=ad.prefill_tools or None, ) - raise TypeError( - f"Expected an Agent, AgentDef or @agent-decorated function, got {type(obj).__name__}" - ) + raise TypeError(f"Expected an Agent or @agent-decorated function, got {type(obj).__name__}") # ── from_instance resolution helpers ──────────────────────────────────── @@ -557,7 +556,7 @@ def __init__( tools: Optional[List[Any]] = None, agents: Optional[List[Any]] = None, strategy: Union[str, Strategy] = Strategy.HANDOFF, - router: Optional[Any] = None, + router: Optional[Union["Agent", Callable[..., Any], "ToolDef"]] = None, output_type: Optional[type] = None, guardrails: Optional[List[Any]] = None, memory: Optional[Any] = None, @@ -916,16 +915,13 @@ def external(self) -> bool: """``True`` if this agent references an external workflow (no local definition). An agent with no model references an existing workflow, except a - ROUTER with a decision tool, which is compiled locally by the server. + ROUTER with a decision tool, which the server compiles inline. """ - decision_router = False - if self.strategy == Strategy.ROUTER and self.router is not None: - from conductor.ai.agents.tool import ToolDef - - decision_router = ( - isinstance(self.router, ToolDef) and self.router.tool_type == "decision" - ) - return not self.model and not decision_router + return not self.model and not ( + self.strategy == Strategy.ROUTER + and isinstance(self.router, ToolDef) + and self.router.tool_type == "decision" + ) # ── Instance-method resolution ────────────────────────────────────── diff --git a/src/conductor/ai/agents/config_serializer.py b/src/conductor/ai/agents/config_serializer.py index c4e3bd43..0c57619b 100644 --- a/src/conductor/ai/agents/config_serializer.py +++ b/src/conductor/ai/agents/config_serializer.py @@ -11,6 +11,8 @@ import logging from typing import TYPE_CHECKING, Any, Dict +from conductor.ai.agents.tool import ToolDef + if TYPE_CHECKING: from conductor.ai.agents.agent import Agent @@ -36,10 +38,8 @@ def serialize(self, agent: "Agent") -> dict: return self._serialize_agent(agent) def _serialize_agent(self, agent: "Agent") -> dict: - from conductor.ai.agents.agent import AgentDef, PromptTemplate, _resolve_agent + from conductor.ai.agents.agent import PromptTemplate - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) # Skill agents — emit the raw skill config so the server's # SkillNormalizer can compile sub-agents (e.g. gilfoyle, dinesh) # and tools (scripts, read_skill_file) into the workflow. @@ -457,8 +457,6 @@ def _serialize_handoff(self, handoff: Any, agent_name: str) -> dict: def _serialize_router(self, agent: "Agent") -> Any: """Serialize a router to either an AgentConfig or a WorkerRef.""" from conductor.ai.agents.agent import Agent as AgentClass - from conductor.ai.agents.tool import ToolDef - router = agent.router if isinstance(router, ToolDef): if router.tool_type != "decision": @@ -468,15 +466,13 @@ def _serialize_router(self, agent: "Agent") -> Any: raise ValueError("Decision router tool config requires 'model'") if not config.get("questions"): raise ValueError("Decision router tool config requires 'questions'") - result = { + return { "name": router.name, "kind": "decision", "model": config["model"], "questions": config["questions"], + **({"provider": config["provider"]} if config.get("provider") is not None else {}), } - if config.get("provider") is not None: - result["provider"] = config["provider"] - return result if isinstance(router, AgentClass) or (hasattr(router, "model") and router.model): return self._serialize_agent(router) elif callable(router): diff --git a/src/conductor/ai/agents/result.py b/src/conductor/ai/agents/result.py index 3a06d29c..29ccc92f 100644 --- a/src/conductor/ai/agents/result.py +++ b/src/conductor/ai/agents/result.py @@ -94,7 +94,7 @@ class AgentResult: Attributes: output: The agent's final answer as a dict. Always contains a - ``"result"`` key with text, structured data or ``None``. + ``"result"`` key whose value is a string (or ``None``). If ``output_type`` was set on the agent, this is a validated instance of that type instead. execution_id: The Conductor execution ID (for debugging in the UI). @@ -598,7 +598,7 @@ def _build_result(self, status: "AgentStatus") -> "AgentResult": correlation_id=self.correlation_id, status=status.status, finish_reason=self._runtime._derive_finish_reason(status.status, status.output), - error=status.reason if status.status in ("FAILED", "TERMINATED", "TIMED_OUT") else None, + error=status.reason if status.status in ("FAILED", "TERMINATED") else None, token_usage=token_usage, metadata=metadata, ) @@ -711,7 +711,6 @@ class EventType(str, Enum): """Types of events emitted during agent execution.""" THINKING = "thinking" - DECISION = "decision" TOOL_CALL = "tool_call" TOOL_RESULT = "tool_result" HANDOFF = "handoff" @@ -733,7 +732,7 @@ class AgentEvent: ``guardrail_pass``, ``guardrail_fail``). tool_name: Tool name (for ``tool_call``, ``tool_result``). args: Tool call arguments (for ``tool_call``). - result: Structured result for ``tool_result`` or ``decision``. + result: Tool result (for ``tool_result``). target: Target agent name (for ``handoff``). output: Final output (for ``done``). execution_id: The Conductor execution ID. diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index 96795881..e53b921a 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -23,7 +23,7 @@ from conductor.ai.agents.runtime.config import AgentConfig from conductor.client.configuration.configuration import Configuration -from conductor.ai.agents.agent import Agent, AgentDef, _resolve_agent +from conductor.ai.agents.agent import Agent from conductor.ai.agents.run_settings import RunSettings from conductor.ai.agents.result import ( AgentEvent, @@ -440,20 +440,6 @@ def _task_events(task: Any, execution_id: str) -> Iterator[AgentEvent]: task_status = str(getattr(task, "status", "") or "").upper() output_data = getattr(task, "output_data", None) or {} - if ( - task_type == "SWITCH" - and task_status == "COMPLETED" - and isinstance(output_data, dict) - and "answers" in output_data - ): - yield AgentEvent( - type=EventType.DECISION, - content=task_ref, - result=output_data, - execution_id=execution_id, - ) - return - # LLM task -> THINKING if "LLM_CHAT_COMPLETE" in task_type: yield AgentEvent( @@ -1120,9 +1106,6 @@ def prepare(self, agent: Any) -> None: """ from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - if isinstance(agent, str): return # nothing to prepare for run-by-name @@ -1725,7 +1708,8 @@ def _register_gate_worker( )(gate_worker) def _register_callback_worker( - self, agent_name: str, position: str, handlers, legacy_fn, domain: "Optional[str]" = None + self, agent_name: str, position: str, handlers, legacy_fn, + domain: "Optional[str]" = None ) -> None: """Register a before_model or after_model callback worker. @@ -2315,22 +2299,14 @@ def _has_worker_tools(self, agent: Agent) -> bool: # ── Plan (compile without executing) ──────────────────────────── - def plan( - self, - agent: Agent, - prompt: Optional[str] = None, - *, - context: Optional[Dict[str, Any]] = None, - ) -> Any: + def plan(self, agent: Agent) -> Any: """Compile an agent to a Conductor workflow definition and return it. This does NOT register, start workers, or execute. Useful for inspecting, debugging, or exporting the compiled workflow. Args: - agent: The Agent or AgentDef to compile. - prompt: Optional input to include in the compilation request. - context: Optional run context, including dynamic Decision questions. + agent: The agent to compile. Returns: The raw server response dict with ``workflowDef`` and @@ -2338,9 +2314,6 @@ def plan( """ from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) if framework: from conductor.ai.agents.frameworks.serializer import serialize_agent @@ -2357,10 +2330,6 @@ def plan( config_json = serializer.serialize(agent) payload = {"agentConfig": config_json} - if prompt is not None: - payload["prompt"] = self._resolve_prompt(prompt) - if context is not None: - payload["context"] = context return self._agent_client.compile_agent(payload) # ── Deploy (CI/CD) ───────────────────────────────────────────── @@ -2407,9 +2376,6 @@ def deploy( for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) registered_name = self._deploy_via_server(agent, framework=framework) @@ -2448,9 +2414,6 @@ async def deploy_async( for agent in all_agents: from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) registered_name = await self._deploy_via_server_async(agent, framework=framework) @@ -2585,8 +2548,6 @@ def serve( has_new = False for agent in all_agents: - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) framework = detect_framework(agent) # Register the agent (workflow + task defs) on the server before # bringing up local workers. Mirrors run()'s deploy-then-register @@ -2737,9 +2698,6 @@ def run( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) if framework is not None: @@ -3443,7 +3401,9 @@ def _register_graph_workers(self, raw_config: dict, workers: list) -> None: elif llm_role == "prep" and llm_var_name: wrapped = GraphWorkerEntry("make_llm_prep_worker", w.func, w.name, llm_var_name) elif llm_role == "finish" and llm_var_name: - wrapped = GraphWorkerEntry("make_llm_finish_worker", w.func, w.name, llm_var_name) + wrapped = GraphWorkerEntry( + "make_llm_finish_worker", w.func, w.name, llm_var_name + ) elif w.name in router_refs: is_dynamic = extra.get("is_dynamic_fanout", False) wrapped = GraphWorkerEntry( @@ -3848,9 +3808,6 @@ def start( # Check for foreign framework agent from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) if framework is not None: return self._start_framework( @@ -4110,9 +4067,6 @@ async def run_async( # Foreign framework check from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) if framework is not None: @@ -4284,9 +4238,6 @@ async def start_async( from conductor.ai.agents.frameworks.serializer import detect_framework - if isinstance(agent, AgentDef): - agent = _resolve_agent(agent) - framework = detect_framework(agent) if framework is not None: return await self._start_framework_async( @@ -5175,10 +5126,8 @@ def _build_result_from_workflow( try: # Reuse the passed workflow when it already carries tasks; # otherwise fetch the full execution for enrichment. - full = ( - wf - if getattr(wf, "tasks", None) - else self._workflow_client.get_workflow(execution_id, include_tasks=True) + full = wf if getattr(wf, "tasks", None) else self._workflow_client.get_workflow( + execution_id, include_tasks=True ) tool_calls = self._extract_tool_calls(full) messages = self._extract_messages(full) diff --git a/src/conductor/client/workflow/task/switch_task.py b/src/conductor/client/workflow/task/switch_task.py index 4ee7fe40..8267a001 100644 --- a/src/conductor/client/workflow/task/switch_task.py +++ b/src/conductor/client/workflow/task/switch_task.py @@ -1,46 +1,31 @@ from copy import deepcopy from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import List from typing_extensions import Self from conductor.client.http.models.workflow_task import WorkflowTask -from conductor.client.workflow.task.task import ( - TaskInterface, - get_task_interface_list_as_workflow_task_list, -) +from conductor.client.workflow.task.task import TaskInterface, get_task_interface_list_as_workflow_task_list from conductor.client.workflow.task.task_type import TaskType class EvaluatorType(str, Enum): - JAVASCRIPT = ("javascript",) - ECMASCRIPT = ("graaljs",) + JAVASCRIPT = "javascript", + ECMASCRIPT = "graaljs", VALUE_PARAM = "value-param" DECISION = "decision" class SwitchTask(TaskInterface): - def __init__( - self, - task_ref_name: str, - case_expression: str, - use_javascript: bool = False, - *, - evaluator_type: Optional[Union[EvaluatorType, str]] = None, - input_parameters: Optional[Dict[str, Any]] = None, - ) -> Self: + def __init__(self, task_ref_name: str, case_expression: str, use_javascript: bool = False) -> Self: super().__init__( task_reference_name=task_ref_name, task_type=TaskType.SWITCH, - input_parameters=input_parameters, ) - if use_javascript and evaluator_type is not None: - raise ValueError("use_javascript and evaluator_type cannot both be set") self._default_case = None self._decision_cases = {} self._expression = deepcopy(case_expression) self._use_javascript = deepcopy(use_javascript) - self._switch_evaluator_type = deepcopy(evaluator_type) def switch_case(self, case_name: str, tasks: List[TaskInterface]) -> Self: if isinstance(tasks, List): @@ -58,12 +43,7 @@ def default_case(self, tasks: List[TaskInterface]) -> Self: def to_workflow_task(self) -> WorkflowTask: workflow = super().to_workflow_task() - if self._switch_evaluator_type is not None: - workflow.evaluator_type = ( - self._switch_evaluator_type.value - if isinstance(self._switch_evaluator_type, EvaluatorType) - else self._switch_evaluator_type - ) + if self.evaluator_type is not None: workflow.expression = self._expression elif self._use_javascript: workflow.evaluator_type = EvaluatorType.ECMASCRIPT @@ -79,5 +59,7 @@ def to_workflow_task(self) -> WorkflowTask: ) if self._default_case is None: self._default_case = [] - workflow.default_case = get_task_interface_list_as_workflow_task_list(*self._default_case) + workflow.default_case = get_task_interface_list_as_workflow_task_list( + *self._default_case + ) return workflow diff --git a/tests/integration/test_decision_switch_e2e.py b/tests/integration/test_decision_switch_e2e.py deleted file mode 100644 index 9ebc6657..00000000 --- a/tests/integration/test_decision_switch_e2e.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Opt-in JEV cases against a real, dedicated Conductor server. - -RUN_JEV_E2E=1 CONDUCTOR_SERVER_URL=http://localhost:8081/api pytest -v -s \ - tests/integration/test_decision_switch_e2e.py - -The server needs DECISION_API_KEY for OpenRouter and OPENAI_API_KEY for chat -specialists. Set JEV_EVIDENCE_DIR to save actual workflow histories. Optional -controlled cases require provider 'controlled' to point to localhost:18081 -with a test API key; enable with RUN_JEV_CONTROLLED=1. -""" - -import asyncio -import copy -import json -import os -import re -import threading -import time -import uuid -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -import pytest - -from conductor.ai.agents import AgentRuntime -from conductor.client.configuration.configuration import Configuration -from conductor.client.http.api_client import ApiClient -from conductor.client.orkes_clients import OrkesClients -from examples.agentic_workflows.ai_decision_routing import create_workflow -from examples.agents.decision_router import support_router -from examples.agents.decision_tool import support_agent - -pytestmark = pytest.mark.skipif( - os.getenv("RUN_JEV_E2E") != "1", reason="Set RUN_JEV_E2E=1 for live JEV cases" -) - -BILLING = "I was charged twice on my latest invoice. Please review the duplicate charge." -TECHNICAL = "The product crashes with error 503 whenever I log in. Please troubleshoot." -REQUESTED_RETRIES = 3 - - -@pytest.fixture -def clients(): - return OrkesClients(configuration=Configuration()) - - -def snapshot(clients, workflow_id): - """Include child histories so selected specialists and tools are verifiable.""" - workflow = clients.get_workflow_client().get_workflow(workflow_id, include_tasks=True) - data = ApiClient().sanitize_for_serialization(workflow) - children = [] - for task in data["tasks"]: - child_id = task.get("outputData", {}).get("subWorkflowId") - if child_id: - children.append(snapshot(clients, child_id)) - return {"workflow": data, "children": children} - - -def record(request, data): - directory = os.getenv("JEV_EVIDENCE_DIR") - if directory: - path = Path(directory) - path.mkdir(parents=True, exist_ok=True) - name = re.sub(r"[^a-zA-Z0-9_-]", "_", request.node.name) - (path / f"{name}.json").write_text(json.dumps(data, indent=2)) - workflow = data["history"]["workflow"] - print(f"{request.node.name}: {workflow['status']} {workflow['workflowId']}") - - -def wait(clients, workflow_id): - deadline = time.monotonic() + 120 - while time.monotonic() < deadline: - workflow = clients.get_workflow_client().get_workflow(workflow_id, include_tasks=True) - if workflow.status in {"COMPLETED", "FAILED", "TIMED_OUT", "TERMINATED"}: - return workflow - time.sleep(0.25) - raise TimeoutError(f"Workflow {workflow_id} did not finish within 120s") - - -def decision_tasks(history): - tasks = [ - t - for t in history["workflow"]["tasks"] - if t.get("workflowTask", {}).get("evaluatorType") == "decision" - ] - for child in history["children"]: - tasks.extend(decision_tasks(child)) - return tasks - - -def run_workflow( - clients, request, prompt, *, provider="openrouter", configure=None, branch_only=False -): - workflow = create_workflow(clients.get_workflow_executor(), provider=provider) - workflow.name = "jev_e2e_" + uuid.uuid4().hex[:12] - definition = workflow.to_workflow_def() - if configure: - configure(definition.tasks[0]) - if branch_only: - definition.tasks = definition.tasks[:1] - definition.output_parameters = {"selectedCase": "${route_request.output.selectedCase}"} - clients.get_metadata_client().register_workflow_def(definition, overwrite=True) - workflow_id = clients.get_workflow_client().start_workflow_by_name( - workflow.name, version=1, input={"request": prompt} - ) - result = wait(clients, workflow_id) - evidence = { - "definition": ApiClient().sanitize_for_serialization(definition), - "history": snapshot(clients, workflow_id), - } - record(request, evidence) - return result, evidence - - -@pytest.mark.parametrize(("prompt", "team"), [(BILLING, "billing"), (TECHNICAL, "technical")]) -def test_live_direct_routing(clients, request, prompt, team): - result, evidence = run_workflow(clients, request, prompt) - assert result.status == "COMPLETED", result.reason_for_incompletion - assert result.output["selectedCase"] == team - assert result.output["result"]["team"] == team - tasks = decision_tasks(evidence["history"]) - assert len(tasks) == 1 - assert tasks[0]["status"] == "COMPLETED" - assert tasks[0]["outputData"]["provider"] == "openrouter" - assert "jev" in tasks[0]["outputData"]["model"] - assert result.output["usage"]["inputTokens"] > 0 - assert result.output["latencyMs"] >= 0 - assert result.output["cost"] >= 0 - branches = [t["referenceTaskName"] for t in evidence["history"]["workflow"]["tasks"]] - assert f"handle_{team}" in branches - assert f"handle_{'technical' if team == 'billing' else 'billing'}" not in branches - - -def test_live_single_question_infers_expression(clients, request): - result, _ = run_workflow( - clients, request, BILLING, configure=lambda task: setattr(task, "expression", "") - ) - assert result.status == "COMPLETED", result.reason_for_incompletion - assert result.output["selectedCase"] == "billing" - - -def test_live_multiple_question_types(clients, request): - def configure(task): - task.input_parameters["questions"].update( - { - "urgent": { - "type": "boolean", - "instructions": "Does this request describe an outage?", - }, - "priority": { - "type": "score", - "instructions": "How urgent is this request?", - "scale": ["routine", "moderate", "urgent"], - }, - } - ) - - result, evidence = run_workflow(clients, request, TECHNICAL, configure=configure) - assert result.status == "COMPLETED", result.reason_for_incompletion - assert result.output["selectedCase"] == "technical" - answers = result.output["answers"] - assert set(answers) == {"route", "urgent", "priority"} - assert 0 <= answers["urgent"]["probability"] <= 1 - scale = evidence["definition"]["tasks"][0]["inputParameters"]["questions"]["priority"]["scale"] - assert 0 <= answers["priority"]["score"] < len(scale) - - -@pytest.mark.parametrize(("prompt", "team"), [(BILLING, "billing"), (TECHNICAL, "technical")]) -def test_live_agent_router(clients, request, prompt, team): - agent = support_router() - agent.name += "_" + uuid.uuid4().hex[:8] - for child in agent.agents: - child.max_tokens = 256 - with AgentRuntime(Configuration()) as runtime: - plan = runtime.plan(agent, prompt) - runtime.deploy(agent) - result = runtime.start(agent, prompt).join(timeout=120) - evidence = {"plan": plan, "history": snapshot(clients, result.execution_id)} - record(request, evidence) - assert result.is_success, result.error - tasks = decision_tasks(evidence["history"]) - assert len(tasks) == 1 - assert tasks[0]["outputData"]["selectedCase"] == team - assert evidence["history"]["children"], "The selected specialist must execute" - assert result.output["result"] - - -def test_live_async_agent_router(clients, request): - async def run(): - agent = support_router() - agent.name += "_async_" + uuid.uuid4().hex[:8] - for child in agent.agents: - child.max_tokens = 256 - async with AgentRuntime(Configuration()) as runtime: - await runtime.deploy_async(agent) - handle = await runtime.start_async(agent, BILLING) - return await handle.join_async(timeout=120) - - result = asyncio.run(run()) - evidence = {"history": snapshot(clients, result.execution_id)} - record(request, evidence) - assert result.is_success, result.error - assert decision_tasks(evidence["history"])[0]["outputData"]["selectedCase"] == "billing" - assert result.output["result"] - - -def test_live_decision_tool(clients, request): - agent = copy.deepcopy(support_agent) - agent.name += "_" + uuid.uuid4().hex[:8] - agent.max_tokens = 256 - agent.max_turns = 3 - agent.instructions = "Call classify_request exactly once, then report its department choice." - with AgentRuntime(Configuration()) as runtime: - result = runtime.run(agent, BILLING, timeout=120) - evidence = {"history": snapshot(clients, result.execution_id)} - record(request, evidence) - assert result.is_success, result.error - tasks = decision_tasks(evidence["history"]) - assert len(tasks) == 1, "The chat agent must actually invoke the decision tool" - assert tasks[0]["outputData"]["answers"]["department"]["choice"] == "billing" - assert tasks[0]["status"] == "COMPLETED" - assert result.output["result"] - - -@pytest.fixture(scope="module") -def controlled_server(): - if os.getenv("RUN_JEV_CONTROLLED") != "1": - pytest.skip("Set RUN_JEV_CONTROLLED=1 and configure the dedicated test provider") - calls = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - calls.append(payload) - state = payload["state"] - status = int(state) if state in {"400", "503"} else 200 - response = { - "model": "typesafe/jev-test", - "id": "controlled-response", - "answers": {"route": {"type": "choice", "choice": "billing", "confidence": 1}}, - "usage": {"input_tokens": 10, "output_tokens": 1, "cost": 0.00001}, - } - if state == "invalid_choice": - response["answers"]["route"]["choice"] = "unknown" - body = b"{" if state == "malformed" else json.dumps(response).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Connection", "close") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - server = ThreadingHTTPServer(("localhost", 18081), Handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield calls - finally: - server.shutdown() - server.server_close() - thread.join() - - -@pytest.fixture -def controlled_provider(controlled_server): - controlled_server.clear() - return controlled_server - - -@pytest.mark.parametrize( - ("state", "error"), - [ - ("503", "Decision HTTP status 503"), - ("400", "Decision HTTP status 400"), - ("malformed", "Invalid Decision JSON response"), - ("invalid_choice", "invalid choice answer"), - ], -) -def test_provider_failure_has_no_task_retries(clients, request, controlled_provider, state, error): - result, evidence = run_workflow( - clients, - request, - state, - provider="controlled", - configure=lambda task: setattr(task, "retry_count", REQUESTED_RETRIES), - ) - assert result.status == "FAILED" - assert len(result.tasks) == 1 - task = evidence["history"]["workflow"]["tasks"][0] - assert task["status"] == "FAILED" - assert task["workflowTask"]["retryCount"] == REQUESTED_RETRIES - assert error in task["reasonForIncompletion"] - assert not task.get("retried", False) - assert task.get("retryCount", 0) == 0 - assert len(controlled_provider) == 1 - - -@pytest.mark.parametrize("case", ["empty_match", "default"]) -def test_empty_and_default_branches(clients, request, controlled_provider, case): - def configure(task): - if case == "empty_match": - task.decision_cases["billing"] = [] - task.default_case = copy.deepcopy(task.decision_cases["technical"]) - task.default_case[0].task_reference_name = "default_branch" - else: - task.default_case = task.decision_cases.pop("billing") - - result, evidence = run_workflow( - clients, - request, - BILLING, - provider="controlled", - configure=configure, - branch_only=case == "empty_match", - ) - assert result.status == "COMPLETED", result.reason_for_incompletion - names = [t["referenceTaskName"] for t in evidence["history"]["workflow"]["tasks"]] - assert "handle_technical" not in names - assert "default_branch" not in names - assert ("handle_billing" in names) == (case == "default") - assert len(controlled_provider) == 1 diff --git a/tests/unit/ai/test_decision_tool.py b/tests/unit/ai/test_decision_tool.py index 7f28d899..9833fb5b 100644 --- a/tests/unit/ai/test_decision_tool.py +++ b/tests/unit/ai/test_decision_tool.py @@ -1,98 +1,47 @@ -import runpy -from pathlib import Path - import pytest from conductor.ai.agents import Agent, Strategy, ToolDef from conductor.ai.agents.config_serializer import AgentConfigSerializer +from examples.agents.decision_router import support_router +from examples.agents.decision_tool import support_agent -def test_decision_tool_serializes_as_router_selector(): - selector = ToolDef( - name="ready", - tool_type="decision", - config={ - "model": "jev-1.13", - "provider": "typesafe", - "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, - }, - ) - agent = Agent( - name="router", - strategy=Strategy.ROUTER, - router=selector, - agents=[Agent(name="yes", model="configured/chat")], - ) +def test_decision_router_example_serializes_inline_selector(monkeypatch): + monkeypatch.setenv("CONDUCTOR_DECISION_PROVIDER", "typesafe") + config = AgentConfigSerializer().serialize(support_router()) + selector = config["router"] - assert AgentConfigSerializer().serialize(agent)["router"] == { - "name": "ready", - "kind": "decision", - "model": "jev-1.13", - "provider": "typesafe", - "questions": {"ready": {"type": "boolean", "instructions": "Ready?"}}, + assert config["external"] is False + assert selector["kind"] == "decision" + assert selector["model"] == "jev-1.13" + assert selector["provider"] == "typesafe" + assert set(selector["questions"]["agent"]["choices"]) == { + child["name"] for child in config["agents"] } + assert Agent(name="existing_workflow").external is True @pytest.mark.parametrize( - ("config", "message"), + ("tool_type", "config", "message"), [ - ({"questions": {"route": {"type": "choice"}}}, "requires 'model'"), - ({"model": "jev-1.13"}, "requires 'questions'"), + ("http", {}, "Only a decision ToolDef"), + ("decision", {"questions": {}}, "requires 'model'"), + ("decision", {"model": "jev-1.13"}, "requires 'questions'"), ], ) -def test_decision_router_requires_model_and_questions(config, message): +def test_decision_router_rejects_invalid_selector(tool_type, config, message): agent = Agent( name="router", strategy=Strategy.ROUTER, - router=ToolDef(name="selector", tool_type="decision", config=config), - agents=[Agent(name="child", model="configured/chat")], + router=ToolDef(name="selector", tool_type=tool_type, config=config), ) with pytest.raises(ValueError, match=message): AgentConfigSerializer().serialize(agent) -def test_non_decision_tool_cannot_be_a_router(): - agent = Agent( - name="router", - strategy=Strategy.ROUTER, - router=ToolDef(name="http", tool_type="http"), - agents=[Agent(name="child", model="configured/chat")], - ) - with pytest.raises(ValueError, match="Only a decision ToolDef"): - AgentConfigSerializer().serialize(agent) - - -def test_decision_router_example_uses_tool_only_as_selector(monkeypatch): - examples = Path(__file__).resolve().parents[3] / "examples" / "agents" - monkeypatch.syspath_prepend(str(examples)) - monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") - - router = runpy.run_path(str(examples / "decision_router.py"))["support_router"]() - config = AgentConfigSerializer().serialize(router) - - assert isinstance(router.router, ToolDef) - assert router.router.tool_type == "decision" - assert config["router"]["kind"] == "decision" - assert set(config["router"]["questions"]["agent"]["choices"]) == { - child["name"] for child in config["agents"] - } - assert all(child["model"] == "configured/chat" for child in config["agents"]) - - -def test_decision_tool_example_serializes_as_server_side_tool(monkeypatch): - examples = Path(__file__).resolve().parents[3] / "examples" / "agents" - monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "configured/chat") - monkeypatch.delenv("CONDUCTOR_DECISION_PROVIDER", raising=False) - agent = runpy.run_path(str(examples / "decision_tool.py"))["support_agent"] - - config = AgentConfigSerializer().serialize(agent) - assert config["model"] == "configured/chat" - assert len(config["tools"]) == 1 +def test_decision_tool_example_uses_existing_tool_serialization(): + config = AgentConfigSerializer().serialize(support_agent) tool = config["tools"][0] - assert tool["name"] == "classify_request" assert tool["toolType"] == "decision" assert tool["config"]["model"] == "jev-1.13" - assert set(tool["config"]["questions"]["department"]["choices"]) == { - "billing", - "technical", - } + assert set(tool["config"]["questions"]["department"]["choices"]) == {"billing", "technical"} diff --git a/tests/unit/ai/test_tool_extraction.py b/tests/unit/ai/test_tool_extraction.py index a31e4776..80b73588 100644 --- a/tests/unit/ai/test_tool_extraction.py +++ b/tests/unit/ai/test_tool_extraction.py @@ -496,7 +496,6 @@ def test_absence_checks_can_still_fail(self, support_run): failed = {c.check for case in suite.cases for c in case.checks if not c.passed} assert failed == {"tool_not_used:lookup_order", "no_handoff_to:billing"} - # ── Agent-internal workers, and the tools that look like them ─────────── @@ -588,38 +587,3 @@ def test_survives_an_unreachable_execution(self, runtime): runtime._workflow_client.get_workflow.side_effect = RuntimeError("boom") assert runtime._extract_tool_calls_and_events("exec-1") == ([], []) - - -@pytest.mark.parametrize("status", ["COMPLETED", "IN_PROGRESS", "FAILED"]) -def test_decision_switch_is_inference_not_a_tool(runtime, status): - output = { - "model": "jev-1.13", - "answers": {"department": {"type": "choice", "choice": "billing"}}, - "usage": {"inputTokens": 10, "outputTokens": 2}, - "latencyMs": 42, - "requestId": "request-1", - } - task = FakeTask( - task_type="SWITCH", - task_def_name="SWITCH", - reference_task_name="support_decision", - output_data=output, - status=status, - ) - workflow = FakeWorkflowRun([task], status=status) - assert runtime._extract_tool_calls(workflow) == [] - events = runtime._extract_events(workflow, "wf-1") - assert not any(e.type in (EventType.TOOL_CALL, EventType.TOOL_RESULT) for e in events) - if status == "COMPLETED": - sse = runtime._sse_to_agent_event( - { - "event": "decision", - "data": {"content": "support_decision", "result": output}, - }, - "wf-1", - ) - assert events[0] == sse - assert events[0].type == EventType.DECISION - assert events[0].result == output - elif status == "FAILED": - assert any(e.type == EventType.ERROR for e in events) diff --git a/tests/unit/workflow/test_decision_switch_task.py b/tests/unit/workflow/test_decision_switch_task.py index 3ab65f3e..d37423e2 100644 --- a/tests/unit/workflow/test_decision_switch_task.py +++ b/tests/unit/workflow/test_decision_switch_task.py @@ -1,81 +1,54 @@ +import pytest + from conductor.client.http.api_client import ApiClient from conductor.client.workflow.task.switch_task import SwitchTask from examples.agentic_workflows.ai_decision_routing import create_workflow -def serialize(value): - return ApiClient().sanitize_for_serialization(value) - - -def test_direct_example_serializes_one_decision_backed_switch(): - definition = serialize(create_workflow(None, provider="typesafe").to_workflow_def()) - decision_tasks = [ - task for task in definition["tasks"] if task.get("evaluatorType") == "decision" - ] - - assert len(decision_tasks) == 1 - decision = decision_tasks[0] - assert decision["type"] == "SWITCH" - assert "AI_DECISION" not in str(definition) - assert decision["expression"] == "route" - +def test_direct_example_serializes_decision_switch_and_branches(): + definition = ApiClient().sanitize_for_serialization( + create_workflow(None, provider="typesafe").to_workflow_def() + ) + decision = definition["tasks"][0] inputs = decision["inputParameters"] + assert (decision["type"], decision["evaluatorType"], decision["expression"]) == ( + "SWITCH", + "decision", + "route", + ) assert inputs["model"] == "jev-1.13" assert inputs["provider"] == "typesafe" assert inputs["state"] == "${workflow.input.request}" - assert set(inputs["questions"]) == {"route"} - choice_keys = set(inputs["questions"][decision["expression"]]["choices"]) - assert choice_keys == set(decision["decisionCases"]) - - assert not { - "retryCount", - "retryLogic", - "retryDelaySeconds", - "backoffScaleFactor", - "maxRetryDelaySeconds", - "asyncComplete", - }.intersection(decision) - - assert definition["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}", - } - - -def test_direct_example_omits_unspecified_provider(): - definition = serialize(create_workflow(None).to_workflow_def()) - assert "provider" not in definition["tasks"][0]["inputParameters"] - - -def test_existing_value_param_switch_serialization_is_unchanged(): - task = SwitchTask("route", "${workflow.input.route}") - - assert serialize(task.to_workflow_task()) == { - "name": "route", - "taskReferenceName": "route", - "inputParameters": {"switchCaseValue": "${workflow.input.route}"}, - "type": "SWITCH", - "decisionCases": {}, - "defaultCase": [], - "evaluatorType": "value-param", - "expression": "switchCaseValue", - } - - -def test_existing_javascript_switch_serialization_is_unchanged(): - task = SwitchTask("route", "return $.route;", use_javascript=True) - - assert serialize(task.to_workflow_task()) == { + assert set(inputs["questions"]["route"]["choices"]) == set(decision["decisionCases"]) + assert not {"retryCount", "asyncComplete"}.intersection(decision) + assert definition["outputParameters"]["selectedCase"] == "${route_request.output.selectedCase}" + assert definition["outputParameters"]["result"] == "${selected_result.output.result}" + + +@pytest.mark.parametrize( + ("javascript", "expression", "inputs", "evaluator", "serialized_expression"), + [ + ( + False, + "${workflow.input.route}", + {"switchCaseValue": "${workflow.input.route}"}, + "value-param", + "switchCaseValue", + ), + (True, "return $.route;", {}, "graaljs", "return $.route;"), + ], +) +def test_existing_switch_serialization( + javascript, expression, inputs, evaluator, serialized_expression +): + task = SwitchTask("route", expression, use_javascript=javascript) + assert ApiClient().sanitize_for_serialization(task.to_workflow_task()) == { "name": "route", "taskReferenceName": "route", - "inputParameters": {}, + "inputParameters": inputs, "type": "SWITCH", "decisionCases": {}, "defaultCase": [], - "evaluatorType": "graaljs", - "expression": "return $.route;", + "evaluatorType": evaluator, + "expression": serialized_expression, }