Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<plugin>:<skill>` 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 `<plugin>:<skill>` 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 `<skills><skill name="...">...</skill></skills>` 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/<name>/` 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).
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ 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`. 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

- **MCP tool discovery and structured tool results no longer break with MCP
Expand Down
8 changes: 6 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 16 additions & 10 deletions docs/providers/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading