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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).

## [4.102.0] - 2026-10-24

### Fixed: 일하는 턴은 시계로 끊기지 않는다 (아무 일도 없을 때만 끝난다)

DEV 에서 긴 에이전트 턴(긴 빌드·연이은 도구 호출)이 일하는 중에 끊겼다. 시간 한도가 모두 **시작부터 센 고정값**이었다.

- `RunBudget(max_idle_seconds=…)`: 유휴 모드. 아무 일도 없이(모델 호출·도구 호출이 없고 도는 것도 없이) 그만큼 지나야
끝난다. 도는 도구(`reserve`~`release`)와 도는 모델 호출(`begin_model_call`~`end_model_call`)은 활동이다.
`max_wall_seconds` 는 상한이 된다. 기본(유휴 모드 아님)은 예전과 같다. `expired()`, `idle_seconds()`, `touch()`.
- `max_total_result_bytes`: 턴 전체 결과 바이트 상한을 결과 하나의 상한(`max_result_bytes`)과 따로 둔다.
- Stage 6 이 모델 호출을 도는 동안 호스트에 알린다(`_host_model_activity`, `bind_run_guard` 가 건다).
- 라우터의 임시 취소 범위는 마감을 한 번 읽지 않고 예산에 매번 묻는다(유휴 모드의 마감은 활동으로 밀린다).
- CLI(claude_code·codex): `timeout_s` 가 **유휴 한도**가 됐다. 출력도 도구 활동도 없이 그만큼 지나야 멈춘다. 브릿지
도구가 도는 동안 CLI 는 아무것도 출력하지 않으므로 턴의 도구 표면이 활동을 알린다(`TurnToolSurface.idle_seconds`,
`set_activity_probe`). 예전에는 시작부터 1시간이면 일하는 중에도 끊었다.

## [4.101.0] - 2026-10-23

### Fixed: 2026-10-23 전체 흐름 검토 (창, assignment 메모리, 정보 유실)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "xgen-agent-runtime"
version = "4.101.0"
version = "4.102.0"
description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API"
readme = "README.md"
license = "Apache-2.0"
Expand Down
89 changes: 80 additions & 9 deletions src/xgen_agent_runtime/host/execution_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ class ExecutionBoundaryError(RuntimeError):


class RunBudget:
"""One run's tool calls, result bytes, usage and time.

Time is either a fixed wall budget (``max_wall_seconds`` from creation, the default) or, with
``max_idle_seconds`` set, an **idle** budget: the run ends only after that long with nothing
happening (no model call, no tool call, nothing in flight); ``max_wall_seconds`` is then the hard
ceiling. A host serving long agent turns uses idle mode. A fixed five minutes cut a turn that was
working, a long build or a run of tool calls, in the middle (2026-10-24 review).

``max_result_bytes`` bounds one result; ``max_total_result_bytes`` bounds all of the run's results
together (the same value when not given, as before).
"""

