agentproc 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentproc-0.7.0 → agentproc-0.8.0}/PKG-INFO +1 -1
- {agentproc-0.7.0 → agentproc-0.8.0}/pyproject.toml +1 -1
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/__init__.py +58 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/runner.py +58 -33
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/PKG-INFO +1 -1
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/SOURCES.txt +1 -0
- agentproc-0.8.0/tests/test_hub_bridge_conformance.py +144 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_runner.py +25 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_scenarios.py +13 -3
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_sdk.py +5 -1
- {agentproc-0.7.0 → agentproc-0.8.0}/setup.cfg +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/cli.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/PERMISSIONS.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/stream_utils.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/stream_utils.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/permission_map.test.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_hook.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_map.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_map.test.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.sh +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/README.md +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/bridge.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/bridge.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/profile.yaml +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/parity.json +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.js +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/yaml.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/requires.txt +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_agentproc.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_bridges.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_conformance.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_diagnostics.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_hub.py +0 -0
- {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_yaml.py +0 -0
|
@@ -166,6 +166,64 @@ class AgentContext:
|
|
|
166
166
|
sys.stdout.flush()
|
|
167
167
|
return _NoopAwaitable()
|
|
168
168
|
|
|
169
|
+
def send_permission_request(self, req: Dict[str, Any]) -> None:
|
|
170
|
+
"""Send a tool-permission request to the bridge.
|
|
171
|
+
|
|
172
|
+
Only valid when ``ctx.permission`` is True (the profile set
|
|
173
|
+
``permission: true`` and the bridge enabled the channel). The bridge
|
|
174
|
+
surfaces the request to the user; the matching decision arrives on
|
|
175
|
+
stdin as a ``{"type":"permission_response",...}`` frame that
|
|
176
|
+
:meth:`read_permission_response` decodes.
|
|
177
|
+
|
|
178
|
+
``req`` MUST include ``request_id`` (unique within the turn),
|
|
179
|
+
``tool_name``, and ``input`` (object). ``description`` and
|
|
180
|
+
``tool_use_id`` are optional.
|
|
181
|
+
"""
|
|
182
|
+
if not self.permission:
|
|
183
|
+
raise RuntimeError(
|
|
184
|
+
"ctx.send_permission_request() requires profile.permission: "
|
|
185
|
+
"true — the bridge would otherwise not honor the request"
|
|
186
|
+
)
|
|
187
|
+
if not isinstance(req, dict):
|
|
188
|
+
raise TypeError("req must be a dict")
|
|
189
|
+
rid = req.get("request_id")
|
|
190
|
+
if not isinstance(rid, str) or not rid:
|
|
191
|
+
raise ValueError("req['request_id'] is required")
|
|
192
|
+
payload: Dict[str, Any] = {"type": "permission_request"}
|
|
193
|
+
for k in ("request_id", "tool_name", "input"):
|
|
194
|
+
payload[k] = req.get(k)
|
|
195
|
+
for k in ("description", "tool_use_id"):
|
|
196
|
+
if k in req:
|
|
197
|
+
payload[k] = req[k]
|
|
198
|
+
sys.stdout.write(
|
|
199
|
+
json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
200
|
+
)
|
|
201
|
+
sys.stdout.flush()
|
|
202
|
+
|
|
203
|
+
def read_permission_response(self) -> Optional[Dict[str, Any]]:
|
|
204
|
+
"""Read the next ``{"type":"permission_response",...}`` frame from stdin.
|
|
205
|
+
|
|
206
|
+
Blocks until a frame arrives (or EOF). Returns the parsed object, or
|
|
207
|
+
``None`` at EOF.
|
|
208
|
+
|
|
209
|
+
Only meaningful when ``ctx.permission`` is True — in the default
|
|
210
|
+
``permission: false`` mode the bridge closes stdin after the turn
|
|
211
|
+
line, so this returns ``None`` immediately.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
line = sys.stdin.readline()
|
|
215
|
+
except Exception:
|
|
216
|
+
return None
|
|
217
|
+
if not line:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
v = json.loads(line.rstrip("\r\n"))
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
return None
|
|
223
|
+
if isinstance(v, dict):
|
|
224
|
+
return v
|
|
225
|
+
return None
|
|
226
|
+
|
|
169
227
|
|
|
170
228
|
@dataclass
|
|
171
229
|
class AgentResult:
|
|
@@ -45,6 +45,7 @@ __all__ = [
|
|
|
45
45
|
"DEFAULT_TIMEOUT_SECS",
|
|
46
46
|
"DEFAULT_KILL_GRACE_SECS",
|
|
47
47
|
"DEFAULT_MAX_REPLY_CHARS",
|
|
48
|
+
"DEFAULT_TRUNCATION_SUFFIX",
|
|
48
49
|
"EXIT_SUCCESS",
|
|
49
50
|
"EXIT_ERROR",
|
|
50
51
|
"EXIT_TIMEOUT",
|
|
@@ -73,6 +74,7 @@ PROTOCOL_VERSION = "0.3"
|
|
|
73
74
|
DEFAULT_TIMEOUT_SECS = 1800
|
|
74
75
|
DEFAULT_KILL_GRACE_SECS = 5
|
|
75
76
|
DEFAULT_MAX_REPLY_CHARS = 8000
|
|
77
|
+
DEFAULT_TRUNCATION_SUFFIX = "\n\n…(truncated)"
|
|
76
78
|
|
|
77
79
|
EXIT_SUCCESS = 0
|
|
78
80
|
EXIT_ERROR = 1
|
|
@@ -207,6 +209,13 @@ def normalize_profile(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
207
209
|
int(src["max_reply_chars"]) if _is_int_like(src.get("max_reply_chars"))
|
|
208
210
|
else DEFAULT_MAX_REPLY_CHARS
|
|
209
211
|
),
|
|
212
|
+
# Spec profile YAML: optional truncation notice appended when the reply
|
|
213
|
+
# is capped. Defaults to "\n\n…(truncated)". An empty string disables
|
|
214
|
+
# the notice entirely (the cap still applies, just no visible marker).
|
|
215
|
+
"truncation_suffix": (
|
|
216
|
+
src["truncation_suffix"] if isinstance(src.get("truncation_suffix"), str)
|
|
217
|
+
else DEFAULT_TRUNCATION_SUFFIX
|
|
218
|
+
),
|
|
210
219
|
# Bridge-side hint: when False, the runner ignores {"type":"partial"}
|
|
211
220
|
# events and assembles the reply from {"type":"text"} events only.
|
|
212
221
|
"streaming": src.get("streaming", True) is not False,
|
|
@@ -545,6 +554,11 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
545
554
|
|
|
546
555
|
result = RunResult()
|
|
547
556
|
text_chunks: List[str] = []
|
|
557
|
+
# Spec 5.4: once an error event arrives, subsequent partial/text events
|
|
558
|
+
# MUST be discarded (they cannot contribute to a failed turn's reply).
|
|
559
|
+
# `session` is exempt — last-wins still applies so the id for the next
|
|
560
|
+
# turn can be captured even after an error.
|
|
561
|
+
error_seen = False
|
|
548
562
|
pending_permission_ids: set = set()
|
|
549
563
|
stdin_lock = threading.Lock()
|
|
550
564
|
stdin_closed = False
|
|
@@ -552,18 +566,13 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
552
566
|
_cumulative_partial_chars = 0
|
|
553
567
|
_partials_truncated = False
|
|
554
568
|
_max_chars = profile["max_reply_chars"]
|
|
555
|
-
_trunc_suffix = "
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
#
|
|
559
|
-
#
|
|
560
|
-
#
|
|
561
|
-
# errors (file/module not found) which appear in the first bytes, so a
|
|
562
|
-
# head cap preserves the high-value signal without unbounded growth.
|
|
563
|
-
# Beyond the cap the tail is dropped with a one-shot marker.
|
|
564
|
-
stderr_window: List[str] = []
|
|
569
|
+
_trunc_suffix = profile["truncation_suffix"]
|
|
570
|
+
# Bounded head capture (1 MB) used for post-mortem pattern diagnosis.
|
|
571
|
+
# The diagnostic patterns target interpreter-startup errors (file/module
|
|
572
|
+
# not found) which appear in the first bytes, so a head cap preserves
|
|
573
|
+
# the high-value signal without unbounded growth. Beyond the cap the
|
|
574
|
+
# tail is dropped with a one-shot marker.
|
|
565
575
|
stderr_full: List[str] = []
|
|
566
|
-
STDERR_CAP = 8192
|
|
567
576
|
STDERR_FULL_CAP = 1 << 20 # 1 MB
|
|
568
577
|
stderr_full_len = 0
|
|
569
578
|
stderr_full_truncated = False
|
|
@@ -579,10 +588,6 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
579
588
|
marker = "\n[agentproc runner] stderr capped at 1 MB; trailing output dropped\n"
|
|
580
589
|
stderr_full.append(marker)
|
|
581
590
|
stderr_full_truncated = True
|
|
582
|
-
stderr_window.append(text)
|
|
583
|
-
total = sum(len(s) for s in stderr_window)
|
|
584
|
-
while total > STDERR_CAP and len(stderr_window) > 1:
|
|
585
|
-
total -= len(stderr_window.pop(0))
|
|
586
591
|
|
|
587
592
|
try:
|
|
588
593
|
proc = subprocess.Popen(
|
|
@@ -668,7 +673,7 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
668
673
|
assert proc.stdout is not None
|
|
669
674
|
|
|
670
675
|
def _handle_line(raw_line: str) -> None:
|
|
671
|
-
nonlocal _cumulative_partial_chars, _partials_truncated
|
|
676
|
+
nonlocal _cumulative_partial_chars, _partials_truncated, error_seen
|
|
672
677
|
line = raw_line.rstrip("\r")
|
|
673
678
|
c = classify_line(line)
|
|
674
679
|
kind = c["kind"]
|
|
@@ -689,7 +694,15 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
689
694
|
if options.on_protocol_line:
|
|
690
695
|
options.on_protocol_line(line)
|
|
691
696
|
elif kind == "partial":
|
|
692
|
-
|
|
697
|
+
# Spec 5.4: post-error partials are discarded (not forwarded, not
|
|
698
|
+
# appended). on_protocol_line still fires so debug traces stay
|
|
699
|
+
# complete.
|
|
700
|
+
if (
|
|
701
|
+
not error_seen
|
|
702
|
+
and streaming
|
|
703
|
+
and options.on_partial
|
|
704
|
+
and not _partials_truncated
|
|
705
|
+
):
|
|
693
706
|
remaining = _max_chars - _cumulative_partial_chars
|
|
694
707
|
if len(c["value"]) >= remaining:
|
|
695
708
|
if remaining > 0:
|
|
@@ -703,11 +716,14 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
703
716
|
if options.on_protocol_line:
|
|
704
717
|
options.on_protocol_line(line)
|
|
705
718
|
elif kind == "text":
|
|
706
|
-
|
|
719
|
+
# Spec 5.4: post-error text is discarded.
|
|
720
|
+
if not error_seen:
|
|
721
|
+
text_chunks.append(c["value"])
|
|
707
722
|
if options.on_protocol_line:
|
|
708
723
|
options.on_protocol_line(line)
|
|
709
724
|
elif kind == "error":
|
|
710
725
|
result.error = c["value"]
|
|
726
|
+
error_seen = True
|
|
711
727
|
if options.on_error:
|
|
712
728
|
options.on_error(c["value"])
|
|
713
729
|
if options.on_protocol_line:
|
|
@@ -750,20 +766,28 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
750
766
|
try:
|
|
751
767
|
decision = options.on_permission(req)
|
|
752
768
|
if isinstance(decision, dict):
|
|
769
|
+
# Spec: when the bridge omits updated_input, the
|
|
770
|
+
# response MUST omit it too — the agent (or wrapped
|
|
771
|
+
# CLI) is responsible for falling back to the
|
|
772
|
+
# request's original input. Don't auto-fill
|
|
773
|
+
# req["input"] here: that would erase the
|
|
774
|
+
# distinction between "user accepted unchanged"
|
|
775
|
+
# and "user never touched it" for downstream CLIs
|
|
776
|
+
# (e.g. Claude Code's updatedInput semantics).
|
|
753
777
|
updated = decision.get("updated_input")
|
|
754
778
|
if updated is None and "updatedInput" in decision:
|
|
755
779
|
updated = decision.get("updatedInput")
|
|
756
|
-
|
|
757
|
-
updated = req.get("input")
|
|
758
|
-
_write_permission_response({
|
|
780
|
+
response_decision: Dict[str, Any] = {
|
|
759
781
|
"request_id": req["request_id"],
|
|
760
782
|
"behavior": (
|
|
761
783
|
"allow" if decision.get("behavior") == "allow"
|
|
762
784
|
else "deny"
|
|
763
785
|
),
|
|
764
|
-
"updated_input": updated if isinstance(updated, dict) else req.get("input"),
|
|
765
786
|
"message": decision.get("message"),
|
|
766
|
-
}
|
|
787
|
+
}
|
|
788
|
+
if isinstance(updated, dict):
|
|
789
|
+
response_decision["updated_input"] = updated
|
|
790
|
+
_write_permission_response(response_decision)
|
|
767
791
|
except Exception as exc: # noqa: BLE001 — surface to agent as deny
|
|
768
792
|
if options.on_stderr:
|
|
769
793
|
options.on_stderr(
|
|
@@ -849,23 +873,24 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
849
873
|
exit_code = proc.wait()
|
|
850
874
|
|
|
851
875
|
_close_stdin()
|
|
852
|
-
|
|
853
|
-
#
|
|
854
|
-
#
|
|
855
|
-
#
|
|
856
|
-
#
|
|
857
|
-
|
|
876
|
+
# Process has exited. Drain threads should hit EOF on their pipes within
|
|
877
|
+
# milliseconds and finish. The hard timeout here is a backstop for the
|
|
878
|
+
# rare case a grandchild inherited the stderr fd and is still alive — it
|
|
879
|
+
# keeps the runner from hanging indefinitely without letting drain latency
|
|
880
|
+
# balloon past the kill_grace_secs budget. 1s each = at most ~2s of extra
|
|
881
|
+
# latency; diagnosis may be incomplete if it fires, but the post-mortem
|
|
882
|
+
# stderr patterns target interpreter-startup errors that land in the
|
|
883
|
+
# first bytes of stderr anyway, well within the 1MB head capture.
|
|
884
|
+
stdout_thread.join(timeout=1)
|
|
885
|
+
stderr_thread.join(timeout=1)
|
|
858
886
|
if stderr_thread.is_alive():
|
|
859
|
-
# Very rare; indicates a pathological agent that keeps stderr open.
|
|
860
|
-
# The diagnosis may be incomplete, but we don't want to hang forever.
|
|
861
887
|
if options.on_stderr:
|
|
862
888
|
options.on_stderr("[agentproc runner] warning: stderr drain timed out; diagnosis may be incomplete")
|
|
863
889
|
|
|
864
890
|
# Reply body = concatenation of {"type":"text"} events (direct, no separator).
|
|
865
891
|
result.reply = "".join(text_chunks)
|
|
866
892
|
if len(result.reply) > profile["max_reply_chars"]:
|
|
867
|
-
|
|
868
|
-
result.reply = result.reply[: profile["max_reply_chars"]] + suffix
|
|
893
|
+
result.reply = result.reply[: profile["max_reply_chars"]] + profile["truncation_suffix"]
|
|
869
894
|
|
|
870
895
|
# If the agent exited non-zero with no error event, peek at its stderr for
|
|
871
896
|
# common "command/file not found" patterns and surface a friendly hint.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Hub bridge engine conformance (wire 0.3).
|
|
2
|
+
|
|
3
|
+
Drives the shared ``hub/_shared/stream_utils.run_bridge`` with a minimal
|
|
4
|
+
identity ``parse_event`` against the shared
|
|
5
|
+
``spec/conformance/hub_bridge.json`` fixture. The Node SDK runs the same
|
|
6
|
+
fixture in ``sdk/node/src/hub_bridge_conformance.test.js``.
|
|
7
|
+
|
|
8
|
+
Together they guarantee the Python and Node hub bridge engines agree on
|
|
9
|
+
multi-line interaction semantics that ``run_bridge`` owns: error-mid-stream
|
|
10
|
+
preserving session, session-discovered-at-end ordering, empty-message +
|
|
11
|
+
attachment accepted, exit-code mapping, partial-as-reply fallback.
|
|
12
|
+
|
|
13
|
+
Per-CLI ``parse_event`` variations are NOT covered here — each hub profile
|
|
14
|
+
has its own ``test_bridges.py`` cases for that. This file pins the engine.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
HUB_ROOT = Path(__file__).resolve().parents[3] / "hub"
|
|
28
|
+
SDK_PYTHON_ROOT = Path(__file__).resolve().parents[1]
|
|
29
|
+
if str(HUB_ROOT) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(HUB_ROOT))
|
|
31
|
+
|
|
32
|
+
from _shared.stream_utils import EventResult, run_bridge # noqa: E402
|
|
33
|
+
|
|
34
|
+
FIXTURE_PATH = Path(__file__).resolve().parents[3] / "spec" / "conformance" / "hub_bridge.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_scenarios():
|
|
38
|
+
data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
|
|
39
|
+
return [pytest.param(s, id=s["name"]) for s in data["scenarios"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _identity_parse_event(event: dict) -> "EventResult | None":
|
|
43
|
+
t = event.get("type")
|
|
44
|
+
if t == "partial":
|
|
45
|
+
return EventResult(partial_text=event.get("text", ""))
|
|
46
|
+
if t == "text":
|
|
47
|
+
return EventResult(final_text=event.get("text", ""))
|
|
48
|
+
if t == "session":
|
|
49
|
+
sid = event.get("id")
|
|
50
|
+
return EventResult(session_id=sid) if isinstance(sid, str) else None
|
|
51
|
+
if t == "error":
|
|
52
|
+
return EventResult(error=event.get("message", ""))
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _identity_build_args(_message: str, _session_id: str, _env) -> list[str]:
|
|
57
|
+
# Returned argv is never actually executed — the test patches
|
|
58
|
+
# subprocess.Popen with a fake proc. The list just needs a recognizable
|
|
59
|
+
# argv[0] for error messages.
|
|
60
|
+
return ["fake-cli"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Fake subprocess for run_bridge
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _FakePipe:
|
|
69
|
+
def __init__(self, lines: List[str]):
|
|
70
|
+
self._lines = list(lines)
|
|
71
|
+
|
|
72
|
+
def __iter__(self):
|
|
73
|
+
for line in self._lines:
|
|
74
|
+
yield line + "\n"
|
|
75
|
+
|
|
76
|
+
def read(self):
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _StderrReader:
|
|
81
|
+
def __init__(self, text: str):
|
|
82
|
+
self._text = text
|
|
83
|
+
|
|
84
|
+
def read(self):
|
|
85
|
+
return self._text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _FakeProc:
|
|
89
|
+
def __init__(self, ndjson_lines: List[str], returncode: int, stderr: str):
|
|
90
|
+
self.stdout = _FakePipe(ndjson_lines)
|
|
91
|
+
self.stderr = _StderrReader(stderr)
|
|
92
|
+
self.returncode = returncode
|
|
93
|
+
|
|
94
|
+
def wait(self):
|
|
95
|
+
return self.returncode
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize("scenario", _load_scenarios())
|
|
99
|
+
def test_hub_bridge_conformance(scenario: dict, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
|
100
|
+
turn = scenario["turn"]
|
|
101
|
+
cli_stdout = scenario.get("cli_stdout", [])
|
|
102
|
+
cli_exit = scenario.get("cli_exit", 0)
|
|
103
|
+
cli_stderr = scenario.get("cli_stderr", "")
|
|
104
|
+
|
|
105
|
+
def _fake_popen(args, *a, **kw): # noqa: ANN001 — signature mirrors subprocess.Popen
|
|
106
|
+
return _FakeProc(cli_stdout, cli_exit, cli_stderr)
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr("subprocess.Popen", _fake_popen)
|
|
109
|
+
|
|
110
|
+
exit_code = run_bridge(
|
|
111
|
+
cli_name="fake-cli",
|
|
112
|
+
cli_install_hint="install hint",
|
|
113
|
+
build_args=_identity_build_args,
|
|
114
|
+
parse_event=_identity_parse_event,
|
|
115
|
+
turn=turn,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
captured = capsys.readouterr()
|
|
119
|
+
actual_lines = [line for line in captured.out.split("\n") if line]
|
|
120
|
+
|
|
121
|
+
expect = scenario["expect"]
|
|
122
|
+
assert exit_code == expect["exit_code"], (
|
|
123
|
+
f"{scenario['name']}: exit got {exit_code}, expected {expect['exit_code']}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if "stdout_lines" in expect:
|
|
127
|
+
assert actual_lines == expect["stdout_lines"], (
|
|
128
|
+
f"{scenario['name']}: stdout got {actual_lines!r}, expected {expect['stdout_lines']!r}"
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if "stdout_lines_any_of" in expect:
|
|
133
|
+
candidates = expect["stdout_lines_any_of"]
|
|
134
|
+
assert any(actual_lines == cand for cand in candidates), (
|
|
135
|
+
f"{scenario['name']}: stdout got {actual_lines!r}, expected any of {candidates!r}"
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if "stdout_lines_contains" in expect:
|
|
140
|
+
joined = "\n".join(actual_lines)
|
|
141
|
+
for needle in expect["stdout_lines_contains"]:
|
|
142
|
+
assert needle in joined, (
|
|
143
|
+
f"{scenario['name']}: expected substring {needle!r} in stdout, got {joined!r}"
|
|
144
|
+
)
|
|
@@ -97,6 +97,15 @@ class TestPermissionHelpers:
|
|
|
97
97
|
{"request_id": "2", "behavior": "deny", "message": "nope"}
|
|
98
98
|
) == '{"type":"permission_response","request_id":"2","behavior":"deny","message":"nope"}'
|
|
99
99
|
|
|
100
|
+
def test_format_allow_without_updated_input_omits_field(self):
|
|
101
|
+
# allow without updated_input MUST omit the field — the agent/CLI is
|
|
102
|
+
# responsible for falling back to the request's original input. The
|
|
103
|
+
# runner must not pre-fill it (would erase the "user accepted
|
|
104
|
+
# unchanged" vs "user never touched it" distinction downstream).
|
|
105
|
+
assert format_permission_response(
|
|
106
|
+
{"request_id": "3", "behavior": "allow"}
|
|
107
|
+
) == '{"type":"permission_response","request_id":"3","behavior":"allow"}'
|
|
108
|
+
|
|
100
109
|
def test_is_valid(self):
|
|
101
110
|
assert is_valid_permission_request({"request_id": "1", "tool_name": "Bash", "input": {}})
|
|
102
111
|
assert not is_valid_permission_request({"request_id": "1", "tool_name": "Bash"})
|
|
@@ -265,6 +274,22 @@ class TestNormalizeProfile:
|
|
|
265
274
|
p2 = normalize_profile({"command": "x", "env_inherit": "all"})
|
|
266
275
|
assert "env_inherit" not in p2
|
|
267
276
|
|
|
277
|
+
def test_truncation_suffix_defaults_to_ellipsis(self):
|
|
278
|
+
p = normalize_profile({"command": "x"})
|
|
279
|
+
assert p["truncation_suffix"] == "\n\n…(truncated)"
|
|
280
|
+
|
|
281
|
+
def test_truncation_suffix_custom_value_is_honoured(self):
|
|
282
|
+
# A custom cap no longer silently strips the truncation notice —
|
|
283
|
+
# users get the default suffix unless they explicitly override.
|
|
284
|
+
p = normalize_profile({"command": "x", "max_reply_chars": 100})
|
|
285
|
+
assert p["truncation_suffix"] == "\n\n…(truncated)"
|
|
286
|
+
p2 = normalize_profile({"command": "x", "truncation_suffix": " [more]"})
|
|
287
|
+
assert p2["truncation_suffix"] == " [more]"
|
|
288
|
+
|
|
289
|
+
def test_truncation_suffix_empty_string_disables_notice(self):
|
|
290
|
+
p = normalize_profile({"command": "x", "truncation_suffix": ""})
|
|
291
|
+
assert p["truncation_suffix"] == ""
|
|
292
|
+
|
|
268
293
|
def test_permission_defaults_false(self):
|
|
269
294
|
assert normalize_profile({"command": "x"})["permission"] is False
|
|
270
295
|
assert normalize_profile({"command": "x", "permission": True})["permission"] is True
|
|
@@ -75,6 +75,16 @@ def test_scenario_conformance(scenario: dict, tmp_path: Path) -> None:
|
|
|
75
75
|
assert r.exit_code == expect["exit_code"], (
|
|
76
76
|
f"{scenario['name']}: exit_code got {r.exit_code!r}, expected {expect['exit_code']!r}"
|
|
77
77
|
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
78
|
+
# partials comparison: when `partials_any_of` is present, the scenario
|
|
79
|
+
# accepts any of the listed candidate sequences (used for spec-loose
|
|
80
|
+
# semantics like whether to emit a tail-truncated chunk vs nothing).
|
|
81
|
+
# Otherwise the strict `partials` equality applies.
|
|
82
|
+
if "partials_any_of" in expect:
|
|
83
|
+
candidates = expect["partials_any_of"]
|
|
84
|
+
assert any(partials == cand for cand in candidates), (
|
|
85
|
+
f"{scenario['name']}: partials got {partials!r}, expected any of {candidates!r}"
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
assert partials == expect["partials"], (
|
|
89
|
+
f"{scenario['name']}: partials got {partials!r}, expected {expect['partials']!r}"
|
|
90
|
+
)
|
|
@@ -42,10 +42,14 @@ def _run_scenario(scenario: dict) -> subprocess.CompletedProcess:
|
|
|
42
42
|
"PYTHONPATH": str(SRC_DIR),
|
|
43
43
|
}
|
|
44
44
|
turn_line = json.dumps(scenario["turn"]) + "\n"
|
|
45
|
+
# Scenarios that exercise the optional permission channel follow the turn
|
|
46
|
+
# with one or more {"type":"permission_response",...} frames the harness
|
|
47
|
+
# reads via ctx.read_permission_response().
|
|
48
|
+
extra = "".join(line + "\n" for line in scenario.get("stdin_after_turn", []))
|
|
45
49
|
return subprocess.run(
|
|
46
50
|
[sys.executable, str(HARNESS), scenario["handler"]],
|
|
47
51
|
env=env,
|
|
48
|
-
input=turn_line,
|
|
52
|
+
input=turn_line + extra,
|
|
49
53
|
capture_output=True,
|
|
50
54
|
text=True,
|
|
51
55
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/permission_map.test.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.js
RENAMED
|
File without changes
|
{agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|