Skip to content

Commit c9a5fc7

Browse files
authored
Merge branch 'main' into fix/611-linear-setup-webhook-secret
2 parents e26085d + 0132d6d commit c9a5fc7

13 files changed

Lines changed: 1579 additions & 18 deletions

agent/src/hooks.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from policy import APPROVAL_RATE_LIMIT, FLOOR_TIMEOUT_S, Outcome
3535
from progress_writer import _generate_ulid
3636
from shell import log, log_error_cw
37+
from stuck_guard import StuckGuard
3738

3839
if TYPE_CHECKING:
3940
from policy import PolicyEngine
@@ -172,6 +173,27 @@ def reset_blocker_reason() -> None:
172173
_reset_blocker_reason_for_tests = reset_blocker_reason
173174

174175

176+
# ABCA-662: latch of the stuck-guard's "why it's spinning" summary, refreshed by
177+
# the between-turns hook. Read by the pipeline's terminal path so a max_turns
178+
# failure can say WHY it capped ("Exceeded max turns — spinning on failing tool
179+
# calls: git push → invalid credentials") vs. a task that genuinely used its
180+
# turns. Process-lifetime (one task), last-writer-wins (the most recent window
181+
# is the most relevant to the cap). Distinct from the blocker latch: this is a
182+
# SOFT diagnostic (advisory), not a canonical BLOCKED[…] terminal reason.
183+
_LAST_STUCK_SUMMARY: str | None = None
184+
185+
186+
def last_stuck_summary() -> str | None:
187+
"""Return the latched stuck-guard summary for the max_turns terminal reason."""
188+
return _LAST_STUCK_SUMMARY
189+
190+
191+
def reset_stuck_summary() -> None:
192+
"""Clear the stuck-summary latch (per-task, alongside the blocker latch)."""
193+
global _LAST_STUCK_SUMMARY
194+
_LAST_STUCK_SUMMARY = None
195+
196+
175197
def detect_egress_denial(text: str) -> tuple[bool, str | None]:
176198
"""Scan tool output for an egress-denial signature (#251).
177199
@@ -1039,6 +1061,7 @@ async def post_tool_use_hook(
10391061
*,
10401062
trajectory: _TrajectoryWriter | None = None,
10411063
progress: Any = None,
1064+
stuck_guard: StuckGuard | None = None,
10421065
) -> dict:
10431066
"""PostToolUse hook: screen tool output for secrets/PII.
10441067
@@ -1051,6 +1074,11 @@ async def post_tool_use_hook(
10511074
present, an egress-denial signature in the tool output emits an
10521075
``egress_denied`` blocker event (#251) — best-effort observability,
10531076
never alters the screening decision.
1077+
1078+
K7: when a ``stuck_guard`` is supplied, every tool result is recorded so a
1079+
between-turns hook can detect a repeating failing command (the ABCA-483
1080+
spin loop) and steer / bail. Recording is best-effort and never alters the
1081+
screening outcome.
10541082
"""
10551083
_PASS_THROUGH: dict = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}}
10561084
_FAIL_CLOSED: dict = {
@@ -1113,6 +1141,14 @@ async def post_tool_use_hook(
11131141
resource=host,
11141142
)
11151143

1144+
# K7: feed the stuck-guard (best-effort — a tracking error must never block
1145+
# the screening path that follows).
1146+
if stuck_guard is not None:
1147+
try:
1148+
stuck_guard.record_tool_result(tool_name, hook_input.get("tool_input"), tool_response)
1149+
except Exception as exc:
1150+
log("WARN", f"stuck-guard record raised (ignored): {type(exc).__name__}: {exc}")
1151+
11161152
try:
11171153
result = scan_tool_output(tool_response)
11181154
except Exception as exc:
@@ -1395,6 +1431,49 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]:
13951431
return []
13961432

13971433

1434+
def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]:
1435+
"""K7: nudge the agent when it repeats the SAME failing command (ABCA-483).
1436+
1437+
Reads the per-task :class:`StuckGuard` stamped on ``ctx`` (by
1438+
:func:`stop_hook`). When the same command has failed with identical output
1439+
enough times in a row, the guard returns a ``steer`` action — a ONE-TIME
1440+
advisory message telling the agent to stop retrying and either work around
1441+
the failure or finish with what it has. Returned as injected text, so the
1442+
SDK continues the turn with the steer as the next user message.
1443+
1444+
ADVISORY ONLY: the guard never kills the task (the bail path was removed —
1445+
distinguishing a true spin from a legitimately-iterating agent is too
1446+
fragile to justify an auto-kill; the ``max_turns`` cap is the real
1447+
backstop). A false positive here costs exactly one extra advisory comment.
1448+
1449+
Runs AFTER cancel (cancel wins — never steer a dying agent) but its own
1450+
no-op-when-cancelled guard makes ordering robust. Fail-open: any error is
1451+
swallowed so a guard bug can never wedge a healthy agent.
1452+
"""
1453+
if ctx.get("_cancel_requested"):
1454+
return []
1455+
guard = ctx.get("stuck_guard")
1456+
if guard is None:
1457+
return []
1458+
try:
1459+
action = guard.evaluate()
1460+
# ABCA-662: refresh the "why it's spinning" latch every turn. When the
1461+
# trailing window is failure-dominated this returns a one-liner; otherwise
1462+
# None (which clears the latch — a task that recovered isn't "stuck"). Read
1463+
# by the terminal path so a later max_turns cap explains itself.
1464+
global _LAST_STUCK_SUMMARY
1465+
_LAST_STUCK_SUMMARY = guard.recent_failure_summary()
1466+
except Exception as exc:
1467+
log("WARN", f"stuck-guard evaluate raised (ignored): {type(exc).__name__}: {exc}")
1468+
return []
1469+
1470+
if action.kind == "steer":
1471+
_emit_nudge_milestone(ctx, "stuck_steer", action.message[:_NUDGE_PREVIEW_LEN])
1472+
log("NUDGE", f"stuck-guard STEER injected: {action.signature}")
1473+
return [action.message]
1474+
return []
1475+
1476+
13981477
# Global list of between-turns hooks. Cancel MUST run first so it can
13991478
# short-circuit nudges on cancelled tasks (no point injecting nudges into a
14001479
# dying agent — worse, the nudge reader mutates DDB state that the agent will
@@ -1406,6 +1485,10 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]:
14061485
# nudge reader to preserve cancel-wins semantics.
14071486
between_turns_hooks: list[BetweenTurnsHook] = [
14081487
_cancel_between_turns_hook,
1488+
# K7 stuck-guard (advisory): injects a one-time "stop retrying X" nudge when
1489+
# the same command keeps failing identically. Runs after cancel (never steer
1490+
# a dying agent); order vs nudge/denial is cosmetic since it never bails.
1491+
_stuck_guard_between_turns_hook,
14091492
_nudge_between_turns_hook,
14101493
# Chunk 3 (finding #2): denial injection runs LAST so both cancel and
14111494
# nudge short-circuits pre-empt it. The hook explicitly re-checks
@@ -1423,6 +1506,7 @@ async def stop_hook(
14231506
task_id: str,
14241507
progress: Any = None,
14251508
engine: Any = None,
1509+
stuck_guard: Any = None,
14261510
) -> dict:
14271511
"""Stop hook: run registered between-turns hooks; block if they produce text.
14281512
@@ -1445,6 +1529,7 @@ async def stop_hook(
14451529
"task_id": task_id,
14461530
"progress": progress,
14471531
"engine": engine,
1532+
"stuck_guard": stuck_guard,
14481533
}
14491534

14501535
# Cancel-before-nudge short-circuit.
@@ -1526,6 +1611,11 @@ def build_hook_matchers(
15261611
SyncHookJSONOutput,
15271612
)
15281613

1614+
# K7: one stuck-guard per task (== per build_hook_matchers call). The
1615+
# PostToolUse closure feeds it every tool result; the Stop closure reads it
1616+
# between turns to steer / bail on a repeating failing command.
1617+
_stuck_guard = StuckGuard()
1618+
15291619
# Closure-based wrapper matches the HookCallback signature exactly:
15301620
# (HookInput, str | None, HookContext) -> Awaitable[HookJSONOutput]
15311621
async def _pre(
@@ -1568,7 +1658,12 @@ async def _post(
15681658
) -> HookJSONOutput:
15691659
try:
15701660
result = await post_tool_use_hook(
1571-
hook_input, tool_use_id, ctx, trajectory=trajectory, progress=progress
1661+
hook_input,
1662+
tool_use_id,
1663+
ctx,
1664+
trajectory=trajectory,
1665+
progress=progress,
1666+
stuck_guard=_stuck_guard,
15721667
)
15731668
return SyncHookJSONOutput(**result)
15741669
except Exception as exc:
@@ -1593,6 +1688,7 @@ async def _stop(
15931688
task_id=stop_task_id,
15941689
progress=progress,
15951690
engine=engine,
1691+
stuck_guard=_stuck_guard,
15961692
)
15971693
except Exception as exc:
15981694
log(

agent/src/pipeline.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,21 @@ def _resolve_overall_task_status(
470470
agent_status = agent_result.status
471471
err = agent_result.error
472472

473+
# ABCA-662: a max_turns cap is a CORRECT classification, but on its own it
474+
# doesn't say WHETHER the task genuinely needed the turns or SPUN on a failing
475+
# operation until it ran out (662 thrashed on a failing `git push` → invalid
476+
# credentials, retried every which way, and capped). When the stuck-guard's
477+
# trailing window was failure-dominated, append its one-line summary so the
478+
# reason distinguishes "ran long" from "looped on an error" — the classifier
479+
# still buckets it as max_turns, but a human sees the real cause. Only enriches
480+
# the max_turns reason; a task that used its turns productively adds nothing.
481+
if err and "error_max_turns" in err:
482+
from hooks import last_stuck_summary
483+
484+
stuck = last_stuck_summary()
485+
if stuck and stuck not in err:
486+
err = f"{err}{stuck}"
487+
473488
if agent_status in ("success", "end_turn") and build_ok:
474489
return "success", err
475490

@@ -700,9 +715,12 @@ def run_task(
700715
# in principle dispatch a second run_task in the same process — reset
701716
# here so a stale BLOCKED[...] reason can never leak into this task's
702717
# terminal error_message (the latch is a scalar, not task_id-keyed).
703-
from hooks import reset_blocker_reason
718+
from hooks import reset_blocker_reason, reset_stuck_summary
704719

705720
reset_blocker_reason()
721+
# ABCA-662: same per-task reset for the stuck-guard recent-failure latch,
722+
# so a prior task's observation can't leak into this task's max_turns copy.
723+
reset_stuck_summary()
706724
# --trace accumulator (design §10.1): when the task opted into
707725
# trace, ``_TrajectoryWriter`` keeps an in-memory copy of each
708726
# event so the pipeline can gzip+upload the full trajectory to

0 commit comments

Comments
 (0)