Skip to content
Merged
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
A custom `base_url` requires an explicit `api_key`: an ambient `OPENAI_API_KEY`
is never forwarded to a non-OpenAI endpoint.

- **`runtime.idle_timeout_seconds` / `runtime.max_idle_recovery_attempts`**
(#488) — Copilot-only knobs to tune the idle watchdog for workflows with
legitimately long tool calls. `idle_timeout_seconds` sets the time without
SDK events before a session is treated as idle (default 90s);
`max_idle_recovery_attempts` caps the number of "please continue" prompts
sent before failing (default 5; `0` fails on the first genuine idle
without ever injecting a prompt). See `docs/configuration.md` and
`docs/workflow-syntax.md`.

### Changed

- The Pydantic AI dependency was narrowed from the full `pydantic-ai` package to
Expand All @@ -34,6 +43,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **The Copilot idle watchdog no longer fires during long-running tool
calls** (#488). The SDK does not guarantee any events during a tool
call — `tool.execution_progress` / `tool.execution_partial_result` exist
in the SDK schema but are opt-in per tool, so for most tool calls nothing
arrives between `tool.execution_start` and `tool.execution_complete` — so
a stale idle clock while a tool was still executing was previously
indistinguishable from a genuinely stuck session — triggering a spurious
"please continue" recovery prompt mid tool-call. That prompt's
conversational reply then overwrote the agent's eventual structured
output (`response_content` is last-message-wins), turning a healthy run
into a non-retryable failure. In-flight tool calls (tracked by
`tool_call_id`) now suppress idle recovery entirely while any remain
outstanding; `max_session_seconds` is the sole backstop for a genuinely
wedged tool. Recovery-prompt and
stuck-session messages also no longer misattribute the failure to a tool
that has already completed — `last_activity_ref`'s tool name is now
cleared (or rolled to another still-in-flight tool) on
`tool.execution_complete` instead of only ever being set. The first
occurrence of extended suppression during a session is logged at
`warning` level (naming the in-flight tools and the `max_session_seconds`
backstop); further occurrences in the same session are debug-only.
- Retry classification now covers the `ModelHTTPError` and `ModelAPIError` types
pydantic-ai actually raises, so `408`, `429` and `5xx` responses are retried on
the Claude provider as well as the new OpenAI one. Previously they were treated
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ workflow:
max_tokens: 4096
default_reasoning_effort: medium # low | medium | high | xhigh | max (optional)
default_context_tier: default # default | long_context (optional, Copilot only)
idle_timeout_seconds: 90 # Copilot only (optional)
max_idle_recovery_attempts: 5 # Copilot only (optional)
# Provider-specific settings...
```

Expand All @@ -31,6 +33,20 @@ context-window tier that every provider-backed agent inherits unless it
declares its own `context_tier` override. See [Context Tier](#context-tier)
for details. This is a Copilot-only capability.

The `idle_timeout_seconds` and `max_idle_recovery_attempts` fields tune the
Copilot provider's idle watchdog: when a session stops emitting SDK events
for `idle_timeout_seconds` (default 90s), Conductor sends a "please continue"
recovery prompt, up to `max_idle_recovery_attempts` times (default 5) before
failing the session. A tool call that is still executing suppresses the
watchdog — most tools emit nothing while running, so a stale idle clock
during a long-running tool call usually means the tool is still running, not
that the session is stuck. Suppression is bounded by `max_session_seconds`
(default 1800s), which is still enforced while a tool is in flight and is the
only limit that can end a genuinely hung tool call. Raise it alongside
`idle_timeout_seconds` if your workflow has tool calls that legitimately run
longer than 30 minutes. Both `idle_timeout_seconds` and
`max_idle_recovery_attempts` are Copilot-only; other providers ignore them.

## Provider Selection

### Copilot Provider
Expand All @@ -47,6 +63,8 @@ workflow:
command: npx
args: ["-y", "open-websearch@latest"]
tools: ["*"]
idle_timeout_seconds: 90
max_idle_recovery_attempts: 5
```

**Features**:
Expand Down
8 changes: 8 additions & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ workflow:
# its own `context_tier`.
# See docs/configuration.md#context-tier.

idle_timeout_seconds: 90 # Optional: seconds without SDK events before a
# Copilot session is treated as idle (Copilot only).
# Default: 90. Suppressed entirely while a tool
# call is in flight.
max_idle_recovery_attempts: 5 # Optional: "please continue" prompts sent before
# failing an idle Copilot session (Copilot only).
# Default: 5. 0 means fail on first genuine idle.

working_dir: "/path/to/cwd" # Optional: global default working directory for LLM agents
# and their MCP servers. Relative paths resolve against the
# parent directory of the workflow YAML file.
Expand Down
2 changes: 2 additions & 0 deletions plugins/conductor/skills/conductor/references/yaml-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ workflow:
timeout: float # Per-request timeout in seconds (optional, default: 600, copilot/claude only)
max_agent_iterations: integer # Max tool-use roundtrips per agent (1-500, optional)
max_session_seconds: float # Wall-clock timeout per agent session in seconds (optional)
idle_timeout_seconds: float # Seconds without SDK events before session is idle (optional, Copilot only, default 90)
max_idle_recovery_attempts: integer # "please continue" prompts before failing idle session (optional, Copilot only, default 5)
default_reasoning_effort: string # Workflow-wide reasoning/thinking effort: low, medium, high, xhigh, max (optional)
skills: [string] # Skills enabled for every provider-backed agent (default: [])
# Each entry is a built-in NAME or a filesystem PATH.
Expand Down
22 changes: 22 additions & 0 deletions src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3419,6 +3419,28 @@ def _coerce_provider(cls, value: Any) -> Any:
(Claude: 50, Copilot: unlimited).
"""

idle_timeout_seconds: float | None = Field(None, ge=1.0)
"""Time without SDK events before a Copilot session is treated as idle.

Copilot provider only; other providers ignore this field. Default is
None, which uses the provider's built-in default (90s). A session is
only considered idle when no SDK events at all have arrived within the
window — an in-flight tool call (between ``tool.execution_start`` and
``tool.execution_complete``) suppresses the check entirely, since most
tools emit nothing during execution (see
``IdleRecoveryConfig.idle_timeout_seconds`` in ``providers/copilot.py``
for the full rationale).
"""

max_idle_recovery_attempts: int | None = Field(None, ge=0)
"""Maximum number of "please continue" prompts sent to an idle Copilot session.

Copilot provider only; other providers ignore this field. Default is
None, which uses the provider's built-in default (5). ``0`` means the
session fails on the first genuine idle timeout without ever injecting
a recovery prompt.
"""

default_reasoning_effort: ReasoningEffort | None = None
"""Workflow-wide default reasoning effort applied to provider-backed agents.

Expand Down
32 changes: 32 additions & 0 deletions src/conductor/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,8 @@ def _validate_provider_capabilities(
# no matter where future call sites are added.
runtime_default_effort = config.workflow.runtime.default_reasoning_effort
runtime_max_session_seconds = config.workflow.runtime.max_session_seconds
runtime_idle_timeout_seconds = config.workflow.runtime.idle_timeout_seconds
runtime_max_idle_recovery_attempts = config.workflow.runtime.max_idle_recovery_attempts
runtime_working_dir = config.workflow.runtime.working_dir
runtime_skills = config.workflow.runtime.skills
skill_limits = config.workflow.runtime.skill_injection
Expand Down Expand Up @@ -2466,6 +2468,36 @@ def _check_agent_capabilities(
f"max_session_seconds."
)

# ----- Workflow-level: idle_recovery tuning knobs -----
# runtime.idle_timeout_seconds / runtime.max_idle_recovery_attempts are
# Copilot-only tuning knobs (#488). Unlike max_session_seconds, these are
# not safety bounds — a provider that ignores them just runs its own
# idle-detection defaults (or none at all) rather than violating an
# operational guarantee — so a mismatch is a warning, not an error.
if runtime_idle_timeout_seconds is not None or runtime_max_idle_recovery_attempts is not None:
providers_using_idle_recovery: dict[str, list[str]] = {}
for agent in all_llm_agents:
pname = _resolved_provider_name(agent, default_provider)
providers_using_idle_recovery.setdefault(pname, []).append(agent.name)
for pname, agent_names in providers_using_idle_recovery.items():
pcaps = _caps_for(pname)
if pcaps is not None and not pcaps.idle_recovery:
set_fields = [
name
for name, value in (
("idle_timeout_seconds", runtime_idle_timeout_seconds),
("max_idle_recovery_attempts", runtime_max_idle_recovery_attempts),
)
if value is not None
]
warnings.append(
f"Workflow declares 'runtime.{'/'.join(set_fields)}' but provider "
f"'{pname}' does not support idle-recovery tuning "
f"(capabilities.idle_recovery=False) and is used by agent(s): "
f"{sorted(agent_names)!r}. The setting will be silently ignored "
f"for these agents."
)

# ----- Workflow-level: working_dir -----
# A runtime-wide working_dir is inherited by every LLM agent that does
# not set its own. A provider that cannot apply it would silently run
Expand Down
11 changes: 11 additions & 0 deletions src/conductor/providers/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ class ProviderCapabilities(BaseModel):
session_continuity: bool = False
"""``True`` when the provider supports per-agent ``session_key``."""

idle_recovery: bool = False
"""``True`` when the provider honors ``runtime.idle_timeout_seconds`` /
``runtime.max_idle_recovery_attempts`` (#488). These are Copilot-only
tuning knobs for its SDK-event-driven idle watchdog; other providers
have no equivalent mechanism and silently ignore both fields. Unlike
``max_session_seconds``, this is a tuning knob rather than a safety
bound, so a mismatch is a validate-time **warning**, not an error.
Defaults to ``False``."""

max_temperature: float | None = None
"""Highest temperature the provider accepts.

Expand Down Expand Up @@ -274,6 +283,8 @@ def declared_limitations(self) -> list[str]:
items.append("no skills support")
if not self.session_continuity:
items.append("no session_key continuity")
if not self.idle_recovery:
items.append("idle_timeout_seconds/max_idle_recovery_attempts ignored")
return items


Expand Down
Loading
Loading