def __init__(
self,
*,
Expand All @@ -25,19 +37,32 @@ def __init__(
max_wall_seconds: float = 300,
max_cost: float = 0,
max_tokens: int = 0,
max_idle_seconds: float = 0,
max_total_result_bytes: Optional[int] = None,
) -> None:
if (
min(max_calls, max_active, max_result_bytes) <= 0
or not math.isfinite(max_wall_seconds)
or max_wall_seconds <= 0
or not math.isfinite(max_idle_seconds)
or max_idle_seconds < 0
):
raise ValueError("invalid run budget")
self.max_calls, self.max_active, self.max_result_bytes = (
max_calls,
max_active,
max_result_bytes,
)
self.deadline = time.monotonic() + max_wall_seconds
self.max_total_result_bytes = (
max_result_bytes if max_total_result_bytes is None else int(max_total_result_bytes)
)
if self.max_total_result_bytes <= 0:
raise ValueError("invalid run budget")
now = time.monotonic()
self._hard_deadline = now + max_wall_seconds
self.max_idle_seconds = float(max_idle_seconds)
self._last_activity = now
self.models_active = 0
if (
not math.isfinite(max_cost)
or max_cost < 0
Expand All @@ -52,25 +77,62 @@ def __init__(
self._usage_seen: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()

@property
def idle_mode(self) -> bool:
return self.max_idle_seconds > 0

@property
def deadline(self) -> float:
"""When the run ends if nothing more happens. Work in flight keeps an idle-mode run open until
the hard ceiling."""
if not self.idle_mode:
return self._hard_deadline
if self.active > 0 or self.models_active > 0:
return self._hard_deadline
return min(self._hard_deadline, self._last_activity + self.max_idle_seconds)

def expired(self) -> bool:
return time.monotonic() >= self.deadline

def idle_seconds(self) -> float:
"""Seconds since anything happened in this run (0 while a tool or model call is in flight)."""
if self.active > 0 or self.models_active > 0:
return 0.0
return max(0.0, time.monotonic() - self._last_activity)

def touch(self) -> None:
"""Record activity (an idle-mode run stays open)."""
self._last_activity = time.monotonic()

def before_model_call(self) -> None:
"""Host port invoked before every provider dispatch, including retries."""
if time.monotonic() >= self.deadline:
if self.expired():
raise ExecutionBoundaryError("run_budget_exceeded")
self.touch()

def begin_model_call(self) -> None:
"""A provider call is in flight (a long answer or a CLI turn is activity, not idleness)."""
with self._lock:
self.models_active += 1
self._last_activity = time.monotonic()

def end_model_call(self) -> None:
with self._lock:
self.models_active = max(0, self.models_active - 1)
self._last_activity = time.monotonic()

def reserve(self) -> None:
with self._lock:
if (
time.monotonic() >= self.deadline
or self.calls >= self.max_calls
or self.active >= self.max_active
):
if self.expired() or self.calls >= self.max_calls or self.active >= self.max_active:
raise ExecutionBoundaryError("run_budget_exceeded")
self.calls += 1
self.active += 1
self._last_activity = time.monotonic()

def release(self) -> None:
with self._lock:
self.active = max(0, self.active - 1)
self._last_activity = time.monotonic()

def charge_result(self, value: Any) -> None:
# A bounded traversal prevents cyclic/deep results becoming a bypass.
Expand All @@ -83,7 +145,8 @@ def charge_result(self, value: Any) -> None:
raise ExecutionBoundaryError("tool_result_invalid") from None
with self._lock:
self.result_bytes += size
if self.result_bytes > self.max_result_bytes or time.monotonic() >= self.deadline:
self._last_activity = time.monotonic()
if self.result_bytes > self.max_total_result_bytes or self.expired():
raise ExecutionBoundaryError("run_budget_exceeded")

def charge_usage(self, *, cost: float = 0, tokens: int = 0) -> None:
Expand All @@ -98,6 +161,7 @@ def charge_usage(self, *, cost: float = 0, tokens: int = 0) -> None:
with self._lock:
self.cost += cost
self.tokens += tokens
self._last_activity = time.monotonic()
if (self.max_cost > 0 and self.cost > self.max_cost) or (
self.max_tokens > 0 and self.tokens > self.max_tokens
):
Expand All @@ -114,6 +178,7 @@ def reconcile_usage(self, key: str, *, cost: float, tokens: int) -> None:
self.tokens += tokens - old_tokens
self.cost += cost - old_cost
self._usage_seen[key] = (tokens, cost)
self._last_activity = time.monotonic()
if (self.max_cost > 0 and self.cost >= self.max_cost) or (
self.max_tokens > 0 and self.tokens >= self.max_tokens
):
Expand All @@ -131,7 +196,8 @@ def enforce(state: Any) -> None:
if context.cancellation_scope is not None:
context.cancellation_scope.check()
if context.run_budget is not None:
if time.monotonic() >= context.run_budget.deadline:
expired = getattr(context.run_budget, "expired", None)
if expired() if callable(expired) else time.monotonic() >= context.run_budget.deadline:
raise ExecutionBoundaryError("run_budget_exceeded")
usage = getattr(state, "turn_token_usage", []) or []
tokens = sum(
Expand Down Expand Up @@ -176,6 +242,11 @@ def before_model_call() -> None:
context.run_budget.before_model_call()

api_stage._host_model_boundary = before_model_call
budget = context.run_budget
if budget is not None and callable(getattr(budget, "begin_model_call", None)):
# A provider call in flight is activity: an idle-mode run must not end under a long answer
# or a CLI turn that is working (its tool calls come back through the bridge meanwhile).
api_stage._host_model_activity = (budget.begin_model_call, budget.end_model_call)


class CancellationScope:
Expand Down
31 changes: 27 additions & 4 deletions src/xgen_agent_runtime/host/tool_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import asyncio
import json
import logging
import threading
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple

Expand Down Expand Up @@ -57,9 +59,26 @@ def __init__(
#: CLI 는 자기 도구 사건을 스트림으로 알린다 — 여기서 파이프라인 사건으로 또 내면 화면에 두 번
#: 뜬다. 진단용으로만 붙잡아 둔다(최근 것 몇 개).
self.events: List[Tuple[str, Dict[str, Any]]] = []
#: 도구 활동. CLI 는 브릿지 도구가 도는 동안 아무것도 출력하지 않으므로, CLI 의 "조용함" 만으로는
#: 멈춘 것과 일하는 것을 가를 수 없다. 이 표면이 그 사실을 안다(:meth:`idle_seconds`).
self._calls_in_flight = 0
self._last_activity = time.monotonic()
self._activity_lock = threading.Lock()

# ── 수명 ─────────────────────────────────────────────────────────

def idle_seconds(self) -> float:
"""마지막 도구 활동 뒤 흐른 시간(초). 도구가 도는 중이면 0."""
with self._activity_lock:
if self._calls_in_flight > 0:
return 0.0
return max(0.0, time.monotonic() - self._last_activity)

def _activity(self, delta: int) -> None:
with self._activity_lock:
self._calls_in_flight = max(0, self._calls_in_flight + delta)
self._last_activity = time.monotonic()

def bind_loop(self, loop: Optional[asyncio.AbstractEventLoop]) -> None:
"""턴 루프를 알린다(러너가 루프를 만들 때 부르고, 닫기 전에 None 으로 부른다)."""
self._loop = loop
Expand Down Expand Up @@ -164,10 +183,14 @@ async def call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is loop:
return await self._dispatch(name, arguments)
future = asyncio.run_coroutine_threadsafe(self._dispatch(name, arguments), loop)
return await asyncio.wrap_future(future)
self._activity(+1)
try:
if running is loop:
return await self._dispatch(name, arguments)
future = asyncio.run_coroutine_threadsafe(self._dispatch(name, arguments), loop)
return await asyncio.wrap_future(future)
finally:
self._activity(-1)


def to_mcp_result(result: Dict[str, Any]) -> Dict[str, Any]:
Expand Down
7 changes: 7 additions & 0 deletions src/xgen_agent_runtime/host/turn_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,13 @@ def run_dir_cleanup(_d: str = run_dir) -> None:
"codex",
kwargs,
)
if _tool_surface is not None and callable(
getattr(llm_client, "set_activity_probe", None)
):
# The CLI prints nothing while a bridged tool runs: its time limit is idle time, and the
# surface says when the tools were last busy. A working turn is no longer stopped at
# the limit; a silent one with nothing running still is (2026-10-24 review).
llm_client.set_activity_probe(_tool_surface.idle_seconds)

# ── 작은 워크스페이스 fast path (관리자/노드 opt-in) ──────────
# 전체 입력을 확실히 실을 수 있을 때만 탐색 왕복을 없앤다. 과제명·확장자·
Expand Down
41 changes: 34 additions & 7 deletions src/xgen_agent_runtime/llm_client/_cli_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
Any,
AsyncGenerator,
AsyncIterator,
Callable,
Iterable,
Mapping,
NamedTuple,
Expand Down Expand Up @@ -343,6 +344,13 @@ class CLIProcessRunner:
#: the pipe buffer, which drain immediately. This grace is generous
#: for that and short enough that a leaked FD costs seconds.
exit_drain_grace_s: float = 5.0
#: ``timeout_s`` is an **idle** limit for :meth:`stream`: the CLI is stopped after that long with no
#: output and no tool activity. A tool the CLI runs through a host bridge prints nothing while it
#: works, so the host says how long its tools have been idle (0 while one is running). Before
#: (2026-10-24) the limit counted from the start: a turn that was working was killed at the hour.
idle_probe: Optional[Callable[[], float]] = None
#: Optional hard ceiling for one stream, from its start (0 = none).
max_wall_s: float = 0.0

def __post_init__(self) -> None:
if not self.binary:
Expand Down Expand Up @@ -429,6 +437,8 @@ async def stream(
start_t=t0,
proc=proc,
drain_grace_s=self.exit_drain_grace_s,
idle_probe=self.idle_probe,
max_wall_s=self.max_wall_s,
):
yield line
rc = await self._reap(proc)
Expand Down Expand Up @@ -575,9 +585,15 @@ async def _aiter_lines(
start_t: float,
proc: Optional[asyncio.subprocess.Process] = None,
drain_grace_s: float = 5.0,
idle_probe: Optional[Callable[[], float]] = None,
max_wall_s: float = 0.0,
) -> AsyncIterator[bytes]:
"""Yield stdout lines until EOF — or until the child is gone and quiet.

``timeout_s`` is an idle limit: it is measured from the last line, and while ``idle_probe`` says a
tool is running or ran recently (its idle seconds are below the limit) the stream keeps waiting.
``max_wall_s`` (when set) is a hard ceiling from ``start_t``.

Once the child has exited, EOF is no longer guaranteed to arrive at
all: an inherited write end can keep the pipe open indefinitely. So
the read budget collapses to ``drain_grace_s`` from the moment the
Expand All @@ -589,12 +605,25 @@ async def _aiter_lines(
return
read_task: Optional[asyncio.Task[bytes]] = None
died_at: Optional[float] = None
last_line_t = start_t
try:
while True:
now = time.monotonic()
remaining = timeout_s - (now - start_t)
if max_wall_s > 0 and now - start_t >= max_wall_s:
raise CLITimeout(f"stream exceeded its {max_wall_s:.0f}s ceiling")
quiet = now - last_line_t
if idle_probe is not None and quiet >= timeout_s:
try:
quiet = min(quiet, float(idle_probe()))
except Exception: # noqa: BLE001 (a broken probe must not keep a dead CLI alive)
pass
remaining = timeout_s - quiet
if remaining <= 0:
raise CLITimeout(f"stream timeout after {timeout_s:.1f}s")
raise CLITimeout(
f"no output and no tool activity for {timeout_s:.1f}s"
if idle_probe is not None
else f"stream timeout: no output for {timeout_s:.1f}s"
)

# One read task, carried across iterations: a readline that
# loses the race must NOT be discarded, or the bytes it is
Expand Down Expand Up @@ -629,11 +658,8 @@ async def _aiter_lines(
)

if read_task not in done:
if died_at is not None or budget < remaining:
# Either draining after exit, or just a poll tick with
# budget left — go round again.
continue
raise CLITimeout(f"stream readline timeout after {timeout_s:.1f}s")
# Draining after exit, or a poll tick: go round again (the idle check is at the top).
continue

try:
line = read_task.result()
Expand All @@ -657,6 +683,7 @@ async def _aiter_lines(
read_task = None
if not line:
return
last_line_t = time.monotonic()
yield line
finally:
if read_task is not None and not read_task.done():
Expand Down
9 changes: 9 additions & 0 deletions src/xgen_agent_runtime/llm_client/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ def __init__(
self._disallow_tools = tuple(disallow_tools)
self._extra_args = tuple(extra_args)
self._timeout_s = timeout_s
#: How long the turn's tools have been idle (seconds, 0 while one runs): set by the host for a
#: turn whose tools run through a bridge. ``timeout_s`` is an idle limit (no output and no tool
#: activity), so a CLI that waits on a long tool is not stopped (:class:`CLIProcessRunner`).
self._activity_probe: Optional[Callable[[], float]] = None
self._extra_env: Dict[str, str] = dict(env_extras) if env_extras else {}
self._strict_wire = strict_wire
self._runner_factory = runner_factory
Expand Down Expand Up @@ -535,6 +539,10 @@ def _env_extras(self) -> Dict[str, str]:
def _truthy(value: Any) -> bool:
return str(value or "").strip().lower() in ("1", "true", "yes", "on")

def set_activity_probe(self, probe: Optional[Callable[[], float]]) -> None:
"""Tell the client how long the turn's tools have been idle (see ``_activity_probe``)."""
self._activity_probe = probe

def _make_runner(
self,
*,
Expand Down Expand Up @@ -564,6 +572,7 @@ def _make_runner(
return CLIProcessRunner(
binary=self._binary,
cwd=self._workspace_dir,
idle_probe=self._activity_probe,
env_extras=env_extras,
timeout_s=effective_timeout,
)
Expand Down
Loading
Loading