Files
multi-agent-mux/.agents/skills/lib_py/agents/input_region.py
T
GodopuandClaude Opus 5.5 622c1f8a9d feat(issue-30): a stall watcher that only notifies
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00

68 lines
2.8 KiB
Python

# input_region.py — Rule-based input region extraction and normalization (T2 & T3)
import re
from lib_py.agents.registry import get_adapter
def extract_input_region(pane_text: str, agent_name: str) -> str:
"""
Extracts the input area text of a pane, using only the agent adapter's facts
(issue #30, measured on herdr 0.9.1 captures in tests/fixtures/panes):
1. an `input_block_prefix` -> the last contiguous block of lines starting
with it, prefix removed, cut at the first `input_stop_pattern` line;
2. two or more `input_rule_pattern` rules -> the lines between the last two;
3. an `input_prompt` -> the last line starting with the prompt.
Then `input_frame_chars`, the prompt and the placeholder are stripped. There
is no whole-pane fallback: a pane with none of these shapes has no input
region and yields a non-empty "unknown" result rather than a guess.
Raises ValueError for agents without input area facts (like hermes' peers).
"""
adapter = get_adapter(agent_name)
if not adapter or not adapter.input_prompt:
raise ValueError(f"Agent {agent_name!r} has no input area facts")
lines = pane_text.splitlines()
prompt = adapter.input_prompt
region_lines = None
if adapter.input_block_prefix:
prefix = adapter.input_block_prefix
block = []
for line in reversed(lines):
if line.lstrip().startswith(prefix):
block.insert(0, line.lstrip()[len(prefix):])
elif block:
break
stop_re = re.compile(adapter.input_stop_pattern) if adapter.input_stop_pattern else None
for i, line in enumerate(block):
if stop_re and stop_re.search(line):
block = block[:i]
break
# No block on screen is not an empty box: fall through to the other shapes.
region_lines = block or None
if region_lines is None and adapter.input_rule_pattern:
rule_re = re.compile(adapter.input_rule_pattern)
rule_indices = [i for i, line in enumerate(lines) if rule_re.search(line)]
if len(rule_indices) >= 2:
region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]]
if region_lines is None:
starts = [line for line in lines if line.lstrip().startswith(prompt)]
# No input line at all: report the whole tail as "not empty" (unknown).
region_lines = starts[-1:] if starts else [pane_text or '?']
frame = adapter.input_frame_chars
if frame:
region_lines = [line.strip().strip(frame) for line in region_lines]
region_text = "\n".join(region_lines)
region_text = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_text, flags=re.MULTILINE)
placeholder = adapter.input_placeholder
if placeholder:
region_text = region_text.replace(placeholder, '')
return region_text.strip()