From cd9a25bcac67524c2e5224109d6da1fe1ed899e8 Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Thu, 20 Aug 2026 11:10:55 +0000 Subject: [PATCH 1/6] feat(schema): add max_tokens field to AgentDef for per-agent override --- src/conductor/config/schema.py | 17 ++++++++ tests/test_config/test_schema.py | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 889b51c4..1318fff2 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1529,6 +1529,13 @@ class AgentDef(BaseModel): max_agent_iterations: 200 instead of using the default limit. """ + max_tokens: int | None = Field(None, ge=1, le=200000) + """Maximum output tokens per response for this agent. + + Overrides the workflow-level runtime.max_tokens for this agent. + Only applies to provider-backed agents (not script or human_gate). + """ + session_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = ( None ) @@ -2083,6 +2090,8 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("script agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("script agents cannot have 'max_agent_iterations'") + if self.max_tokens is not None: + raise ValueError("script agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("script agents cannot have 'session_key'") if self.retry is not None: @@ -2139,6 +2148,8 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("workflow agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("workflow agents cannot have 'max_agent_iterations'") + if self.max_tokens is not None: + raise ValueError("workflow agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("workflow agents cannot have 'session_key'") if self.retry is not None: @@ -2196,6 +2207,8 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("wait agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("wait agents cannot have 'max_agent_iterations'") + if self.max_tokens is not None: + raise ValueError("wait agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("wait agents cannot have 'session_key'") if self.retry is not None: @@ -2269,6 +2282,8 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("set agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("set agents cannot have 'max_agent_iterations'") + if self.max_tokens is not None: + raise ValueError("set agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("set agents cannot have 'session_key'") if self.retry is not None: @@ -2339,6 +2354,8 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("terminate agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("terminate agents cannot have 'max_agent_iterations'") + if self.max_tokens is not None: + raise ValueError("terminate agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("terminate agents cannot have 'session_key'") if self.max_depth is not None: diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 1498cfb6..9d9020c5 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -667,6 +667,77 @@ def test_allowed_on_regular_agent(self) -> None: assert agent.max_session_seconds == 90.0 +class TestAgentDefMaxTokens: + """Tests for max_tokens on AgentDef.""" + + def test_default_is_none(self) -> None: + """Test that max_tokens defaults to None.""" + agent = AgentDef(name="a", model="gpt-4", prompt="test") + assert agent.max_tokens is None + + def test_accepts_valid_value(self) -> None: + """Test that max_tokens accepts a valid value.""" + agent = AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=8192) + assert agent.max_tokens == 8192 + + def test_rejects_zero(self) -> None: + """Test that max_tokens rejects zero.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=0) + assert "greater than or equal to 1" in str(exc_info.value) + + def test_rejects_negative(self) -> None: + """Test that max_tokens rejects negative values.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=-100) + assert "greater than or equal to 1" in str(exc_info.value) + + def test_allowed_on_regular_agent(self) -> None: + """Test that regular agents can have max_tokens.""" + agent = AgentDef( + name="a", type="agent", model="gpt-4", prompt="test", max_tokens=32768 + ) + assert agent.max_tokens == 32768 + + def test_rejects_over_200000(self) -> None: + """Test that max_tokens rejects values above 200000.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=200001) + assert "less than or equal to 200000" in str(exc_info.value) + + def test_rejected_on_script_agent(self) -> None: + """Test that script agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="s", type="script", command="echo hi", max_tokens=8192) + assert "max_tokens" in str(exc_info.value) + + def test_rejected_on_workflow_agent(self) -> None: + """Test that workflow agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="w", type="workflow", workflow="./sub.yaml", max_tokens=8192) + assert "max_tokens" in str(exc_info.value) + + def test_rejected_on_wait_agent(self) -> None: + """Test that wait agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="w", type="wait", duration="5s", max_tokens=8192) + assert "max_tokens" in str(exc_info.value) + + def test_rejected_on_set_agent(self) -> None: + """Test that set agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef(name="s", type="set", value="x", max_tokens=8192) + assert "max_tokens" in str(exc_info.value) + + def test_rejected_on_terminate_agent(self) -> None: + """Test that terminate agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef( + name="t", type="terminate", status="success", reason="done", max_tokens=8192 + ) + assert "max_tokens" in str(exc_info.value) + + class TestRuntimeConfig: """Tests for RuntimeConfig model.""" From 2d5bf6ef24a8da4e75d9332e9d24227ffe0eca8c Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Thu, 20 Aug 2026 18:16:10 +0000 Subject: [PATCH 2/6] style: format max_tokens schema tests --- tests/test_config/test_schema.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 9d9020c5..0507ca8f 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -694,9 +694,7 @@ def test_rejects_negative(self) -> None: def test_allowed_on_regular_agent(self) -> None: """Test that regular agents can have max_tokens.""" - agent = AgentDef( - name="a", type="agent", model="gpt-4", prompt="test", max_tokens=32768 - ) + agent = AgentDef(name="a", type="agent", model="gpt-4", prompt="test", max_tokens=32768) assert agent.max_tokens == 32768 def test_rejects_over_200000(self) -> None: @@ -732,9 +730,7 @@ def test_rejected_on_set_agent(self) -> None: def test_rejected_on_terminate_agent(self) -> None: """Test that terminate agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: - AgentDef( - name="t", type="terminate", status="success", reason="done", max_tokens=8192 - ) + AgentDef(name="t", type="terminate", status="success", reason="done", max_tokens=8192) assert "max_tokens" in str(exc_info.value) From c43e0f13f303e083bc85d9b8abfb25063ff3d407 Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Tue, 25 Aug 2026 23:53:42 +0000 Subject: [PATCH 3/6] fix(config): reject unsupported per-agent max_tokens --- src/conductor/config/validator.py | 17 ++++- src/conductor/providers/capabilities.py | 8 ++ src/conductor/providers/claude.py | 3 + .../test_validator_capabilities.py | 75 +++++++++++++++++++ tests/test_providers/test_capabilities.py | 18 +++++ .../test_pydantic_ai_agent_builder.py | 13 ++++ 6 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 5c2948b8..3b20772b 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -2276,9 +2276,10 @@ def _check_agent_capabilities( provider: per-agent MCP provider-override, tools allowlist (via :func:`_check_agent_tools`), reasoning effort (per-agent override OR the inherited workflow-wide default), structured output schema, and an - explicit ``max_session_seconds``. Shared so a ``for_each`` group's inline - ``AgentDef`` — which is not in ``config.agents`` but runs identically at - runtime — gets the same treatment as a top-level agent (#270). + explicit ``max_session_seconds`` or ``max_tokens``. Shared so a + ``for_each`` group's inline ``AgentDef`` — which is not in + ``config.agents`` but runs identically at runtime — gets the same + treatment as a top-level agent (#270). Reads the workflow-wide ``runtime_default_effort`` from the enclosing scope; it is bound at the top of the function, so every call site is safe. @@ -2364,6 +2365,16 @@ def _check_agent_capabilities( f"(capabilities.max_session_seconds=False)." ) + # max_tokens: silently ignoring an explicit response-length cap + # violates the workflow author's intent. + if agent.max_tokens is not None and not caps.max_tokens: + errors.append( + f"Agent '{agent.name}' sets max_tokens={agent.max_tokens!r} but provider " + f"'{provider_name}' does not apply per-agent output token caps " + f"(capabilities.max_tokens=False). Remove it, use runtime.max_tokens where " + f"the provider honours it, or override the agent to a provider that does." + ) + # working_dir: a provider that cannot apply the directory would # silently run the agent (and its MCP servers) in the wrong cwd — # the same class of silently-dropped operational intent as diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py index 097cb184..ae679b9b 100644 --- a/src/conductor/providers/capabilities.py +++ b/src/conductor/providers/capabilities.py @@ -118,6 +118,13 @@ class ProviderCapabilities(BaseModel): ``max_session_seconds`` wall-clock timeout. False means the setting is silently ignored — workflows that set it fail validation.""" + max_tokens: bool = False + """``True`` when the provider applies an agent's per-agent + ``max_tokens`` output cap. + + ``False`` means the value would be silently ignored, so workflows that + set it fail validation instead. Defaults to ``False`` (conservative).""" + checkpoint_resume: bool """``True`` when provider session state survives ``conductor resume`` cleanly (re-establishes session_id, tool state, etc.).""" @@ -320,6 +327,7 @@ def _build_unimplemented_placeholder() -> ProviderCapabilities: structured_output="native", interrupt=True, max_session_seconds=True, + max_tokens=True, checkpoint_resume=True, usage_tracking=True, concurrent_safe=True, diff --git a/src/conductor/providers/claude.py b/src/conductor/providers/claude.py index bbb32573..845c9269 100644 --- a/src/conductor/providers/claude.py +++ b/src/conductor/providers/claude.py @@ -132,6 +132,9 @@ class ClaudeProvider(AgentProvider): interrupt=True, # ``max_session_seconds`` is enforced by the Pydantic AI interrupt helper. max_session_seconds=True, + # ``AgentDef.max_tokens`` overrides the workflow-level default in + # ``_pydantic_ai.agent_builder.build_agent``. + max_tokens=True, # Anthropic's API is stateless per-request — no session state to # persist across ``conductor resume``. checkpoint_resume=False, diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index e2c82efd..d12c9498 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -34,6 +34,7 @@ def _caps(**overrides: object) -> ProviderCapabilities: "structured_output": "native", "interrupt": True, "max_session_seconds": True, + "max_tokens": True, "checkpoint_resume": True, "usage_tracking": True, "concurrent_safe": True, @@ -576,6 +577,80 @@ def test_omitted_against_unsupported_provider_passes(self, patch_caps: Any) -> N validate_workflow_config(config) +class TestMaxTokensCrossCheck: + def test_explicit_setting_against_unsupported_provider_errors(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(max_tokens=False)}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi", max_tokens=1024)], + ) + + with pytest.raises(ConfigurationError) as exc_info: + validate_workflow_config(config) + + message = str(exc_info.value) + assert "Agent 'a' sets max_tokens=1024" in message + assert "provider 'copilot' does not apply per-agent output token caps" in message + assert "capabilities.max_tokens=False" in message + + def test_explicit_setting_against_supported_provider_passes(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(max_tokens=True)}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi", max_tokens=1024)], + ) + validate_workflow_config(config) + + def test_omitted_against_unsupported_provider_passes(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(max_tokens=False)}) + config = _build_workflow(agents=[AgentDef(name="a", prompt="hi")]) + validate_workflow_config(config) + + def test_for_each_inline_agent_is_checked(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(max_tokens=False)}) + config = _for_each_workflow( + inline=AgentDef(name="inner", prompt="{{ item }}", max_tokens=1024), + ) + + with pytest.raises(ConfigurationError, match="Agent 'inner' sets max_tokens=1024"): + validate_workflow_config(config) + + +class TestRealMaxTokensCapabilities: + @pytest.mark.parametrize( + "provider_name", + ["copilot", "hermes", "claude-agent-sdk", "aca"], + ) + def test_unsupported_providers_reject_per_agent_max_tokens(self, provider_name: str) -> None: + provider: dict[str, str] = {"name": provider_name} + if provider_name == "aca": + provider["pool_endpoint"] = "https://example.invalid" + config = WorkflowConfig.model_validate( + { + "workflow": { + "name": "test", + "entry_point": "a", + "runtime": {"provider": provider}, + }, + "agents": [{"name": "a", "prompt": "hi", "max_tokens": 1024}], + } + ) + + with pytest.raises(ConfigurationError, match="capabilities.max_tokens=False"): + validate_workflow_config(config) + + def test_claude_accepts_per_agent_max_tokens(self) -> None: + config = WorkflowConfig.model_validate( + { + "workflow": { + "name": "test", + "entry_point": "a", + "runtime": {"provider": "claude"}, + }, + "agents": [{"name": "a", "prompt": "hi", "max_tokens": 1024}], + } + ) + validate_workflow_config(config) + + class TestConcurrencyCrossCheck: def test_parallel_group_with_unsafe_provider_errors(self, patch_caps: Any) -> None: patch_caps({"copilot": _caps(concurrent_safe=False)}) diff --git a/tests/test_providers/test_capabilities.py b/tests/test_providers/test_capabilities.py index 6037363b..3364964c 100644 --- a/tests/test_providers/test_capabilities.py +++ b/tests/test_providers/test_capabilities.py @@ -25,6 +25,7 @@ def _stable_capabilities(**overrides: object) -> ProviderCapabilities: "structured_output": "native", "interrupt": True, "max_session_seconds": True, + "max_tokens": True, "checkpoint_resume": True, "usage_tracking": True, "concurrent_safe": True, @@ -232,6 +233,23 @@ def test_working_dir_capability_matrix(self, provider_name: str, expected: bool) caps = get_capabilities(provider_name) assert caps.working_dir is expected + @pytest.mark.parametrize( + ("provider_name", "expected"), + [ + ("copilot", False), + ("claude", True), + ("hermes", False), + ("claude-agent-sdk", False), + ("aca", False), + ], + ) + def test_max_tokens_capability_matrix(self, provider_name: str, expected: bool) -> None: + """Only providers that apply a per-agent output cap declare support.""" + if provider_name == "claude-agent-sdk": + pytest.importorskip("claude_agent_sdk") + caps = get_capabilities(provider_name) + assert caps.max_tokens is expected + def test_working_dir_false_listed_as_limitation(self) -> None: """Requirement: the experimental banner surfaces working_dir=False.""" lims = _stable_capabilities(working_dir=False).declared_limitations() diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index 81559304..12d42447 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -164,6 +164,19 @@ def test_temperature_and_max_tokens_in_model_settings(self) -> None: assert pydantic_agent.model_settings["temperature"] == 0.7 assert pydantic_agent.model_settings["max_tokens"] == 4096 + def test_agent_max_tokens_overrides_runtime_default(self) -> None: + """A per-agent max_tokens value takes precedence over the runtime default.""" + agent_def = AgentDef(name="sampler", max_tokens=1000) + + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=4096, + ) + + assert pydantic_agent.model_settings["max_tokens"] == 1000 + class TestReasoningMapping: """Tests for mapping reasoning effort to Anthropic extended thinking.""" From 726855262ee00f99998ed33cd0e6345e9700ac4e Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Wed, 26 Aug 2026 04:28:07 +0000 Subject: [PATCH 4/6] fix(config): complete per-agent max_tokens validation --- CHANGELOG.md | 6 +++ docs/workflow-syntax.md | 6 +-- .../skills/conductor/references/authoring.md | 10 ++--- .../conductor/references/yaml-schema.md | 12 +++--- src/conductor/config/schema.py | 23 +++++----- src/conductor/providers/capabilities.py | 13 +++--- tests/test_config/test_schema.py | 43 ++++++++++++++++--- .../test_pydantic_ai_agent_builder.py | 32 +++++++++++++- 8 files changed, 104 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d54ef5..ad2b1ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.33...HEAD) +### Added + +- `max_tokens` field on `AgentDef` for per-agent output token cap override (#470). + Only honoured by the `claude` provider; other providers reject it at + validation time via `ProviderCapabilities.max_tokens`. + ### Fixed - **MCP tool discovery and structured tool results no longer break with MCP diff --git a/docs/workflow-syntax.md b/docs/workflow-syntax.md index d742b94c..7a59bfd3 100644 --- a/docs/workflow-syntax.md +++ b/docs/workflow-syntax.md @@ -933,7 +933,7 @@ agents: **Iteration counting** — wait steps count toward `workflow.limits.max_iterations` (each pause is one step). They are not subject to `max_agent_iterations`, which counts per-LLM-agent tool iterations. -**Restrictions** — wait steps cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `retry`, `dialog`, `reasoning`, `validator`, `timeout_seconds`, or `output`. Wait steps also cannot be used inside `parallel` groups or `for_each` groups. +**Restrictions** — wait steps cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `retry`, `dialog`, `reasoning`, `validator`, `timeout_seconds`, or `output`. Wait steps also cannot be used inside `parallel` groups or `for_each` groups. See [`examples/wait-step.yaml`](../examples/wait-step.yaml) for a complete polling workflow. ### Set Steps @@ -1019,7 +1019,7 @@ Per-key typing on multi `values:` is not supported. **Composition** — set steps are allowed inside `parallel` groups (each member publishes its bound value to context) and as the inline agent of a `for_each` group (one bound value per item). Inside a parallel group, set templates cannot reference sibling group members (the validator catches this at config time, since the engine renders against a pre-group snapshot). -**Restrictions** — set agents cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `options`, `input_mapping`, `max_depth`, `retry`, `dialog`, `reasoning`, `validator`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, or `session_key`. They count toward `limits.max_iterations` like any other step. +**Restrictions** — set agents cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `options`, `input_mapping`, `max_depth`, `retry`, `dialog`, `reasoning`, `validator`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, or `session_key`. They count toward `limits.max_iterations` like any other step. **Events** — set steps emit `set_started` / `set_completed` / `set_failed` (mirroring the script-step lifecycle) in all three positions: linear main loop, parallel group member, and for-each iteration. The `set_completed` payload carries `output_type`, `output_keys` (sorted, empty for scalars), and `value_repr` (a JSON-safe preview, truncated at 512 chars). @@ -1159,7 +1159,7 @@ agents: **Final output** — when `output_template:` is set, it *replaces* the workflow-level `output:` mapping for this termination path. Each rendered value is passed through the same JSON-coercion helper used elsewhere in the engine, so `"true"` becomes `True`, `"42"` becomes `42`, and JSON literals are parsed. When `output_template:` is omitted, the workflow-level `output:` is rendered as on any other terminal path. -**Restrictions** — terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `max_depth`, `retry`, `dialog`, `reasoning`, `validator`, `workflow`, `input_mapping`, or `options`. They cannot appear as members of a parallel group or as a `for_each` inline agent — route to them from those groups' `routes:` instead. +**Restrictions** — terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `max_depth`, `retry`, `dialog`, `reasoning`, `validator`, `workflow`, `input_mapping`, or `options`. They cannot appear as members of a parallel group or as a `for_each` inline agent — route to them from those groups' `routes:` instead. **Sub-workflow boundary** — a `status: failed` terminate inside a sub-workflow is downgraded to a `SubworkflowTerminatedError` (subclass of `ExecutionError`) at the parent boundary so the parent treats it as a normal sub-workflow failure (its own `workflow_failed` does NOT inherit `is_explicit: true`). The child's rendered output, reason, and terminate step name are preserved on the wrapper as `terminated_output`, `terminated_reason`, and `terminated_by` for `on_error` hooks and debugging surfaces. A `status: success` terminate inside a sub-workflow returns its rendered output cleanly and the parent continues with its next routes. diff --git a/plugins/conductor/skills/conductor/references/authoring.md b/plugins/conductor/skills/conductor/references/authoring.md index df18d2c2..315de9c8 100644 --- a/plugins/conductor/skills/conductor/references/authoring.md +++ b/plugins/conductor/skills/conductor/references/authoring.md @@ -423,7 +423,7 @@ routes: ### Script Restrictions -Script agents **cannot** have: `prompt`, `provider`, `model`, `tools`, `output`, `system_prompt`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `timeout_seconds` (use `timeout:` instead), `input_mapping`, or `max_depth`. +Script agents **cannot** have: `prompt`, `provider`, `model`, `tools`, `output`, `system_prompt`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `timeout_seconds` (use `timeout:` instead), `input_mapping`, or `max_depth`. Command and args support Jinja2 templating for dynamic values. ## Wait Steps (`type: wait`) @@ -488,7 +488,7 @@ agents: ### Wait Restrictions -Wait agents **cannot** have: `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, or `output`. They also cannot be used inside `parallel` groups or `for_each` groups. +Wait agents **cannot** have: `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, or `output`. They also cannot be used inside `parallel` groups or `for_each` groups. See `examples/wait-step.yaml` for a complete polling workflow. ## Set Steps @@ -569,7 +569,7 @@ Routes attached to a set step see the bound value directly. Dict outputs expose ### Set Step Restrictions -Set agents **cannot** have: `prompt`, `provider`, `model`, `tools`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `options`, `input_mapping`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, or `session_key`. They count toward `limits.max_iterations` like any other step. +Set agents **cannot** have: `prompt`, `provider`, `model`, `tools`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `options`, `input_mapping`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, or `session_key`. They count toward `limits.max_iterations` like any other step. `output:` schema validation is permitted only when the rendered output is a dict (always for `values:`, sometimes for `value:`). A single-`value:` step with a declared schema that produces a scalar raises a `ValidationError` pointing to `values:`. @@ -623,7 +623,7 @@ for_each: title: "{{ issue.title }}" ``` -**Restrictions** — workflow steps cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `session_key`, or `timeout_seconds`. +**Restrictions** — workflow steps cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, or `timeout_seconds`. ## Terminate Steps (`type: terminate`) @@ -660,7 +660,7 @@ agents: - **Sub-workflow boundary** — a `status: failed` terminate inside a child sub-workflow is downgraded to `SubworkflowTerminatedError` (also an `ExecutionError`) at the parent boundary. The parent treats it as a normal sub-workflow failure (its own `workflow_failed` does NOT inherit `is_explicit: true`). The child's rendered output, reason, and terminate-step name are preserved as `terminated_output` / `terminated_reason` / `terminated_by` attributes on the wrapper for `on_error` hooks and debugging surfaces. A `status: success` child terminate returns its rendered output cleanly and the parent continues with its next routes. - **Branching on a child's termination** — if the parent's routes need to react to a child's outcome, the child should use `status: success` plus an `output_template:` carrying the relevant fields. Failed terminate is an error from the parent's perspective; parent `routes:` are only evaluated after successful steps. -**Restrictions** — terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `workflow`, `input_mapping`, or `options`. Cannot appear as a parallel-group member or as a `for_each` inline agent — route to them from those groups' `routes:` instead. Conversely, regular agents cannot have `status`, `reason`, or `output_template` — those fields are rejected at schema validation to catch authors who forgot to add `type: terminate`. +**Restrictions** — terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `workflow`, `input_mapping`, or `options`. Cannot appear as a parallel-group member or as a `for_each` inline agent — route to them from those groups' `routes:` instead. Conversely, regular agents cannot have `status`, `reason`, or `output_template` — those fields are rejected at schema validation to catch authors who forgot to add `type: terminate`. See `examples/terminate.yaml` for a complete example demonstrating success, failure, and pass-through paths. diff --git a/plugins/conductor/skills/conductor/references/yaml-schema.md b/plugins/conductor/skills/conductor/references/yaml-schema.md index 55865be6..b0033435 100644 --- a/plugins/conductor/skills/conductor/references/yaml-schema.md +++ b/plugins/conductor/skills/conductor/references/yaml-schema.md @@ -279,13 +279,13 @@ agents: # ("true" -> True, "42" -> 42, JSON literals parsed) ``` -**Script agent restrictions:** Cannot have `prompt`, `provider`, `model`, `tools`, `output`, `system_prompt`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `timeout_seconds` (use `timeout`), `input_mapping`, or `max_depth`. Output is always `{stdout, stderr, exit_code}`. If `stdout` is valid JSON, its top-level keys are auto-merged into the output dict. +**Script agent restrictions:** Cannot have `prompt`, `provider`, `model`, `tools`, `output`, `system_prompt`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `timeout_seconds` (use `timeout`), `input_mapping`, or `max_depth`. Output is always `{stdout, stderr, exit_code}`. If `stdout` is valid JSON, its top-level keys are auto-merged into the output dict. -**Set agent restrictions:** Cannot have `prompt`, `provider`, `model`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, or `session_key`. Requires exactly one of `value:` or `values:`. `output_type:` is forbidden with `values:` (per-key typing not yet supported). `output:` schema validation is permitted only when the rendered output is a dict (always for `values:`, sometimes for `value:`); a scalar with a declared schema raises `ValidationError`. Set agents are allowed inside `parallel` groups and as `for_each` inline agents, and count toward `limits.max_iterations` like any other step. +**Set agent restrictions:** Cannot have `prompt`, `provider`, `model`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, or `session_key`. Requires exactly one of `value:` or `values:`. `output_type:` is forbidden with `values:` (per-key typing not yet supported). `output:` schema validation is permitted only when the rendered output is a dict (always for `values:`, sometimes for `value:`); a scalar with a declared schema raises `ValidationError`. Set agents are allowed inside `parallel` groups and as `for_each` inline agents, and count toward `limits.max_iterations` like any other step. -**Workflow agent restrictions (`type: workflow`):** Cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `session_key`, or `timeout_seconds`. Requires `workflow:` path. Supports `input_mapping` and `max_depth`. Allowed inside `for_each` groups for dynamic fan-out. +**Workflow agent restrictions (`type: workflow`):** Cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, `options`, `retry`, `reasoning`, `dialog`, `validator`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, or `timeout_seconds`. Requires `workflow:` path. Supports `input_mapping` and `max_depth`. Allowed inside `for_each` groups for dynamic fan-out. -**Terminate agent restrictions (`type: terminate`):** Requires `status` (`success` | `failed`) and a non-empty `reason`. Cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `workflow`, `input_mapping`, or `options`. Cannot be used as a parallel-group member or as a `for_each` inline agent — route to a terminate step from those groups' `routes:` instead. Reaching a terminate step ends the workflow immediately (no routes evaluated after) and produces a distinguishable event payload: `workflow_completed` (for `success`) or `workflow_failed` (for `failed`) with `termination_reason`, `terminated_by`, `is_explicit: true`, and `status`. `status: failed` raises `WorkflowTerminated` (an `ExecutionError` subclass), gives the CLI a non-zero exit code, and is intentionally NOT resumable (no on-failure checkpoint saved). Inside a sub-workflow, a `status: failed` terminate is downgraded at the parent boundary to `SubworkflowTerminatedError` (also an `ExecutionError`), preserving the child's rendered `terminated_output`/`terminated_reason`/`terminated_by` as attributes on the wrapper. +**Terminate agent restrictions (`type: terminate`):** Requires `status` (`success` | `failed`) and a non-empty `reason`. Cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `max_depth`, `retry`, `dialog`, `validator`, `reasoning`, `workflow`, `input_mapping`, or `options`. Cannot be used as a parallel-group member or as a `for_each` inline agent — route to a terminate step from those groups' `routes:` instead. Reaching a terminate step ends the workflow immediately (no routes evaluated after) and produces a distinguishable event payload: `workflow_completed` (for `success`) or `workflow_failed` (for `failed`) with `termination_reason`, `terminated_by`, `is_explicit: true`, and `status`. `status: failed` raises `WorkflowTerminated` (an `ExecutionError` subclass), gives the CLI a non-zero exit code, and is intentionally NOT resumable (no on-failure checkpoint saved). Inside a sub-workflow, a `status: failed` terminate is downgraded at the parent boundary to `SubworkflowTerminatedError` (also an `ExecutionError`), preserving the child's rendered `terminated_output`/`terminated_reason`/`terminated_by` as attributes on the wrapper. **Reasoning effort:** `reasoning.effort` (and `runtime.default_reasoning_effort`) accepts `low`, `medium`, `high`, `xhigh`, or `max`. Per-agent value overrides the runtime default. Each provider translates the unified value to its native API: @@ -367,7 +367,7 @@ Wait agents produce a single, strict field: ### Wait Restrictions -Forbidden fields: `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `session_key`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `output`. Wait steps cannot be used inside `parallel` or `for_each` groups. +Forbidden fields: `prompt`, `model`, `provider`, `tools`, `system_prompt`, `options`, `command`, `args`, `env`, `working_dir`, `timeout`, `workflow`, `input_mapping`, `max_depth`, `max_session_seconds`, `max_agent_iterations`, `max_tokens`, `session_key`, `retry`, `dialog`, `validator`, `reasoning`, `timeout_seconds`, `output`. Wait steps cannot be used inside `parallel` or `for_each` groups. `Esc` / `Ctrl+G` cancels in-progress waits. Workflow-level `limits.timeout_seconds` also cancels them. ## Set Agent Schema @@ -493,7 +493,7 @@ agents: # the parent and traverse in Jinja2. ``` -**Human gate restrictions:** Requires `options` and `prompt`. Cannot have `input_mapping`, `dialog`, `validator`, `sandbox`, `max_depth`, `reasoning`, `context_tier`, `skills`, `plugins`, `timeout_seconds`, `session_key`, `working_dir`, `output_mode`, `value`, `values`, or `output_type`. +**Human gate restrictions:** Requires `options` and `prompt`. Cannot have `input_mapping`, `dialog`, `validator`, `sandbox`, `max_depth`, `reasoning`, `context_tier`, `skills`, `plugins`, `timeout_seconds`, `max_tokens`, `session_key`, `working_dir`, `output_mode`, `value`, `values`, or `output_type`. ## Parallel Group Schema diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 1318fff2..f4640488 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1530,10 +1530,10 @@ class AgentDef(BaseModel): """ max_tokens: int | None = Field(None, ge=1, le=200000) - """Maximum output tokens per response for this agent. + """Overrides the workflow-level runtime.max_tokens for this agent. Controls + response length, not the context window (that budget is context.max_tokens). - Overrides the workflow-level runtime.max_tokens for this agent. - Only applies to provider-backed agents (not script or human_gate). + Rejected on script, workflow, wait, set, and terminate steps. """ session_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = ( @@ -1947,6 +1947,13 @@ def validate_agent_type(self) -> AgentDef: "(only 'script' agents support this field)" ) + # max_tokens is only meaningful on provider-backed agents. + if self.type not in (None, "agent") and self.max_tokens is not None: + raise ValueError( + f"'{self.type}' agents cannot have 'max_tokens' " + "(only provider-backed agents support this field)" + ) + # Fields exclusive to ``type: questions``. A standalone guard, like the # terminate/script ones above, so it also covers types with no branch # of their own. The nav flags are tri-state (``bool | None``) precisely @@ -2090,8 +2097,6 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("script agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("script agents cannot have 'max_agent_iterations'") - if self.max_tokens is not None: - raise ValueError("script agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("script agents cannot have 'session_key'") if self.retry is not None: @@ -2148,8 +2153,6 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("workflow agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("workflow agents cannot have 'max_agent_iterations'") - if self.max_tokens is not None: - raise ValueError("workflow agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("workflow agents cannot have 'session_key'") if self.retry is not None: @@ -2207,8 +2210,6 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("wait agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("wait agents cannot have 'max_agent_iterations'") - if self.max_tokens is not None: - raise ValueError("wait agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("wait agents cannot have 'session_key'") if self.retry is not None: @@ -2282,8 +2283,6 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("set agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("set agents cannot have 'max_agent_iterations'") - if self.max_tokens is not None: - raise ValueError("set agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("set agents cannot have 'session_key'") if self.retry is not None: @@ -2354,8 +2353,6 @@ def validate_agent_type(self) -> AgentDef: raise ValueError("terminate agents cannot have 'max_session_seconds'") if self.max_agent_iterations is not None: raise ValueError("terminate agents cannot have 'max_agent_iterations'") - if self.max_tokens is not None: - raise ValueError("terminate agents cannot have 'max_tokens'") if self.session_key is not None: raise ValueError("terminate agents cannot have 'session_key'") if self.max_depth is not None: diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py index ae679b9b..c9dae10e 100644 --- a/src/conductor/providers/capabilities.py +++ b/src/conductor/providers/capabilities.py @@ -118,13 +118,6 @@ class ProviderCapabilities(BaseModel): ``max_session_seconds`` wall-clock timeout. False means the setting is silently ignored — workflows that set it fail validation.""" - max_tokens: bool = False - """``True`` when the provider applies an agent's per-agent - ``max_tokens`` output cap. - - ``False`` means the value would be silently ignored, so workflows that - set it fail validation instead. Defaults to ``False`` (conservative).""" - checkpoint_resume: bool """``True`` when provider session state survives ``conductor resume`` cleanly (re-establishes session_id, tool state, etc.).""" @@ -204,6 +197,12 @@ class ProviderCapabilities(BaseModel): ``session_continuity=False`` fail validation, rather than silently losing the context the author asked to keep. Defaults to ``False``.""" + max_tokens: bool = False + """``True`` when the provider applies a per-agent ``max_tokens`` output cap. + + ``False`` means the value would be silently ignored, so workflows that set it + fail validation instead.""" + upstream_pin: str | None = None """Upstream package pin surfaced in the experimental banner, e.g. ``"claude-agent-sdk>=0.1.0"``. ``None`` for providers that have no diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 0507ca8f..41ee1fd5 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -680,6 +680,16 @@ def test_accepts_valid_value(self) -> None: agent = AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=8192) assert agent.max_tokens == 8192 + def test_minimum_boundary(self) -> None: + """Test that max_tokens accepts the minimum value of 1.""" + agent = AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=1) + assert agent.max_tokens == 1 + + def test_maximum_boundary(self) -> None: + """Test that max_tokens accepts the maximum value of 200000.""" + agent = AgentDef(name="a", model="gpt-4", prompt="test", max_tokens=200000) + assert agent.max_tokens == 200000 + def test_rejects_zero(self) -> None: """Test that max_tokens rejects zero.""" with pytest.raises(ValidationError) as exc_info: @@ -707,31 +717,54 @@ def test_rejected_on_script_agent(self) -> None: """Test that script agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: AgentDef(name="s", type="script", command="echo hi", max_tokens=8192) - assert "max_tokens" in str(exc_info.value) + assert "'script' agents cannot have 'max_tokens'" in str(exc_info.value) def test_rejected_on_workflow_agent(self) -> None: """Test that workflow agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: AgentDef(name="w", type="workflow", workflow="./sub.yaml", max_tokens=8192) - assert "max_tokens" in str(exc_info.value) + assert "'workflow' agents cannot have 'max_tokens'" in str(exc_info.value) def test_rejected_on_wait_agent(self) -> None: """Test that wait agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: AgentDef(name="w", type="wait", duration="5s", max_tokens=8192) - assert "max_tokens" in str(exc_info.value) + assert "'wait' agents cannot have 'max_tokens'" in str(exc_info.value) def test_rejected_on_set_agent(self) -> None: """Test that set agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: AgentDef(name="s", type="set", value="x", max_tokens=8192) - assert "max_tokens" in str(exc_info.value) + assert "'set' agents cannot have 'max_tokens'" in str(exc_info.value) def test_rejected_on_terminate_agent(self) -> None: """Test that terminate agents cannot have max_tokens.""" with pytest.raises(ValidationError) as exc_info: AgentDef(name="t", type="terminate", status="success", reason="done", max_tokens=8192) - assert "max_tokens" in str(exc_info.value) + assert "'terminate' agents cannot have 'max_tokens'" in str(exc_info.value) + + def test_rejected_on_human_gate(self) -> None: + """Test that human_gate agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef( + name="g", + type="human_gate", + prompt="Choose:", + options=[GateOption(label="Ok", value="ok", route="next")], + max_tokens=8192, + ) + assert "'human_gate' agents cannot have 'max_tokens'" in str(exc_info.value) + + def test_rejected_on_questions(self) -> None: + """Test that questions agents cannot have max_tokens.""" + with pytest.raises(ValidationError) as exc_info: + AgentDef( + name="q", + type="questions", + questions=[{"text": "Name?"}], + max_tokens=8192, + ) + assert "'questions' agents cannot have 'max_tokens'" in str(exc_info.value) class TestRuntimeConfig: diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index 12d42447..bea58844 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -164,8 +164,8 @@ def test_temperature_and_max_tokens_in_model_settings(self) -> None: assert pydantic_agent.model_settings["temperature"] == 0.7 assert pydantic_agent.model_settings["max_tokens"] == 4096 - def test_agent_max_tokens_overrides_runtime_default(self) -> None: - """A per-agent max_tokens value takes precedence over the runtime default.""" + def test_agent_max_tokens_overrides_workflow_default(self) -> None: + """A per-agent max_tokens must win over the workflow-level default.""" agent_def = AgentDef(name="sampler", max_tokens=1000) pydantic_agent = build_agent( @@ -177,6 +177,34 @@ def test_agent_max_tokens_overrides_runtime_default(self) -> None: assert pydantic_agent.model_settings["max_tokens"] == 1000 + def test_workflow_default_used_when_agent_max_tokens_unset(self) -> None: + """With no per-agent override the workflow default still applies.""" + agent_def = AgentDef(name="sampler") + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=4096, + ) + assert pydantic_agent.model_settings["max_tokens"] == 4096 + + def test_agent_max_tokens_with_reasoning_coerced(self) -> None: + """max_tokens=1000 with reasoning.effort=low produces coerced value.""" + agent_def = AgentDef( + name="sampler", + model="claude-3-7-sonnet-latest", + max_tokens=1000, + reasoning=ReasoningConfig(effort="low"), + ) + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=4096, + ) + # low = budget 2048, required = 2048 + 4096 = 6144 + assert pydantic_agent.model_settings["max_tokens"] == 6144 + class TestReasoningMapping: """Tests for mapping reasoning effort to Anthropic extended thinking.""" From fb0cbb01a3367acde1d2b78e856098dead33977d Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Wed, 26 Aug 2026 08:56:51 +0000 Subject: [PATCH 5/6] fix(config): clarify per-agent max token constraints --- docs/providers/comparison.md | 7 ++-- .../providers/_pydantic_ai/agent_builder.py | 10 ++++++ src/conductor/providers/capabilities.py | 2 ++ tests/test_providers/test_capabilities.py | 10 ++++++ .../test_pydantic_ai_agent_builder.py | 34 +++++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md index 0229175d..6d499189 100644 --- a/docs/providers/comparison.md +++ b/docs/providers/comparison.md @@ -23,6 +23,7 @@ This guide helps you choose between GitHub Copilot, Anthropic Claude, Claude Age | **Structured Output** | Prompt injection | Native | Prompt injection | Prompt injection | | **Session Resume** | Yes | No | No | Yes | | **Tool Output Limits** | native SDK spill (large_output) | conductor-side truncation+spill | native CLI env var | N/A | +| **Per-agent max_tokens** | No | Yes | No | No | > **About the experimental tier.** `claude-agent-sdk` and `hermes` declare > specific capability carve-outs (e.g. no per-agent tools allowlist). `conductor validate` @@ -167,8 +168,10 @@ package it as a plugin (a `.claude-plugin/plugin.json` with the skill under directories directly and accepts the identical skill untouched. See the [Skills section of the workflow syntax guide](../workflow-syntax.md#skills). -Separately, `temperature` and `max_tokens` are **rejected at the factory** — -sampling behavior is controlled by the CLI. +Separately, `runtime.temperature` and `runtime.max_tokens` are **rejected at +the factory** — sampling behavior is controlled by the CLI. Per-agent +`AgentDef.max_tokens` is caught earlier by `conductor validate` +(`ProviderCapabilities.max_tokens=False`). ### Example Claude Agent SDK Workflow diff --git a/src/conductor/providers/_pydantic_ai/agent_builder.py b/src/conductor/providers/_pydantic_ai/agent_builder.py index ffde883c..0a2f649a 100644 --- a/src/conductor/providers/_pydantic_ai/agent_builder.py +++ b/src/conductor/providers/_pydantic_ai/agent_builder.py @@ -239,6 +239,16 @@ def _coerce_for_thinking( budget = int(thinking.get("budget_tokens", 0)) effective_max_tokens = max_tokens if max_tokens is not None else 0 required = budget + _ANTHROPIC_THINKING_HEADROOM + if max_tokens is not None and effective_max_tokens < required: + logger.info( + "Raising max_tokens from %s to %s for extended thinking on model %s " + "(budget_tokens=%s + headroom=%s)", + max_tokens, + required, + model, + budget, + _ANTHROPIC_THINKING_HEADROOM, + ) effective_max_tokens = max(effective_max_tokens, required) if effective_max_tokens > _ANTHROPIC_THINKING_OUTPUT_CAP: logger.info( diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py index c9dae10e..6aa726af 100644 --- a/src/conductor/providers/capabilities.py +++ b/src/conductor/providers/capabilities.py @@ -275,6 +275,8 @@ def declared_limitations(self) -> list[str]: items.append("working_dir ignored") if not self.skills: items.append("no skills support") + if not self.max_tokens: + items.append("per-agent max_tokens ignored") if not self.session_continuity: items.append("no session_key continuity") return items diff --git a/tests/test_providers/test_capabilities.py b/tests/test_providers/test_capabilities.py index 3364964c..b921a7d6 100644 --- a/tests/test_providers/test_capabilities.py +++ b/tests/test_providers/test_capabilities.py @@ -152,6 +152,7 @@ def test_each_false_flag_produces_a_limitation(self) -> None: structured_output="none", interrupt=False, max_session_seconds=False, + max_tokens=False, checkpoint_resume=False, usage_tracking=False, concurrent_safe=False, @@ -172,8 +173,17 @@ def test_each_false_flag_produces_a_limitation(self) -> None: assert "no usage tracking" in lims assert "not safe to run in parallel" in lims assert "no skills support" in lims + assert "per-agent max_tokens ignored" in lims assert "no session_key continuity" in lims + def test_max_tokens_false_listed_as_limitation(self) -> None: + lims = _stable_capabilities(max_tokens=False).declared_limitations() + assert "per-agent max_tokens ignored" in lims + assert ( + "per-agent max_tokens ignored" + not in _stable_capabilities(max_tokens=True).declared_limitations() + ) + def test_prompt_injection_structured_output_listed_as_limitation(self) -> None: caps = _stable_capabilities( tier="experimental", diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index bea58844..ed52a9c3 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging from typing import Any import pytest @@ -177,6 +178,39 @@ def test_agent_max_tokens_overrides_workflow_default(self) -> None: assert pydantic_agent.model_settings["max_tokens"] == 1000 + def test_thinking_max_tokens_bump_logs_info(self, caplog: pytest.LogCaptureFixture) -> None: + """Explicit max_tokens overridden by thinking requirement logs at INFO.""" + agent_def = AgentDef( + name="thinker", + model="claude-3-7-sonnet-latest", + max_tokens=1000, + ) + with caplog.at_level(logging.INFO): + build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=None, + default_reasoning_effort="high", + ) + assert "Raising max_tokens from 1000 to" in caplog.text + + def test_thinking_max_tokens_none_does_not_log(self, caplog: pytest.LogCaptureFixture) -> None: + """Unset max_tokens with thinking does not produce a 'Raising' log.""" + agent_def = AgentDef( + name="thinker", + model="claude-3-7-sonnet-latest", + ) + with caplog.at_level(logging.INFO): + build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=None, + default_reasoning_effort="high", + ) + assert "Raising max_tokens" not in caplog.text + def test_workflow_default_used_when_agent_max_tokens_unset(self) -> None: """With no per-agent override the workflow default still applies.""" agent_def = AgentDef(name="sampler") From 40b3c1a411cb6a3e833f4265f73d3437777bcb8a Mon Sep 17 00:00:00 2001 From: nskun <8086.nk@gmail.com> Date: Wed, 26 Aug 2026 15:28:02 +0000 Subject: [PATCH 6/6] fix(claude): preserve explicit max token caps with reasoning --- AGENTS.md | 2 +- CHANGELOG.md | 5 +- docs/configuration.md | 8 +- docs/providers/claude.md | 26 ++++--- .../skills/conductor/references/authoring.md | 2 +- .../conductor/references/yaml-schema.md | 2 +- src/conductor/config/schema.py | 5 ++ src/conductor/config/validator.py | 31 +++++++- .../providers/_pydantic_ai/agent_builder.py | 59 ++++++++++----- .../test_validator_capabilities.py | 73 +++++++++++++++++++ .../test_pydantic_ai_agent_builder.py | 72 ++++++++++++++---- 11 files changed, 238 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f393c57f..6fe620ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ step-by-step checklist. - **Route evaluation**: First matching `when` condition wins; no `when` = always matches - **Tool resolution**: `null` = all workflow tools, `[]` = none, `[list]` = subset - **Set step typing**: `output_type` defaults to `auto` (safe YAML parse with `_to_json_safe` normalisation — `datetime`/`date`/`time` → ISO 8601, non-string dict keys and other non-JSON-safe values raise `ExecutionError`). Explicit `string`/`number`/`integer`/`boolean`/`list`/`dict` only valid on single `value:`. `WorkflowContext.store` accepts any JSON-safe value (scalars/lists from `set` steps in addition to the dicts produced by LLM / script / gate / parallel-group outputs); `_add_agent_input` returns the scalar verbatim for `step.output` and raises a clear `KeyError` for `step.output.field` shorthand on non-dict outputs. -- **Reasoning effort**: `runtime.default_reasoning_effort` sets a workflow-wide default; per-agent `reasoning.effort` overrides it. Allowed values: `low`, `medium`, `high`, `xhigh`, `max`. Each provider translates the unified value to its native API (Copilot: `reasoning_effort` on the session, validated against the model's `supported_reasoning_efforts`; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904 tokens, with `temperature` coerced to 1.0 and `max_tokens` bumped to fit the budget). `max` is Copilot/Claude-only — the Hermes provider advertises only the first four levels in `CAPABILITIES.reasoning_effort` and re-checks the resolved effort against that tuple at execute time (in addition to the static `conductor validate` cross-check), so `max` is rejected on Hermes both statically and at runtime, including when it only resolves to `max` after Jinja template rendering. See `examples/reasoning-effort.yaml`. +- **Reasoning effort**: `runtime.default_reasoning_effort` sets a workflow-wide default; per-agent `reasoning.effort` overrides it. Allowed values: `low`, `medium`, `high`, `xhigh`, `max`. Each provider translates the unified value to its native API (Copilot: `reasoning_effort` on the session, validated against the model's `supported_reasoning_efforts`; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904 tokens, with `temperature` coerced to 1.0). On Claude, an omitted per-agent `max_tokens` is derived as at least the thinking budget plus 4096 answer tokens; an explicit value above the budget and within the 64000-token output cap is preserved, a value equal to or below the budget is rejected during validation, and a value above the output cap is clamped to 64000 with an INFO log. `max` is Copilot/Claude-only — the Hermes provider advertises only the first four levels in `CAPABILITIES.reasoning_effort` and re-checks the resolved effort against that tuple at execute time (in addition to the static `conductor validate` cross-check), so `max` is rejected on Hermes both statically and at runtime, including when it only resolves to `max` after Jinja template rendering. See `examples/reasoning-effort.yaml`. - **Context-window bar** (`context_window_used`/`context_window_max` on `agent_completed`/`parallel_agent_completed`, issue #412): `context_window_used` is sourced from `AgentOutput.last_call_input_tokens` (a single call's prompt size), never `input_tokens` (a billing total summed across every call) — reusing the billing figure produced a false ">100%" red bar on any multi-turn agent. `WorkflowEngine._context_window_fields()` is the single place both LLM-agent emission sites build the pair; when `used > max` (impossible for one real API call), it drops both to `None`, logs at debug on every occurrence, and also logs at warning once per run (via a `_context_window_anomaly_warned` latch, matching the `_pricing_hook_failed_warned`/`_budget_unpriced_warned` pattern) — a debug-only record would never reach an operator, and this anomaly means either the provider-reported token count or the looked-up context-window cap is wrong. Copilot's `assistant.usage` dedup (below) keys on `api_call_id`, falling back to `provider_call_id` then `service_request_id` when the SDK omits it, since all three are independently optional per the SDK schema. - **Periodic checkpoints** (`runtime.checkpoint`, issue #244): opt-in `CheckpointConfig` (`every_agent: bool`, `every_seconds: int|None`, `keep_last: int=5`; `is_enabled = every_agent or every_seconds is not None`). Off by default → failure-only behavior preserved. `WorkflowEngine._maybe_save_periodic_checkpoint()` is called once at the **top of `_execute_loop`** (single choke point), where prior outputs are committed and `_current_agent_name` is the step *about to run* — so a periodic checkpoint reuses failure-checkpoint `current_agent` semantics and resume continues forward with no special-casing. Gated via the `_periodic_checkpoints_active` property (**root engine only**, `_subworkflow_depth == 0`, + `is_enabled`) and skips the first iteration (`limits.current_iteration == 0`). The save decision is `_periodic_checkpoint_due(now)` (`every_agent` OR `every_seconds` throttle; first save always fires). `_save_checkpoint_on_failure` and the periodic path share `_write_checkpoint(error, trigger)` (which best-effort-guards provider `get_session_ids()` so it never raises). The periodic save wraps write+emit+rotate; on any failure it calls `_record_periodic_checkpoint_failure()` which emits a **`checkpoint_save_failed`** event (consecutive-failure count; surfaced by `ConsoleEventSubscriber` + JSONL + dashboard) so a recovery-reliant user isn't silently left without checkpoints. After a save the engine calls `rotate_periodic_checkpoints`; at a terminal **non-resumable** outcome (clean completion via `run()`/`resume()`, or an explicit `status: failed` terminate) `_cleanup_run_periodic_checkpoints()` deletes the run's periodic checkpoints (an unexpected failure leaves them in place alongside the failure checkpoint). `conductor checkpoint list` shows a `Trigger` column and `—` for periodic rows' error type. See `examples/periodic-checkpoints.yaml` and `docs/workflow-syntax.md` (Periodic Checkpoints section). - **Skills**: `runtime.skills: [entry, ...]` sets a workflow-wide default list enabled for every provider-backed agent; per-agent `skills: [entry, ...]` overrides it (tri-state via list presence: omitted = inherit, `skills: []` = explicit opt-out, `skills: [entry, ...]` = explicit set). **Each entry is either a registered built-in name or a filesystem path** (issue #350). Classification is *syntactic* — path when it starts with `~`/`.` or contains `/` or `\`, otherwise a built-in name — so a bare `conductor` can never be shadowed by a same-named local directory and resolution never depends on what happens to exist. A path may be a single skill directory (holds `SKILL.md`) or a root of them, which expands to every immediate child holding one (not recursive); `skills/registry.py::resolve_skills(entries, base_dir)` does the expansion centrally rather than passing roots through, because eager injection needs a name per skill and claude-agent-sdk needs a `:` name. Relative paths resolve against the workflow file's directory (`AgentExecutor(workflow_dir=...)`, threaded from `WorkflowEngine._workflow_dir`), mirroring `_resolve_agent_working_dir` — `normpath`, not `resolve()`, so symlink aliases stay distinct. Paths are **trusted input**: the same YAML can already run arbitrary shell via `type: script`, so no allowlist applies. `AgentDef.validate_skills` only shape-checks path entries (the schema has no base dir) but keeps the eager built-in-name check, so an unknown *name* still fails at load time as before. Every resolved `SKILL.md` must have valid YAML frontmatter declaring `name` and `description` — checked inside `resolve_skills` (via `skills/frontmatter.py`, parsed with **ruamel.yaml**, not PyYAML) rather than only in `conductor validate`, because `conductor run` never calls the static validator; both CLIs skip an unparseable skill *silently*, which is the bug this closes. The observable contract is the same across providers — *"the agent has access to the named skill"* — but the mechanism differs via `AgentProvider.supports_native_skills` (readable without instantiating a provider via `providers/capabilities.py::uses_native_skills`, which returns `None` when it cannot be determined so callers skip rather than guess): **Copilot** (`True`) registers the skill directory on the SDK session via `skill_directories` (progressive disclosure via `SKILL.md` frontmatter); **Claude Agent SDK** (`True`) is also native but goes through the Claude Code *plugin* surface — `providers/claude_agent_sdk.py::_resolve_skill_plugins` maps each resolved directory back to the plugin that owns it (`skills/registry.py::resolve_skill_plugin` walks up for `.claude-plugin/plugin.json`), registers that root via `ClaudeAgentOptions.plugins` and enables the skill by its `:` name via `ClaudeAgentOptions.skills`. Because that SDK has **no bare skill-directory option**, a path skill outside a plugin is unreachable there — `config/validator.py` now refuses it statically (naming both remedies) instead of letting it fail as a runtime `ProviderError`; the identical skill works on `copilot` untouched. **Claude** and **Hermes** (`False`) eagerly inject every enabled skill's `SKILL.md` plus `references/*.md` into the rendered prompt inside `...` tags. That is expensive — the bundled `conductor` skill alone is ~132KB (~33K tokens), paid on every call and every retry — so `runtime.skill_injection` (`SkillInjectionConfig`: `warn_bytes` default 64KB, `max_bytes` default 160KB, either nullable) bounds it, enforced both in `AgentExecutor` and statically in `conductor validate`, measured against the exact string prepended and reported with a per-skill breakdown. The defaults deliberately straddle the bundled skill so enabling it on `claude` warns rather than breaking — `max_bytes` was raised from 128KB to 160KB when the skill outgrew the original ceiling, and must keep tracking it; a `warn_bytes` above `max_bytes` is rejected as unreachable. Native providers are exempt. Providers also declare `skills: bool` on their `ProviderCapabilities` descriptor so `conductor validate` catches skills-against-unsupported-provider mismatches — `hermes` declares `True` (it reaches skills through the provider-agnostic eager-injection path in `AgentExecutor`; it previously omitted the field, defaulting to `False`, while its own `execute()` docstring described injection working), and `aca` is the one `False` (skill directories are host paths the in-sandbox runner cannot read). `AgentExecutor._reject_unsupported_skills` now enforces a `skills=False` declaration at run time too, because `conductor run` never calls the static validator — otherwise the declaration held only at validate time while the eager-injection path happily injected anyway. Built-in skills live under `plugins/conductor/skills//` and are bundled into the wheel via the hatchling `force-include` entries in `pyproject.toml` — both the skill body **and** `plugins/conductor/.claude-plugin/`, because without the manifest no plugin root resolves and every skills-enabled agent on `claude-agent-sdk` fails with a `ProviderError`. Skills are rejected on non-provider-backed step types (script, wait, set, terminate, workflow, human_gate). **Discovery** (`runtime.skill_discovery`, issue #362) is the opt-in alternative to enumerating entries: `sources: [personal, project]` maps onto `~/.copilot/skills` + `~/.claude/skills`, and `.github/skills` + `.claude/skills` walked from the workflow file's directory to the repo root (first ancestor with `.git`; only the workflow file's own directory is used when none is found, so an unversioned tree cannot sweep in whatever sits above it). A third source, `plugins`, was **removed with issue #378** — it reached into a plugin and took exactly one of the three things it ships, which is the bug `runtime.plugins` fixes rather than a feature with a gap; it was also wrong more often than it looked (of 13 installed plugins, 3 were silently degraded and the 3 most plugin-like, shipping `agents/` + MCP but no `skills/`, were never discovered at all). Every mapped location is a *skills root*, so both expand through the same `registry.py::expand_skills_root` — discovery adds no second opinion about what a skill directory is, and a child that cannot be read is contained there so one stray directory cannot discard its readable siblings. **Conductor scans centrally rather than enabling each provider's own discovery**, and that is the whole point of the feature: locations are provider-specific, so one flag asking each provider to find its own would surface *different skill sets to different agents inside one run*. It also keeps `enable_config_discovery` off on Copilot (it would additionally auto-load MCP servers from `.mcp.json`) and `setting_sources=[]` on claude-agent-sdk. Sources scan in a fixed canonical order (`project` → `personal`) independent of YAML order, so reordering cannot change which of two same-named skills wins. Discovery joins the **workflow-level default set**, so the existing tri-state is unchanged and `skills: []` remains the one opt-out; note the inherited case can produce skills from an *empty* `runtime.skills`. The organising principle is a **strict/lenient asymmetry — the user wrote the explicit entries and did not write the discovered ones**: broken frontmatter, a claimed name, an unreadable directory, or a skill `claude-agent-sdk` cannot load are an *error* for a declared skill and a *warning + skip* for a discovered one — with a provider that has no native skill surface at all as the one exception, which errors either way. That last case is not theoretical — only 1 of 13 installed Copilot plugins on a real machine ships `.claude-plugin/plugin.json`, so erroring would bury a claude-agent-sdk user in failures for content they never wrote. `claude`/`hermes` refuse discovery outright (measured 260KB ≈ 65K tokens, well over the default `max_bytes`, and machine-dependent — there is no limit to tune). That refusal is enforced **twice**, in `config/validator.py` and again in `AgentExecutor._reject_discovery_without_native_skills`, because `conductor run` never calls the static validator — the same reason `_reject_unsupported_skills` exists. Explicit entries beat discovered ones on a name collision, which fires immediately in practice because installing Conductor's own plugin puts a second `conductor` skill on the machine. `ResolvedSkill.discovered: bool` carries the provenance so callers branch on a field rather than sniffing a string. `discover_skills(..., home=...)` takes the home directory as a parameter specifically so no test reads the developer's real `~`. `cli/validate.py::_report_skill_discovery` lists the *effective* set — it resolves rather than merely scanning, so a skill the run would drop is not listed or billed, and it forwards any diagnostic the validator did not already print (the validator only resolves skills for agents that *inherit*, so a workflow whose agents all declare their own `skills:` is reported nowhere else) — an ambient set is the one part of a workflow the YAML does not capture, so making it inspectable is part of the feature, not a debugging aid. See `examples/skills-self-improving-workflow.yaml`, `examples/skills-discovery.yaml`, and `docs/workflow-syntax.md` (Skills section). diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2b1ae0..f352e408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `max_tokens` field on `AgentDef` for per-agent output token cap override (#470). Only honoured by the `claude` provider; other providers reject it at - validation time via `ProviderCapabilities.max_tokens`. + validation time via `ProviderCapabilities.max_tokens`. With Claude extended + thinking, compatible explicit caps are preserved and caps at or below the + thinking budget are rejected; automatic sizing remains in place when the + agent does not set a cap. ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index df5491dd..688e5cd9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -467,8 +467,12 @@ agents (none of which call a model). (`claude-3-7-*`, `claude-opus-4*`, `claude-sonnet-4*`, `claude-haiku-4*`); a `ValidationError` is raised otherwise. The provider also auto-coerces `temperature` to `1.0` (required by the Anthropic API for extended thinking, - logged at INFO) and bumps `max_tokens` to fit `budget + 4096`, capped at - `64000` (logged at INFO when clamped). + logged at INFO). If the agent does not set `max_tokens`, the effective value + is derived automatically as at least `budget + 4096`, capped at `64000`. + An explicit per-agent value greater than the budget and within the existing + `64000`-token output cap is preserved; a value equal to or below the budget + is rejected during validation rather than silently increased. A value above + the output cap is clamped to `64000` and logged at INFO. Reasoning / thinking content emitted by the model is surfaced via `agent_reasoning` events and rendered in the dashboard, JSONL logs, and diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 4bf2e6db..58f3d47b 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -416,7 +416,7 @@ The unified effort level is translated into Anthropic's `max` is pinned to `64000 − 4096` — the largest budget that still fits the default `+ 4096` answer headroom under the 64000-token cap (see -[auto-coercion](#auto-coercion-of-temperature-and-max_tokens) below). At `max`, +[the handling rules](#temperature-and-max_tokens-with-reasoning) below). At `max`, the effective `max_tokens` lands exactly on the 64000-token cap. ### Supported models @@ -432,20 +432,26 @@ accepts any model whose name starts with one of: Requesting `reasoning.effort` on any other model raises a `ValidationError` at startup so you fail fast instead of silently dropping the budget. -### Auto-coercion of `temperature` and `max_tokens` +### `temperature` and `max_tokens` with reasoning When extended thinking is enabled, the Anthropic API requires `temperature=1.0` -and a `max_tokens` value large enough to contain both the thinking budget and -the visible response. The provider handles this for you: +and `max_tokens` to be greater than the thinking budget. The provider handles +these constraints as follows: - **`temperature`**: coerced to `1.0` (logged at INFO if you configured a different value). -- **`max_tokens`**: bumped to `budget + 4096`, capped at `64000` (logged at INFO - when clamped). - -This means you don't need to hand-tune `max_tokens` when raising the effort — -the provider will widen the output budget to fit. If you've explicitly set a -`max_tokens` higher than `budget + 4096`, your value is preserved. +- **No per-agent `max_tokens`**: the effective value is derived automatically + as at least `budget + 4096`, capped at `64000`. An inherited value that must + be raised, or a value that must be clamped, is logged at INFO. +- **Explicit per-agent `max_tokens`**: a value greater than the thinking budget + and within the existing `64000`-token output cap is preserved. A value equal + to or below the budget is rejected during validation instead of being + silently increased. A value above the output cap is clamped to `64000` and + logged at INFO. + +Within the provider's supported range, this keeps an explicitly configured +per-agent value as the actual output cap while retaining automatic sizing when +the agent does not set one. ### Reasoning content in events diff --git a/plugins/conductor/skills/conductor/references/authoring.md b/plugins/conductor/skills/conductor/references/authoring.md index 315de9c8..ddbaa1af 100644 --- a/plugins/conductor/skills/conductor/references/authoring.md +++ b/plugins/conductor/skills/conductor/references/authoring.md @@ -171,7 +171,7 @@ agents: `reasoning.effort` (per-agent) and `runtime.default_reasoning_effort` (workflow-wide) accept `low`, `medium`, `high`, `xhigh`, or `max`. Per-agent overrides the runtime default. The provider translates the unified value to its native API: - **Copilot**: forwarded as `reasoning_effort` on the session. Validated against the model's advertised `supported_reasoning_efforts`; raises `ValidationError` for unsupported combinations (skipped in mock-handler mode or when capability metadata is absent). -- **Claude**: enables extended thinking via `thinking={"type": "enabled", "budget_tokens": N}` with mapping `low=2048`, `medium=8192`, `high=16384`, `xhigh=32768`, `max=59904`. Auto-coerces `temperature` to `1.0` (logged at INFO) and bumps `max_tokens` to fit `budget + 4096` (capped at 64000, logged at INFO when clamped). Only valid on thinking-capable models (`claude-3-7-*`, `claude-opus-4*`, `claude-sonnet-4*`, `claude-haiku-4*`); raises `ValidationError` otherwise. +- **Claude**: enables extended thinking via `thinking={"type": "enabled", "budget_tokens": N}` with mapping `low=2048`, `medium=8192`, `high=16384`, `xhigh=32768`, `max=59904`. Auto-coerces `temperature` to `1.0` (logged at INFO). When the agent omits `max_tokens`, derives an effective value of at least `budget + 4096` (capped at 64000); when the agent sets it explicitly, preserves values above the budget and within that cap, rejects values equal to or below the budget during validation, and clamps values above the output cap to 64000 with an INFO log. Only valid on thinking-capable models (`claude-3-7-*`, `claude-opus-4*`, `claude-sonnet-4*`, `claude-haiku-4*`); raises `ValidationError` otherwise. - **Hermes**: forwarded to the hermes-agent library via `reasoning_config={"effort": value}`. Support depends on the underlying model and hermes version. `max` is **not** offered on Hermes (its four-level tuple omits it); the provider re-checks the resolved effort against that tuple at execute time in addition to the static `conductor validate` cross-check, so `max` is rejected both statically and at runtime (including when a templated `effort` only resolves to `max` after rendering). Both providers surface reasoning content via `agent_reasoning` events visible in the dashboard, JSONL logs, and the console at `-vv`. Not allowed on `script`, `human_gate`, `workflow`, or `wait` agent types. diff --git a/plugins/conductor/skills/conductor/references/yaml-schema.md b/plugins/conductor/skills/conductor/references/yaml-schema.md index b0033435..2feb8a9c 100644 --- a/plugins/conductor/skills/conductor/references/yaml-schema.md +++ b/plugins/conductor/skills/conductor/references/yaml-schema.md @@ -290,7 +290,7 @@ agents: **Reasoning effort:** `reasoning.effort` (and `runtime.default_reasoning_effort`) accepts `low`, `medium`, `high`, `xhigh`, or `max`. Per-agent value overrides the runtime default. Each provider translates the unified value to its native API: - **Copilot**: forwards `reasoning_effort` to the session. Validated against the model's advertised `supported_reasoning_efforts` (when available); raises `ValidationError` for unsupported combinations. -- **Claude**: enables extended thinking via `thinking={"type":"enabled","budget_tokens":N}` with mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904. Auto-coerces `temperature=1.0` (Anthropic API requirement) and bumps `max_tokens` to fit `budget+4096` (capped at 64000). Only valid on thinking-capable models (Claude 3.7+, Opus/Sonnet/Haiku 4.x); raises `ValidationError` otherwise. +- **Claude**: enables extended thinking via `thinking={"type":"enabled","budget_tokens":N}` with mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904. Auto-coerces `temperature=1.0` (Anthropic API requirement). When the agent omits `max_tokens`, derives an effective value of at least `budget+4096` (capped at 64000); when the agent sets it explicitly, preserves values above the budget and within that cap, rejects values equal to or below the budget during validation, and clamps values above the output cap to 64000 with an INFO log. Only valid on thinking-capable models (Claude 3.7+, Opus/Sonnet/Haiku 4.x); raises `ValidationError` otherwise. - **Hermes**: forwarded to hermes-agent via `reasoning_config={"effort": value}`. Support depends on the underlying model and hermes version. `max` is **not** offered on Hermes; the provider re-checks the resolved effort against its capability tuple at execute time in addition to the static `conductor validate` cross-check, so `max` is rejected both statically and at runtime on this provider. All three providers surface reasoning content via `agent_reasoning` events visible in the dashboard, JSONL logs, and console at `-vv`. diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index f4640488..b9cba30f 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1533,6 +1533,11 @@ class AgentDef(BaseModel): """Overrides the workflow-level runtime.max_tokens for this agent. Controls response length, not the context window (that budget is context.max_tokens). + With Claude extended thinking, an explicit value must be greater than the + resolved thinking budget and is preserved when it is within the provider's + output cap. When omitted, Claude continues to derive a value from the + budget plus answer headroom. + Rejected on script, workflow, wait, set, and terminate steps. """ diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 3b20772b..c1869fc6 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -10,7 +10,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, cast import jinja2 from jinja2 import Environment, meta, nodes @@ -24,6 +24,7 @@ requires_plugin_root_for_skills, uses_native_skills, ) +from conductor.providers.reasoning import ReasoningEffort, effort_to_budget_tokens from conductor.skills import ( BYTES_PER_TOKEN_ESTIMATE, SkillError, @@ -2339,6 +2340,34 @@ def _check_agent_capabilities( f"but provider '{provider_name}' supports only {list(supported)!r}." ) + # Claude extended thinking requires max_tokens > budget_tokens. An + # explicit per-agent max_tokens is a real output cap, so reject an + # incompatible pair instead of silently raising the configured cap. + # Templated efforts are resolved and checked by ClaudeProvider at run + # time because their budget is not yet known here. + if ( + provider_name == "claude" + and agent.max_tokens is not None + and effective_effort is not None + and not is_jinja_template(effective_effort) + ): + effort = cast(ReasoningEffort, effective_effort) + budget = effort_to_budget_tokens(effort) + if agent.max_tokens <= budget: + source = ( + "reasoning.effort" + if (agent.reasoning is not None and agent.reasoning.effort is not None) + else "runtime.default_reasoning_effort" + ) + errors.append( + f"Agent '{agent.name}' sets max_tokens={agent.max_tokens}, but " + f"{source}={effort!r} maps to Claude budget_tokens={budget}; " + "max_tokens must be greater than budget_tokens. Increase the " + "agent's max_tokens, remove it to let Conductor derive " + "max_tokens automatically, or lower the reasoning effort when " + "possible." + ) + # Structured output: hard error when no support, warning when # experimental + prompt injection (stable prompt-injection providers # like Copilot are silent — they've earned the behavior). diff --git a/src/conductor/providers/_pydantic_ai/agent_builder.py b/src/conductor/providers/_pydantic_ai/agent_builder.py index 0a2f649a..d2eba9a1 100644 --- a/src/conductor/providers/_pydantic_ai/agent_builder.py +++ b/src/conductor/providers/_pydantic_ai/agent_builder.py @@ -51,8 +51,9 @@ # and is used by the temperature/max_tokens coercion helper. _ANTHROPIC_THINKING_OUTPUT_CAP: int = 64_000 -# Headroom above the thinking budget required by the Anthropic API: -# ``max_tokens > budget_tokens``. Matches CLAUDE_ANSWER_HEADROOM_TOKENS. +# Visible-answer headroom Conductor reserves when it derives ``max_tokens``. +# Anthropic itself only requires ``max_tokens > budget_tokens``. Matches +# CLAUDE_ANSWER_HEADROOM_TOKENS. _ANTHROPIC_THINKING_HEADROOM: int = 4_096 # Pydantic AI v2 splits the ``Agent(retries=...)`` budget into tool retries and @@ -212,26 +213,34 @@ def _coerce_for_thinking( max_tokens: int | None, thinking: dict[str, Any] | None, model: str, + *, + explicit_max_tokens: bool = False, + agent_name: str | None = None, ) -> tuple[float | None, int | None]: """Adjust temperature and max_tokens to satisfy Anthropic thinking constraints. When extended thinking is enabled, the Anthropic API requires ``temperature == 1.0`` (or omitted) and ``max_tokens > budget_tokens``. - This helper mirrors ``ClaudeProvider._coerce_for_thinking`` by forcing the - temperature to 1.0 and bumping ``max_tokens`` to at least the thinking - budget plus headroom, clamped to the extended-thinking output cap. + This helper forces the temperature to 1.0. When ``max_tokens`` comes from + a workflow default (or is unset), it is raised to the thinking budget plus + answer headroom. An explicit per-agent cap instead bypasses that headroom + adjustment and is rejected when it cannot contain the thinking budget. Args: temperature: User-configured temperature (may be ``None``). max_tokens: User-configured max output tokens (may be ``None``). thinking: Resolved ``anthropic_thinking`` dict or ``None``. model: Resolved model identifier (used only for log messages). + explicit_max_tokens: Whether ``max_tokens`` was explicitly configured + on the agent rather than inherited from the workflow runtime. + agent_name: Agent name used to make explicit-cap errors actionable. Returns: Tuple of ``(effective_temperature, effective_max_tokens)``. Raises: - ValidationError: If the per-model cap cannot satisfy the thinking budget. + ValidationError: If an explicit per-agent cap or the per-model cap + cannot satisfy the thinking budget. """ if thinking is None: return temperature, max_tokens @@ -239,17 +248,31 @@ def _coerce_for_thinking( budget = int(thinking.get("budget_tokens", 0)) effective_max_tokens = max_tokens if max_tokens is not None else 0 required = budget + _ANTHROPIC_THINKING_HEADROOM - if max_tokens is not None and effective_max_tokens < required: - logger.info( - "Raising max_tokens from %s to %s for extended thinking on model %s " - "(budget_tokens=%s + headroom=%s)", - max_tokens, - required, - model, - budget, - _ANTHROPIC_THINKING_HEADROOM, - ) - effective_max_tokens = max(effective_max_tokens, required) + if explicit_max_tokens: + if effective_max_tokens <= budget: + subject = f"Agent {agent_name!r}" if agent_name is not None else "Agent" + raise ValidationError( + f"{subject} sets max_tokens={effective_max_tokens}, but extended " + f"thinking on model {model!r} requires max_tokens to be greater " + f"than budget_tokens={budget}.", + suggestion=( + "Increase the agent's max_tokens, remove it to let Conductor " + "derive max_tokens automatically, or lower reasoning.effort " + "when possible." + ), + ) + else: + if max_tokens is not None and effective_max_tokens < required: + logger.info( + "Raising max_tokens from %s to %s for extended thinking on model %s " + "(budget_tokens=%s + headroom=%s)", + max_tokens, + required, + model, + budget, + _ANTHROPIC_THINKING_HEADROOM, + ) + effective_max_tokens = max(effective_max_tokens, required) if effective_max_tokens > _ANTHROPIC_THINKING_OUTPUT_CAP: logger.info( "Clamping max_tokens %s to %s for extended thinking on model %s " @@ -322,6 +345,8 @@ def _build_model_settings( max_tokens, thinking, model_name, + explicit_max_tokens=agent_max_tokens is not None, + agent_name=agent.name, ) settings: AnthropicModelSettings = AnthropicModelSettings() diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index d12c9498..2d4535e6 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -650,6 +650,79 @@ def test_claude_accepts_per_agent_max_tokens(self) -> None: ) validate_workflow_config(config) + @pytest.mark.parametrize("max_tokens", [1000, 2048]) + def test_claude_rejects_agent_max_tokens_at_or_below_reasoning_budget( + self, max_tokens: int + ) -> None: + config = WorkflowConfig.model_validate( + { + "workflow": { + "name": "test", + "entry_point": "a", + "runtime": {"provider": "claude"}, + }, + "agents": [ + { + "name": "a", + "prompt": "hi", + "max_tokens": max_tokens, + "reasoning": {"effort": "low"}, + } + ], + } + ) + + with pytest.raises(ConfigurationError) as exc_info: + validate_workflow_config(config) + + message = str(exc_info.value) + assert f"Agent 'a' sets max_tokens={max_tokens}" in message + assert "reasoning.effort='low' maps to Claude budget_tokens=2048" in message + assert "max_tokens must be greater than budget_tokens" in message + assert "remove it to let Conductor derive max_tokens automatically" in message + + def test_claude_preserves_compatible_agent_max_tokens_with_reasoning(self) -> None: + config = WorkflowConfig.model_validate( + { + "workflow": { + "name": "test", + "entry_point": "a", + "runtime": {"provider": "claude"}, + }, + "agents": [ + { + "name": "a", + "prompt": "hi", + "max_tokens": 2049, + "reasoning": {"effort": "low"}, + } + ], + } + ) + + validate_workflow_config(config) + + def test_claude_checks_inherited_reasoning_effort_against_agent_cap(self) -> None: + config = WorkflowConfig.model_validate( + { + "workflow": { + "name": "test", + "entry_point": "a", + "runtime": { + "provider": "claude", + "default_reasoning_effort": "high", + }, + }, + "agents": [{"name": "a", "prompt": "hi", "max_tokens": 16384}], + } + ) + + with pytest.raises( + ConfigurationError, + match="runtime.default_reasoning_effort='high'.*budget_tokens=16384", + ): + validate_workflow_config(config) + class TestConcurrencyCrossCheck: def test_parallel_group_with_unsafe_provider_errors(self, patch_caps: Any) -> None: diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index ed52a9c3..6e1489d0 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -178,22 +178,22 @@ def test_agent_max_tokens_overrides_workflow_default(self) -> None: assert pydantic_agent.model_settings["max_tokens"] == 1000 - def test_thinking_max_tokens_bump_logs_info(self, caplog: pytest.LogCaptureFixture) -> None: - """Explicit max_tokens overridden by thinking requirement logs at INFO.""" + def test_inherited_max_tokens_bump_logs_info(self, caplog: pytest.LogCaptureFixture) -> None: + """An inherited runtime max_tokens is still raised automatically.""" agent_def = AgentDef( name="thinker", model="claude-3-7-sonnet-latest", - max_tokens=1000, ) with caplog.at_level(logging.INFO): - build_agent( + pydantic_agent = build_agent( agent_def, system_prompt="", rendered_prompt="", - default_max_tokens=None, - default_reasoning_effort="high", + default_max_tokens=1000, + default_reasoning_effort="low", ) - assert "Raising max_tokens from 1000 to" in caplog.text + assert pydantic_agent.model_settings["max_tokens"] == 6144 + assert "Raising max_tokens from 1000 to 6144" in caplog.text def test_thinking_max_tokens_none_does_not_log(self, caplog: pytest.LogCaptureFixture) -> None: """Unset max_tokens with thinking does not produce a 'Raising' log.""" @@ -222,22 +222,68 @@ def test_workflow_default_used_when_agent_max_tokens_unset(self) -> None: ) assert pydantic_agent.model_settings["max_tokens"] == 4096 - def test_agent_max_tokens_with_reasoning_coerced(self) -> None: - """max_tokens=1000 with reasoning.effort=low produces coerced value.""" + @pytest.mark.parametrize("max_tokens", [1000, 2048]) + def test_agent_max_tokens_at_or_below_reasoning_budget_is_rejected( + self, max_tokens: int + ) -> None: + """An explicit cap must be greater than the thinking budget.""" agent_def = AgentDef( name="sampler", model="claude-3-7-sonnet-latest", - max_tokens=1000, + max_tokens=max_tokens, reasoning=ReasoningConfig(effort="low"), ) + + with pytest.raises(ValidationError) as exc_info: + build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_max_tokens=4096, + ) + + message = str(exc_info.value) + assert f"Agent 'sampler' sets max_tokens={max_tokens}" in message + assert "requires max_tokens to be greater than budget_tokens=2048" in message + assert "remove it to let Conductor derive max_tokens automatically" in message + + def test_agent_max_tokens_is_checked_against_inherited_reasoning(self) -> None: + """The runtime backstop also covers workflow-default reasoning effort.""" + agent_def = AgentDef( + name="sampler", + model="claude-3-7-sonnet-latest", + max_tokens=16384, + ) + + with pytest.raises(ValidationError) as exc_info: + build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + default_reasoning_effort="high", + ) + + message = str(exc_info.value) + assert "Agent 'sampler' sets max_tokens=16384" in message + assert "requires max_tokens to be greater than budget_tokens=16384" in message + + def test_compatible_agent_max_tokens_with_reasoning_is_preserved(self) -> None: + """A compatible explicit cap is not raised to include default headroom.""" + agent_def = AgentDef( + name="sampler", + model="claude-3-7-sonnet-latest", + max_tokens=2049, + reasoning=ReasoningConfig(effort="low"), + ) + pydantic_agent = build_agent( agent_def, system_prompt="", rendered_prompt="", - default_max_tokens=4096, + default_max_tokens=8192, ) - # low = budget 2048, required = 2048 + 4096 = 6144 - assert pydantic_agent.model_settings["max_tokens"] == 6144 + + assert pydantic_agent.model_settings["max_tokens"] == 2049 class TestReasoningMapping: