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
7 changes: 6 additions & 1 deletion app/services/graph_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ def interview_agents(
) -> InterviewResult:
"""Interview simulated agents via OASIS IPC."""
from .simulation_runner import SimulationRunner
from ..utils.llm_client import CLI_CALL_TIMEOUT_SECONDS

result = InterviewResult(
interview_topic=interview_requirement,
Expand Down Expand Up @@ -348,11 +349,15 @@ def interview_agents(
for idx in selected_indices
]

# One full CLI-call ceiling per interviewed agent: a fixed 180s budget
# starved whole batches to 0/N with subprocess-based providers.
batch_timeout = float(CLI_CALL_TIMEOUT_SECONDS * max(1, len(interviews_request)))

api_result = SimulationRunner.interview_agents_batch(
simulation_id=simulation_id,
interviews=interviews_request,
platform=None,
timeout=180.0,
timeout=batch_timeout,
)

if not api_result.get("success", False):
Expand Down
64 changes: 56 additions & 8 deletions app/utils/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
LLM Client — CLI-only providers (Claude Code, Codex).
"""

import functools
import json
import re
import shutil
import subprocess
import time
import tempfile
from typing import Optional, Dict, Any, List

from ..config import Config
Expand All @@ -16,6 +19,39 @@
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2.0 # seconds

# Wall-clock ceiling for a single CLI provider call. Long prompts (large
# knowledge-graph context, interview batches) can legitimately take several
# minutes through a subprocess-based provider. Callers that wait on multiple
# sequential calls (e.g. interview batches) should budget per call using this
# constant rather than hardcoding their own number.
CLI_CALL_TIMEOUT_SECONDS = 600


class LLMTimeoutError(RuntimeError):
"""A CLI call exhausted its wall-clock ceiling.

Kept distinct from generic RuntimeError so the retry loop can skip it:
a prompt that exhausts the full ceiling will almost certainly exhaust it
again, and retrying only multiplies the wait (3x600s + backoff ~= 30 min
blocked on a single call).
"""


@functools.lru_cache(maxsize=None)
def _resolve_cli(binary: str) -> str:
"""Resolve a provider CLI to an absolute path, failing with a clear error.

On Windows, invoking a bare name only works for .exe; npm-style .cmd shims
need the resolved path. shutil.which also gives us a clean early failure
instead of an opaque FileNotFoundError mid-simulation.
"""
resolved = shutil.which(binary)
if resolved is None:
raise RuntimeError(
f"'{binary}' CLI not found on PATH. Install it and log in before running simulations."
)
return resolved


class LLMClient:
"""LLM Client — supports claude-cli and codex-cli."""
Expand Down Expand Up @@ -59,6 +95,9 @@ def chat(
if self.provider == "codex-cli":
return self._chat_codex_cli(messages, temperature, max_tokens, response_format)
return self._chat_claude_cli(messages, temperature, max_tokens, response_format)
except LLMTimeoutError:
# No retry: see LLMTimeoutError docstring
raise
except RuntimeError as exc:
last_error = exc
if attempt < MAX_RETRIES - 1:
Expand Down Expand Up @@ -92,15 +131,20 @@ def _chat_claude_cli(

try:
result = subprocess.run(
["claude", "-p", "--output-format", "json", prompt],
capture_output=True, text=True, timeout=300,
cwd="/tmp"
[_resolve_cli("claude"), "-p", "--output-format", "json"],
input=prompt,
capture_output=True, text=True, timeout=CLI_CALL_TIMEOUT_SECONDS,
encoding="utf-8", errors="replace",
cwd=tempfile.gettempdir()
)

if result.returncode != 0:
logger.error(f"Claude CLI error: {result.stderr[:200]}")
raise RuntimeError(f"Claude CLI failed: {result.stderr[:200]}")

if "�" in result.stdout:
logger.warning("Claude CLI output contained undecodable bytes; replaced with U+FFFD")

try:
output = json.loads(result.stdout)
content = output.get("result", result.stdout)
Expand All @@ -110,7 +154,7 @@ def _chat_claude_cli(
return self._clean_content(content)

except subprocess.TimeoutExpired:
raise RuntimeError("Claude CLI timed out after 300s")
raise LLMTimeoutError(f"Claude CLI timed out after {CLI_CALL_TIMEOUT_SECONDS}s")

def _chat_codex_cli(
self,
Expand All @@ -137,16 +181,20 @@ def _chat_codex_cli(

try:
result = subprocess.run(
["codex", "exec", "--skip-git-repo-check"],
[_resolve_cli("codex"), "exec", "--skip-git-repo-check"],
input=prompt,
capture_output=True, text=True, timeout=180,
cwd="/tmp"
capture_output=True, text=True, timeout=CLI_CALL_TIMEOUT_SECONDS,
encoding="utf-8", errors="replace",
cwd=tempfile.gettempdir()
)

if result.returncode != 0:
logger.error(f"Codex CLI error: {result.stderr[:200]}")
raise RuntimeError(f"Codex CLI failed: {result.stderr[:200]}")

if "�" in result.stdout:
logger.warning("Codex CLI output contained undecodable bytes; replaced with U+FFFD")

raw = result.stdout.strip()
parts = raw.split("\ncodex\n")
if len(parts) > 1:
Expand All @@ -163,7 +211,7 @@ def _chat_codex_cli(
return self._clean_content(content)

except subprocess.TimeoutExpired:
raise RuntimeError("Codex CLI timed out after 180s")
raise LLMTimeoutError(f"Codex CLI timed out after {CLI_CALL_TIMEOUT_SECONDS}s")

def chat_json(
self,
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ dev = [

[tool.hatch.build.targets.wheel]
packages = ["app"]

[tool.uv]
# rapidfuzz >=3.14 ships no Windows cp312 wheels (and no sdist in-range), which
# breaks `uv sync` under requires-python <3.13 on Windows. Scoped to win32 so
# macOS/Linux keep the resolver's pick; drop once upstream publishes wheels.
override-dependencies = ["rapidfuzz==3.13.0; sys_platform == 'win32'"]
60 changes: 26 additions & 34 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.