Skip to content

Commit 812a7dc

Browse files
jrob5756Jason RobertCopilot
authored
fix(providers): suppress idle watchdog during in-flight Copilot tool calls (#490)
* fix(providers): suppress idle watchdog during in-flight Copilot tool calls The Copilot SDK emits no events between tool.execution_start and tool.execution_complete, so a stale idle clock during a long-running tool call was indistinguishable from a genuinely stuck session, triggering a spurious "please continue" recovery prompt mid tool-call that could overwrite the agent's eventual structured output. In-flight tool calls (tracked by tool_call_id) now suppress idle recovery entirely while any remain outstanding; max_session_seconds / max_agent_iterations remain the backstop for a genuinely wedged tool. last_activity_ref's tool name is cleared (or rolled to another still-in-flight tool) on tool.execution_complete instead of only ever being set. Adds configurable runtime.idle_timeout_seconds and runtime.max_idle_recovery_attempts (Copilot-only) so workflows with legitimately long tool calls can tune the watchdog. Closes #488 * fix(providers): address PR #490 review findings on idle watchdog suppression Blocking fixes (#488): - Remove the "pop the oldest entry" fallback in the tool.execution_complete handler that could evict a different, still-running tool's active_tools entry on a duplicate/unmatched event, re-arming the watchdog mid-tool-call and reproducing #488 while appearing fixed. Replaced with a non-mutating debug log; max_session_seconds remains the backstop for a stale entry. - Strengthen TestOnEventActiveTools assertions so both tests actually pin the fix (verified to fail against the pre-fix provider, pass post-fix). - Add an end-to-end regression test driving a real tool.execution_start -> silence -> tool.execution_complete sequence through _send_and_wait, asserting the recovery prompt never clobbers response_content. - Add a warn-once latch (mirroring _context_window_anomaly_warned) so the first occurrence of extended idle-recovery suppression during a session is logged at warning level (console + logger), instead of silently degrading a previously console-visible 90s warning into up to 31.5 minutes of total silence. Recommendations applied: - Corrected the repeated false claim that the SDK "emits no events" during a tool call (it does not guarantee any, but tool.execution_progress / tool.execution_partial_result exist and are opt-in) across copilot.py, schema.py, docs/configuration.md, CHANGELOG.md, and the PR description; consolidated the rationale into one canonical docstring. - Corrected the inaccurate claim that max_agent_iterations backstops a wedged tool call (its counter only advances on tool.execution_start, so it's frozen for the whole wedge) — max_session_seconds is the sole backstop. - Added IdleRecoveryConfig.__post_init__ validation so directly-constructed configs (bypassing the Pydantic schema bounds) can't produce an unbounded busy-wait loop. - Simplified factory.py's IdleRecoveryConfig construction to a dict-filter + single constructor call instead of a three-way ternary per field. - Added ProviderCapabilities.idle_recovery (Copilot-only) with a workflow-level validator warning (not an error, since these are tuning knobs rather than safety bounds) when idle_timeout_seconds / max_idle_recovery_attempts are set against a provider that ignores them. - Bounded two previously-unbounded busy-wait test loops with asyncio.wait_for(..., timeout=5.0). - Added an overlapping-tool-calls end-to-end test keyed on tool_call_id (not tool_name), verified to reproduce the hang if the dict were mistakenly keyed by tool name instead. - Documented the max_session_seconds backstop in docs/configuration.md so a legitimately long tool call doesn't silently exceed it unexpectedly. Skipped: ACA forwarding of the two idle-recovery fields (larger, separate scope spanning factory/aca/aca_runner) and AGENTS.md documentation update (the two runtime knobs and active_tools mechanism are already documented in the config docs and code comments; deferring to keep this diff scoped to the review findings). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jason Robert <jasonrobert@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 75eceda commit 812a7dc

16 files changed

Lines changed: 1014 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
A custom `base_url` requires an explicit `api_key`: an ambient `OPENAI_API_KEY`
2323
is never forwarded to a non-OpenAI endpoint.
2424

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

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

3544
### Fixed
3645

46+
- **The Copilot idle watchdog no longer fires during long-running tool
47+
calls** (#488). The SDK does not guarantee any events during a tool
48+
call — `tool.execution_progress` / `tool.execution_partial_result` exist
49+
in the SDK schema but are opt-in per tool, so for most tool calls nothing
50+
arrives between `tool.execution_start` and `tool.execution_complete` — so
51+
a stale idle clock while a tool was still executing was previously
52+
indistinguishable from a genuinely stuck session — triggering a spurious
53+
"please continue" recovery prompt mid tool-call. That prompt's
54+
conversational reply then overwrote the agent's eventual structured
55+
output (`response_content` is last-message-wins), turning a healthy run
56+
into a non-retryable failure. In-flight tool calls (tracked by
57+
`tool_call_id`) now suppress idle recovery entirely while any remain
58+
outstanding; `max_session_seconds` is the sole backstop for a genuinely
59+
wedged tool. Recovery-prompt and
60+
stuck-session messages also no longer misattribute the failure to a tool
61+
that has already completed — `last_activity_ref`'s tool name is now
62+
cleared (or rolled to another still-in-flight tool) on
63+
`tool.execution_complete` instead of only ever being set. The first
64+
occurrence of extended suppression during a session is logged at
65+
`warning` level (naming the in-flight tools and the `max_session_seconds`
66+
backstop); further occurrences in the same session are debug-only.
3767
- Retry classification now covers the `ModelHTTPError` and `ModelAPIError` types
3868
pydantic-ai actually raises, so `408`, `429` and `5xx` responses are retried on
3969
the Claude provider as well as the new OpenAI one. Previously they were treated

docs/configuration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ workflow:
1717
max_tokens: 4096
1818
default_reasoning_effort: medium # low | medium | high | xhigh | max (optional)
1919
default_context_tier: default # default | long_context (optional, Copilot only)
20+
idle_timeout_seconds: 90 # Copilot only (optional)
21+
max_idle_recovery_attempts: 5 # Copilot only (optional)
2022
# Provider-specific settings...
2123
```
2224

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

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

3652
### Copilot Provider
@@ -47,6 +63,8 @@ workflow:
4763
command: npx
4864
args: ["-y", "open-websearch@latest"]
4965
tools: ["*"]
66+
idle_timeout_seconds: 90
67+
max_idle_recovery_attempts: 5
5068
```
5169
5270
**Features**:

docs/workflow-syntax.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ workflow:
7575
# its own `context_tier`.
7676
# See docs/configuration.md#context-tier.
7777

78+
idle_timeout_seconds: 90 # Optional: seconds without SDK events before a
79+
# Copilot session is treated as idle (Copilot only).
80+
# Default: 90. Suppressed entirely while a tool
81+
# call is in flight.
82+
max_idle_recovery_attempts: 5 # Optional: "please continue" prompts sent before
83+
# failing an idle Copilot session (Copilot only).
84+
# Default: 5. 0 means fail on first genuine idle.
85+
7886
working_dir: "/path/to/cwd" # Optional: global default working directory for LLM agents
7987
# and their MCP servers. Relative paths resolve against the
8088
# parent directory of the workflow YAML file.

plugins/conductor/skills/conductor/references/yaml-schema.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ workflow:
3535
timeout: float # Per-request timeout in seconds (optional, default: 600, copilot/claude only)
3636
max_agent_iterations: integer # Max tool-use roundtrips per agent (1-500, optional)
3737
max_session_seconds: float # Wall-clock timeout per agent session in seconds (optional)
38+
idle_timeout_seconds: float # Seconds without SDK events before session is idle (optional, Copilot only, default 90)
39+
max_idle_recovery_attempts: integer # "please continue" prompts before failing idle session (optional, Copilot only, default 5)
3840
default_reasoning_effort: string # Workflow-wide reasoning/thinking effort: low, medium, high, xhigh, max (optional)
3941
skills: [string] # Skills enabled for every provider-backed agent (default: [])
4042
# Each entry is a built-in NAME or a filesystem PATH.

src/conductor/config/schema.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,6 +3419,28 @@ def _coerce_provider(cls, value: Any) -> Any:
34193419
(Claude: 50, Copilot: unlimited).
34203420
"""
34213421

3422+
idle_timeout_seconds: float | None = Field(None, ge=1.0)
3423+
"""Time without SDK events before a Copilot session is treated as idle.
3424+
3425+
Copilot provider only; other providers ignore this field. Default is
3426+
None, which uses the provider's built-in default (90s). A session is
3427+
only considered idle when no SDK events at all have arrived within the
3428+
window — an in-flight tool call (between ``tool.execution_start`` and
3429+
``tool.execution_complete``) suppresses the check entirely, since most
3430+
tools emit nothing during execution (see
3431+
``IdleRecoveryConfig.idle_timeout_seconds`` in ``providers/copilot.py``
3432+
for the full rationale).
3433+
"""
3434+
3435+
max_idle_recovery_attempts: int | None = Field(None, ge=0)
3436+
"""Maximum number of "please continue" prompts sent to an idle Copilot session.
3437+
3438+
Copilot provider only; other providers ignore this field. Default is
3439+
None, which uses the provider's built-in default (5). ``0`` means the
3440+
session fails on the first genuine idle timeout without ever injecting
3441+
a recovery prompt.
3442+
"""
3443+
34223444
default_reasoning_effort: ReasoningEffort | None = None
34233445
"""Workflow-wide default reasoning effort applied to provider-backed agents.
34243446

src/conductor/config/validator.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,6 +1821,8 @@ def _validate_provider_capabilities(
18211821
# no matter where future call sites are added.
18221822
runtime_default_effort = config.workflow.runtime.default_reasoning_effort
18231823
runtime_max_session_seconds = config.workflow.runtime.max_session_seconds
1824+
runtime_idle_timeout_seconds = config.workflow.runtime.idle_timeout_seconds
1825+
runtime_max_idle_recovery_attempts = config.workflow.runtime.max_idle_recovery_attempts
18241826
runtime_working_dir = config.workflow.runtime.working_dir
18251827
runtime_skills = config.workflow.runtime.skills
18261828
skill_limits = config.workflow.runtime.skill_injection
@@ -2466,6 +2468,36 @@ def _check_agent_capabilities(
24662468
f"max_session_seconds."
24672469
)
24682470

2471+
# ----- Workflow-level: idle_recovery tuning knobs -----
2472+
# runtime.idle_timeout_seconds / runtime.max_idle_recovery_attempts are
2473+
# Copilot-only tuning knobs (#488). Unlike max_session_seconds, these are
2474+
# not safety bounds — a provider that ignores them just runs its own
2475+
# idle-detection defaults (or none at all) rather than violating an
2476+
# operational guarantee — so a mismatch is a warning, not an error.
2477+
if runtime_idle_timeout_seconds is not None or runtime_max_idle_recovery_attempts is not None:
2478+
providers_using_idle_recovery: dict[str, list[str]] = {}
2479+
for agent in all_llm_agents:
2480+
pname = _resolved_provider_name(agent, default_provider)
2481+
providers_using_idle_recovery.setdefault(pname, []).append(agent.name)
2482+
for pname, agent_names in providers_using_idle_recovery.items():
2483+
pcaps = _caps_for(pname)
2484+
if pcaps is not None and not pcaps.idle_recovery:
2485+
set_fields = [
2486+
name
2487+
for name, value in (
2488+
("idle_timeout_seconds", runtime_idle_timeout_seconds),
2489+
("max_idle_recovery_attempts", runtime_max_idle_recovery_attempts),
2490+
)
2491+
if value is not None
2492+
]
2493+
warnings.append(
2494+
f"Workflow declares 'runtime.{'/'.join(set_fields)}' but provider "
2495+
f"'{pname}' does not support idle-recovery tuning "
2496+
f"(capabilities.idle_recovery=False) and is used by agent(s): "
2497+
f"{sorted(agent_names)!r}. The setting will be silently ignored "
2498+
f"for these agents."
2499+
)
2500+
24692501
# ----- Workflow-level: working_dir -----
24702502
# A runtime-wide working_dir is inherited by every LLM agent that does
24712503
# not set its own. A provider that cannot apply it would silently run

src/conductor/providers/capabilities.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ class ProviderCapabilities(BaseModel):
192192
session_continuity: bool = False
193193
"""``True`` when the provider supports per-agent ``session_key``."""
194194

195+
idle_recovery: bool = False
196+
"""``True`` when the provider honors ``runtime.idle_timeout_seconds`` /
197+
``runtime.max_idle_recovery_attempts`` (#488). These are Copilot-only
198+
tuning knobs for its SDK-event-driven idle watchdog; other providers
199+
have no equivalent mechanism and silently ignore both fields. Unlike
200+
``max_session_seconds``, this is a tuning knob rather than a safety
201+
bound, so a mismatch is a validate-time **warning**, not an error.
202+
Defaults to ``False``."""
203+
195204
max_temperature: float | None = None
196205
"""Highest temperature the provider accepts.
197206
@@ -274,6 +283,8 @@ def declared_limitations(self) -> list[str]:
274283
items.append("no skills support")
275284
if not self.session_continuity:
276285
items.append("no session_key continuity")
286+
if not self.idle_recovery:
287+
items.append("idle_timeout_seconds/max_idle_recovery_attempts ignored")
277288
return items
278289

279290

0 commit comments

Comments
 (0)