Skip to content

Commit 98ac1ae

Browse files
committed
feat: Implement research timeout with time-based polling configurable via environment variables and add a CLI command to recover timed-out research runs.
1 parent 852f1bb commit 98ac1ae

File tree

8 files changed

+193
-19
lines changed

8 files changed

+193
-19
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# AI Configuration
12
OPENAI_API_KEY=sk-...
23
PROMPT_REFINER_MODEL=gpt-5.2
34
DEEP_RESEARCH_MODEL=o3-deep-research
5+
6+
# Research Configuration
7+
POLLING_INTERVAL_IN_SECONDS=10
8+
MAX_POLL_TIME_IN_MINUTES=60

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Create a `.env` file (untracked) with your OpenAI credentials:
3636
OPENAI_API_KEY=sk-...
3737
PROMPT_REFINER_MODEL=gpt-5.2
3838
DEEP_RESEARCH_MODEL=o3-deep-research
39+
POLLING_INTERVAL_IN_SECONDS=10
40+
MAX_POLL_TIME_IN_MINUTES=60
3941
```
4042

4143
Deep research requires an OpenAI account with the browsing tooling enabled. Document any environment keys for additional tooling in the repo as you add them.
@@ -68,6 +70,17 @@ pdm run compendium render my-topic.xml --format html
6870
- `--format FORMAT` — Output format(s) to generate (`md`, `xml`, `html`, `pdf`).
6971
- `--output PATH` — Base path/filename for the output.
7072

73+
### 5. Recover from a timeout
74+
75+
If a research task times out (exceeding `MAX_POLL_TIME_IN_MINUTES`), recovery information is saved to `timed_out_research.json`. You can resume checking for its completion without starting over:
76+
77+
```bash
78+
pdm run compendium recover
79+
```
80+
81+
**Options:**
82+
- `--input PATH` — Path to the recovery JSON file (defaults to `timed_out_research.json`).
83+
7184
---
7285

7386
## Library Usage
@@ -78,7 +91,11 @@ from compendiumscribe import build_compendium, ResearchConfig, DeepResearchError
7891
try:
7992
compendium = build_compendium(
8093
"Emerging pathogen surveillance",
81-
config=ResearchConfig(background=False, max_tool_calls=30),
94+
config=ResearchConfig(
95+
background=False,
96+
max_tool_calls=30,
97+
max_poll_time_minutes=15,
98+
),
8299
)
83100
except DeepResearchError as exc:
84101
# Handle or log deep research failures

src/compendiumscribe/cli.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from __future__ import annotations
2-
1+
import json
32
import re
4-
from datetime import datetime
3+
from datetime import datetime, timezone
54
from pathlib import Path
6-
from typing import TYPE_CHECKING
5+
from typing import Any, TYPE_CHECKING
76

87
import click
98

@@ -15,6 +14,7 @@
1514
DeepResearchError,
1615
ResearchConfig,
1716
ResearchProgress,
17+
ResearchTimeoutError,
1818
build_compendium,
1919
)
2020

@@ -83,6 +83,13 @@ def handle_progress(update: ResearchProgress) -> None:
8383
meta = update.metadata or {}
8484
if "poll_attempt" in meta:
8585
suffix = f" (poll #{meta['poll_attempt']})"
86+
87+
if "elapsed_seconds" in meta:
88+
seconds = meta["elapsed_seconds"]
89+
mins, secs = divmod(seconds, 60)
90+
time_str = f"{mins}m {secs}s" if mins > 0 else f"{secs}s"
91+
suffix += f" [Time elapsed: {time_str}]"
92+
8693
stream_kwargs = {"err": update.status == "error"}
8794
click.echo(
8895
f"[{timestamp}] {phase_label}: {update.message}{suffix}",
@@ -98,6 +105,19 @@ def handle_progress(update: ResearchProgress) -> None:
98105
try:
99106
client = create_openai_client(timeout=config.request_timeout_seconds)
100107
compendium = build_compendium(topic, client=client, config=config)
108+
except ResearchTimeoutError as exc:
109+
timeout_data = {
110+
"research_id": exc.research_id,
111+
"topic": topic,
112+
"no_background": no_background,
113+
"formats": list(formats),
114+
"max_tool_calls": max_tool_calls,
115+
"timestamp": datetime.now(timezone.utc).isoformat(),
116+
}
117+
Path("timed_out_research.json").write_text(json.dumps(timeout_data, indent=2))
118+
click.echo(f"\n[!] Deep research timed out (ID: {exc.research_id}).", err=True)
119+
click.echo(f"Stored recovery information in timed_out_research.json", err=True)
120+
raise SystemExit(1) from exc
101121
except MissingAPIKeyError as exc:
102122
click.echo(f"Configuration error: {exc}", err=True)
103123
raise SystemExit(1) from exc
@@ -167,6 +187,72 @@ def render(
167187
_write_outputs(compendium, base_path, formats)
168188

169189

190+
@cli.command()
191+
@click.option(
192+
"--input",
193+
"input_file",
194+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
195+
default=Path("timed_out_research.json"),
196+
show_default=True,
197+
help="Path to the recovery JSON file.",
198+
)
199+
def recover(input_file: Path):
200+
"""Recover a timed-out deep research run."""
201+
if not input_file.exists():
202+
click.echo(f"Error: Recovery file {input_file} not found.", err=True)
203+
raise SystemExit(1)
204+
205+
try:
206+
data = json.loads(input_file.read_text(encoding="utf-8"))
207+
research_id = data["research_id"]
208+
topic = data["topic"]
209+
formats = tuple(data["formats"])
210+
max_tool_calls = data.get("max_tool_calls")
211+
no_background = data.get("no_background", False)
212+
except (json.JSONDecodeError, KeyError) as exc:
213+
click.echo(f"Error: Failed to parse recovery file: {exc}", err=True)
214+
raise SystemExit(1)
215+
216+
click.echo(f"Checking status for research ID: {research_id} ('{topic}')...")
217+
218+
config = ResearchConfig(
219+
background=not no_background,
220+
max_tool_calls=max_tool_calls,
221+
)
222+
223+
try:
224+
client = create_openai_client(timeout=config.request_timeout_seconds)
225+
response = client.responses.retrieve(research_id)
226+
from .research.utils import coerce_optional_string, get_field
227+
status = coerce_optional_string(get_field(response, "status"))
228+
229+
if status != "completed":
230+
click.echo(f"Research is not yet completed (current status: {status}).")
231+
click.echo("Please try again later.")
232+
return
233+
234+
click.echo("Research completed! Decoding payload and writing outputs.")
235+
from .research.parsing import parse_deep_research_response
236+
from .compendium import Compendium
237+
238+
payload = parse_deep_research_response(response)
239+
compendium = Compendium.from_payload(
240+
topic=topic,
241+
payload=payload,
242+
generated_at=datetime.now(timezone.utc),
243+
)
244+
245+
slug = _generate_slug(topic)
246+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
247+
base_path = Path(f"{slug}_{timestamp}")
248+
249+
_write_outputs(compendium, base_path, formats)
250+
251+
except Exception as exc:
252+
click.echo(f"Error during recovery: {exc}", err=True)
253+
raise SystemExit(1)
254+
255+
170256
def _write_outputs(
171257
compendium: "Compendium", base_path: Path, formats: tuple[str, ...]
172258
) -> None:

src/compendiumscribe/research/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from .config import ResearchConfig
4-
from .errors import DeepResearchError
4+
from .errors import (
5+
DeepResearchError,
6+
ResearchTimeoutError,
7+
)
58
from .execution import (
69
await_completion,
710
execute_deep_research,
@@ -37,6 +40,7 @@
3740

3841
__all__ = [
3942
"DeepResearchError",
43+
"ResearchTimeoutError",
4044
"ResearchConfig",
4145
"ProgressPhase",
4246
"ProgressStatus",

src/compendiumscribe/research/config.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ class ResearchConfig:
2424
)
2525
use_prompt_refinement: bool = True
2626
background: bool = True
27-
poll_interval_seconds: float = 5.0
28-
max_poll_attempts: int = 240
27+
polling_interval_seconds: float = field(
28+
default_factory=lambda: float(
29+
os.getenv("POLLING_INTERVAL_IN_SECONDS", "10.0")
30+
)
31+
)
32+
max_poll_time_minutes: float = field(
33+
default_factory=lambda: float(
34+
os.getenv("MAX_POLL_TIME_IN_MINUTES", "60.0")
35+
)
36+
)
2937
enable_code_interpreter: bool = True
3038
use_web_search: bool = True
3139
max_tool_calls: int | None = None

src/compendiumscribe/research/errors.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,16 @@ class MissingConfigurationError(RuntimeError):
99
"""Raised when required configuration is missing."""
1010

1111

12-
__all__ = ["DeepResearchError", "MissingConfigurationError"]
12+
class ResearchTimeoutError(DeepResearchError):
13+
"""Raised when deep research exceeds the configured time limit."""
14+
15+
def __init__(self, message: str, research_id: str):
16+
super().__init__(message)
17+
self.research_id = research_id
18+
19+
20+
__all__ = [
21+
"DeepResearchError",
22+
"MissingConfigurationError",
23+
"ResearchTimeoutError",
24+
]

src/compendiumscribe/research/execution/polling.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import time
55

66
from ..config import ResearchConfig
7-
from ..errors import DeepResearchError
7+
from ..errors import DeepResearchError, ResearchTimeoutError
88
from ..progress import emit_progress
99
from ..utils import coerce_optional_string, get_field
1010

@@ -27,9 +27,20 @@ def await_completion(
2727
)
2828

2929
current = response
30-
while attempts < config.max_poll_attempts:
31-
time.sleep(config.poll_interval_seconds)
30+
start_time = time.monotonic()
31+
max_seconds = config.max_poll_time_minutes * 60
32+
33+
while True:
34+
elapsed_seconds = int(time.monotonic() - start_time)
35+
if elapsed_seconds > max_seconds:
36+
raise ResearchTimeoutError(
37+
f"Deep research did not complete within the {config.max_poll_time_minutes} minute limit.",
38+
research_id=response.id,
39+
)
40+
41+
time.sleep(config.polling_interval_seconds)
3242
attempts += 1
43+
elapsed_seconds = int(time.monotonic() - start_time)
3344

3445
current = client.responses.retrieve(response.id)
3546
status = coerce_optional_string(get_field(current, "status"))
@@ -40,7 +51,10 @@ def await_completion(
4051
phase="deep_research",
4152
status="completed",
4253
message="Deep research run finished; decoding payload.",
43-
metadata={"status": status},
54+
metadata={
55+
"status": status,
56+
"elapsed_seconds": elapsed_seconds,
57+
},
4458
)
4559
break
4660

@@ -57,11 +71,8 @@ def await_completion(
5771
metadata={
5872
"status": status,
5973
"poll_attempt": attempts,
74+
"elapsed_seconds": elapsed_seconds,
6075
},
6176
)
62-
else:
63-
raise DeepResearchError(
64-
"Deep research did not complete within the polling limit."
65-
)
6677

6778
return current

tests/research/execution/test_core.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ def retrieve(self, response_id: str):
104104

105105
config = ResearchConfig(
106106
background=True,
107-
poll_interval_seconds=0,
108-
max_poll_attempts=3,
107+
polling_interval_seconds=0,
108+
max_poll_time_minutes=1,
109109
progress_callback=callback,
110110
)
111111

@@ -123,3 +123,34 @@ def retrieve(self, response_id: str):
123123
"completed",
124124
"Deep research run finished; decoding payload.",
125125
) in progress_updates
126+
127+
128+
def test_execute_deep_research_raises_timeout_error():
129+
pending = SimpleNamespace(
130+
id="resp_poll",
131+
status="in_progress",
132+
output=[],
133+
)
134+
135+
class FastPollingResponses:
136+
def create(self, **kwargs):
137+
return pending
138+
139+
def retrieve(self, response_id: str):
140+
return pending
141+
142+
responses = FastPollingResponses()
143+
client = SimpleNamespace(responses=responses)
144+
145+
# Set a very short timeout and interval
146+
config = ResearchConfig(
147+
background=True,
148+
polling_interval_seconds=0.01,
149+
max_poll_time_minutes=0.0001, # Fraction of a second
150+
)
151+
152+
from compendiumscribe.research.errors import ResearchTimeoutError
153+
with pytest.raises(ResearchTimeoutError) as excinfo:
154+
execute_deep_research(client, "prompt", config)
155+
156+
assert excinfo.value.research_id == "resp_poll"

0 commit comments

Comments
 (0)