agentproc 0.3.0__tar.gz → 0.4.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.3.0 → agentproc-0.4.0}/PKG-INFO +1 -1
- {agentproc-0.3.0 → agentproc-0.4.0}/pyproject.toml +1 -1
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc/cli.py +129 -29
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc/hub.py +223 -12
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc/runner.py +252 -23
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc.egg-info/PKG-INFO +1 -1
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc.egg-info/SOURCES.txt +1 -0
- agentproc-0.4.0/tests/test_conformance.py +33 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/tests/test_hub.py +13 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/setup.cfg +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc/__init__.py +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/tests/test_agentproc.py +0 -0
- {agentproc-0.3.0 → agentproc-0.4.0}/tests/test_runner.py +0 -0
|
@@ -227,6 +227,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
227
227
|
def show_help() -> None:
|
|
228
228
|
sys.stdout.write(f"""agentproc v{PKG_VERSION} (protocol {PROTOCOL_VERSION})
|
|
229
229
|
|
|
230
|
+
The fastest way in:
|
|
231
|
+
agentproc hub list # see what's available
|
|
232
|
+
agentproc hub run echo-agent -p "hello" # smoke test (no API key)
|
|
233
|
+
cd ~/projects/my-app && agentproc hub run claude-code -p "explain this"
|
|
234
|
+
|
|
235
|
+
The CLI fetches the profile from the GitHub hub on first use, caches it at
|
|
236
|
+
~/.agentproc/cache/hub/<name>/ (24h TTL), and uses your current directory as
|
|
237
|
+
the agent's cwd. Set GITHUB_TOKEN to raise the rate limit (see `agentproc hub --help`).
|
|
238
|
+
|
|
239
|
+
Hub subcommands:
|
|
240
|
+
hub list List all profiles in the hub
|
|
241
|
+
hub show <name> Show a profile's README
|
|
242
|
+
hub run <name> [run-options] Fetch (if needed) and run a profile
|
|
243
|
+
hub install <name> Copy a profile to the current directory
|
|
244
|
+
|
|
245
|
+
Run `agentproc hub --help` for the full hub reference.
|
|
246
|
+
|
|
247
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
Advanced: run a local profile YAML directly (no hub fetch)
|
|
250
|
+
|
|
230
251
|
Usage:
|
|
231
252
|
agentproc --profile <path.yaml> --prompt "hello" [options]
|
|
232
253
|
|
|
@@ -240,7 +261,8 @@ Session:
|
|
|
240
261
|
--from <user> Sender identifier
|
|
241
262
|
|
|
242
263
|
Execution:
|
|
243
|
-
--cwd <path> Override profile.cwd
|
|
264
|
+
--cwd <path> Override profile.cwd (relative paths resolve
|
|
265
|
+
against the profile.yaml's directory)
|
|
244
266
|
--env KEY=VALUE Extra env var (repeatable)
|
|
245
267
|
--timeout <secs> Override profile.timeout_secs
|
|
246
268
|
--no-stream Set AGENT_STREAMING=0
|
|
@@ -263,9 +285,16 @@ Output semantics:
|
|
|
263
285
|
The final session id is printed on stderr as: agentproc:session:<id>
|
|
264
286
|
|
|
265
287
|
Examples:
|
|
266
|
-
|
|
267
|
-
agentproc
|
|
268
|
-
|
|
288
|
+
# Local profile (relative cwd resolves next to profile.yaml):
|
|
289
|
+
agentproc --profile ./hub/echo-agent/profile.yaml --prompt "hi"
|
|
290
|
+
|
|
291
|
+
# Local claude-code profile, claude runs against your project:
|
|
292
|
+
agentproc --profile ./hub/claude-code/profile.yaml \\
|
|
293
|
+
--prompt "explain this codebase" \\
|
|
294
|
+
--cwd /path/to/your/project
|
|
295
|
+
|
|
296
|
+
# Prompt from stdin:
|
|
297
|
+
cat prompt.txt | agentproc --profile prof.yaml --stdin
|
|
269
298
|
""")
|
|
270
299
|
|
|
271
300
|
|
|
@@ -288,9 +317,46 @@ def _run_hub_subcommand(args: List[str]) -> int:
|
|
|
288
317
|
sub = args[0]
|
|
289
318
|
rest = args[1:]
|
|
290
319
|
|
|
320
|
+
# Any subcommand with --help/-h shows the hub help uniformly.
|
|
321
|
+
if "--help" in rest or "-h" in rest:
|
|
322
|
+
_show_hub_help()
|
|
323
|
+
return 0
|
|
324
|
+
|
|
291
325
|
# Common flag
|
|
292
326
|
refresh = "--refresh" in rest
|
|
293
|
-
|
|
327
|
+
|
|
328
|
+
# Separate hub-level flags from runner-level flags and positional args.
|
|
329
|
+
# In the `hub run` context, `-p` means `--prompt` (the profile name is
|
|
330
|
+
# positional, not a path), so normalize it before handing off to argparse.
|
|
331
|
+
positional: List[str] = []
|
|
332
|
+
runner_args: List[str] = []
|
|
333
|
+
takes_value = {"--prompt", "-p", "--session", "--session-name", "--from",
|
|
334
|
+
"--cwd", "--env", "--timeout"}
|
|
335
|
+
boolean_flags = {"--no-stream", "--verbose", "--quiet", "--raw", "--stdin"}
|
|
336
|
+
i = 0
|
|
337
|
+
while i < len(rest):
|
|
338
|
+
a = rest[i]
|
|
339
|
+
if a in ("--refresh", "-h", "--help"):
|
|
340
|
+
i += 1
|
|
341
|
+
continue
|
|
342
|
+
if a in takes_value:
|
|
343
|
+
runner_args.append("--prompt" if a == "-p" else a)
|
|
344
|
+
if i + 1 < len(rest):
|
|
345
|
+
runner_args.append(rest[i + 1])
|
|
346
|
+
i += 2
|
|
347
|
+
else:
|
|
348
|
+
i += 1
|
|
349
|
+
continue
|
|
350
|
+
if a in boolean_flags:
|
|
351
|
+
runner_args.append(a)
|
|
352
|
+
i += 1
|
|
353
|
+
continue
|
|
354
|
+
if a.startswith("-"):
|
|
355
|
+
sys.stderr.write(f"error: unknown option: {a}\n\n")
|
|
356
|
+
_show_hub_help()
|
|
357
|
+
return 2
|
|
358
|
+
positional.append(a)
|
|
359
|
+
i += 1
|
|
294
360
|
|
|
295
361
|
def _log(msg: str) -> None:
|
|
296
362
|
sys.stderr.write(msg + "\n")
|
|
@@ -319,7 +385,14 @@ def _run_hub_subcommand(args: List[str]) -> int:
|
|
|
319
385
|
if not positional:
|
|
320
386
|
sys.stderr.write("error: hub install requires a profile name\n")
|
|
321
387
|
return 2
|
|
322
|
-
hub_mod.install_profile(positional[0], Path.cwd(), refresh=refresh, on_log=_log)
|
|
388
|
+
dest = hub_mod.install_profile(positional[0], Path.cwd(), refresh=refresh, on_log=_log)
|
|
389
|
+
# Tell the user exactly what they got and how to run it next.
|
|
390
|
+
rel_profile = os.path.relpath(str(dest / "profile.yaml"), str(Path.cwd()))
|
|
391
|
+
sys.stderr.write("\n")
|
|
392
|
+
sys.stderr.write(f"Next: edit {rel_profile} if you want, then run:\n")
|
|
393
|
+
sys.stderr.write(
|
|
394
|
+
f' agentproc --profile {rel_profile} --prompt "hi" --cwd <your-project>\n'
|
|
395
|
+
)
|
|
323
396
|
return 0
|
|
324
397
|
|
|
325
398
|
if sub == "run":
|
|
@@ -330,18 +403,20 @@ def _run_hub_subcommand(args: List[str]) -> int:
|
|
|
330
403
|
cache_dir = hub_mod.fetch_profile(profile_name, refresh=refresh, on_log=_log)
|
|
331
404
|
profile_path = str(cache_dir / "profile.yaml")
|
|
332
405
|
|
|
333
|
-
#
|
|
406
|
+
# Parse the runner-level flags we separated out above.
|
|
334
407
|
parser = build_parser()
|
|
335
|
-
# Drop the first positional (profile name) and --refresh from rest.
|
|
336
|
-
runner_args = [a for i, a in enumerate(rest) if not (
|
|
337
|
-
(not a.startswith("--") and i == 0) or a == "--refresh"
|
|
338
|
-
)]
|
|
339
408
|
opts = parser.parse_args(runner_args)
|
|
340
409
|
|
|
341
410
|
if not opts.prompt and not opts.stdin:
|
|
342
411
|
sys.stderr.write("error: hub run requires --prompt <text> or --stdin\n")
|
|
343
412
|
return 2
|
|
344
413
|
|
|
414
|
+
# hub run uses the user's current directory as the agent's cwd when
|
|
415
|
+
# --cwd is not given. Matches the hub docs and the right default for
|
|
416
|
+
# AI-CLI profiles where the agent should operate on the user's project.
|
|
417
|
+
if not opts.cwd:
|
|
418
|
+
opts.cwd = str(Path.cwd())
|
|
419
|
+
|
|
345
420
|
return _run_agent_with_profile(profile_path, opts)
|
|
346
421
|
|
|
347
422
|
sys.stderr.write(f"error: unknown hub subcommand: {sub}\n\n")
|
|
@@ -385,6 +460,7 @@ Profiles are cached at ~/.agentproc/cache/hub/<name>/ (24h TTL).
|
|
|
385
460
|
|
|
386
461
|
def _run_agent_with_profile(profile_path: str, opts) -> int:
|
|
387
462
|
"""Shared runner logic for both --profile path and hub run path."""
|
|
463
|
+
profile_dir = str(Path(profile_path).resolve().parent)
|
|
388
464
|
try:
|
|
389
465
|
yaml_text = Path(profile_path).resolve().read_text(encoding="utf-8")
|
|
390
466
|
profile_raw = parse_yaml(yaml_text)
|
|
@@ -426,6 +502,7 @@ def _run_agent_with_profile(profile_path: str, opts) -> int:
|
|
|
426
502
|
from_user=opts.from_user,
|
|
427
503
|
streaming=streaming,
|
|
428
504
|
cwd=opts.cwd,
|
|
505
|
+
profile_dir=profile_dir,
|
|
429
506
|
extra_env=extra_env,
|
|
430
507
|
timeout_secs=opts.timeout,
|
|
431
508
|
),
|
|
@@ -446,6 +523,7 @@ def _run_agent_with_profile(profile_path: str, opts) -> int:
|
|
|
446
523
|
from_user=opts.from_user,
|
|
447
524
|
streaming=streaming,
|
|
448
525
|
cwd=opts.cwd,
|
|
526
|
+
profile_dir=profile_dir,
|
|
449
527
|
extra_env=extra_env,
|
|
450
528
|
timeout_secs=opts.timeout,
|
|
451
529
|
on_partial=lambda t: verbose and sys.stderr.write(
|
|
@@ -478,26 +556,48 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
478
556
|
if argv is None:
|
|
479
557
|
argv = sys.argv[1:]
|
|
480
558
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
parser = build_parser()
|
|
486
|
-
opts = parser.parse_args(argv)
|
|
487
|
-
|
|
488
|
-
if opts.help:
|
|
489
|
-
show_help()
|
|
490
|
-
return 0
|
|
491
|
-
if opts.version:
|
|
492
|
-
show_version()
|
|
493
|
-
return 0
|
|
559
|
+
try:
|
|
560
|
+
# `agentproc hub <subcommand>` — defer to hub dispatcher.
|
|
561
|
+
if argv and argv[0] == "hub":
|
|
562
|
+
return _run_hub_subcommand(argv[1:])
|
|
494
563
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
564
|
+
parser = build_parser()
|
|
565
|
+
opts = parser.parse_args(argv)
|
|
566
|
+
|
|
567
|
+
if opts.help:
|
|
568
|
+
show_help()
|
|
569
|
+
return 0
|
|
570
|
+
if opts.version:
|
|
571
|
+
show_version()
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
if not opts.profile:
|
|
575
|
+
sys.stderr.write("error: --profile is required\n\n")
|
|
576
|
+
show_help()
|
|
577
|
+
return 2
|
|
499
578
|
|
|
500
|
-
|
|
579
|
+
return _run_agent_with_profile(opts.profile, opts)
|
|
580
|
+
except KeyboardInterrupt:
|
|
581
|
+
return 130
|
|
582
|
+
except Exception as e:
|
|
583
|
+
# Friendly handling for known hub errors: print the message + hint,
|
|
584
|
+
# never a raw stack trace.
|
|
585
|
+
from .hub import HubError
|
|
586
|
+
if isinstance(e, HubError):
|
|
587
|
+
sys.stderr.write(f"error: {e}\n")
|
|
588
|
+
if e.hint:
|
|
589
|
+
sys.stderr.write(f"\n{e.hint}\n")
|
|
590
|
+
return 1
|
|
591
|
+
# Network errors that slipped through unwrapped.
|
|
592
|
+
msg = str(e)
|
|
593
|
+
if any(s in msg for s in ("fetch failed", "ENOTFOUND", "ECONNREFUSED", "ECONNRESET", "urlopen error")):
|
|
594
|
+
sys.stderr.write(f"error: network error talking to GitHub: {msg}\n")
|
|
595
|
+
sys.stderr.write("\nThis is usually transient. Re-run the command, or run against a local checkout:\n")
|
|
596
|
+
sys.stderr.write(' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"\n')
|
|
597
|
+
return 1
|
|
598
|
+
# Everything else: surface the message without dumping a traceback.
|
|
599
|
+
sys.stderr.write(f"error: {msg}\n")
|
|
600
|
+
return 1
|
|
501
601
|
|
|
502
602
|
|
|
503
603
|
if __name__ == "__main__":
|
|
@@ -27,7 +27,7 @@ import time
|
|
|
27
27
|
import urllib.error
|
|
28
28
|
import urllib.request
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import Callable, Dict, List, Optional
|
|
30
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
31
31
|
|
|
32
32
|
HUB_REPO = "jeffkit/agentproc"
|
|
33
33
|
HUB_REF = "main"
|
|
@@ -38,6 +38,38 @@ GITHUB_TREES = "https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1"
|
|
|
38
38
|
GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{ref}/{path}"
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class HubError(RuntimeError):
|
|
42
|
+
"""Hub fetch failure with a human-readable remediation hint.
|
|
43
|
+
|
|
44
|
+
The CLI prints `message` + `hint` to stderr instead of dumping a stack
|
|
45
|
+
trace. `status` is the HTTP status (0 for network errors).
|
|
46
|
+
|
|
47
|
+
Inherits from RuntimeError so existing `except RuntimeError` callers
|
|
48
|
+
continue to work.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, hint: str = "", status: int = 0) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.hint = hint
|
|
54
|
+
self.status = status
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auth_headers(*, json: bool = False) -> Dict[str, str]:
|
|
58
|
+
"""Build HTTP headers, adding Authorization if a token is in env.
|
|
59
|
+
|
|
60
|
+
GITHUB_TOKEN (the env var GitHub Actions injects) or GH_TOKEN (what
|
|
61
|
+
the `gh` CLI sets) raises GitHub's anonymous rate limit from ~60/hr
|
|
62
|
+
to ~5000/hr.
|
|
63
|
+
"""
|
|
64
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or ""
|
|
65
|
+
h: Dict[str, str] = {"User-Agent": "agentproc-cli"}
|
|
66
|
+
if json:
|
|
67
|
+
h["Accept"] = "application/vnd.github+json"
|
|
68
|
+
if token:
|
|
69
|
+
h["Authorization"] = f"Bearer {token}"
|
|
70
|
+
return h
|
|
71
|
+
|
|
72
|
+
|
|
41
73
|
def _cache_root() -> Path:
|
|
42
74
|
"""Root cache directory: ~/.agentproc/cache/hub/"""
|
|
43
75
|
return Path.home() / ".agentproc" / "cache" / "hub"
|
|
@@ -73,19 +105,95 @@ def _write_cache_meta(name: str) -> None:
|
|
|
73
105
|
|
|
74
106
|
|
|
75
107
|
def _http_get_json(url: str, timeout: int = 30) -> Any:
|
|
76
|
-
"""GET a URL, return parsed JSON. Raises
|
|
77
|
-
req = urllib.request.Request(url, headers=
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
"""GET a URL, return parsed JSON. Raises HubError on failure."""
|
|
109
|
+
req = urllib.request.Request(url, headers=_auth_headers(json=True))
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
112
|
+
return json.loads(r.read().decode("utf-8"))
|
|
113
|
+
except urllib.error.HTTPError as e:
|
|
114
|
+
body = ""
|
|
115
|
+
try:
|
|
116
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
raise _http_error_to_hub(e.code, body[:200], url, is_json=True) from e
|
|
120
|
+
except urllib.error.URLError as e:
|
|
121
|
+
# Network/DNS/connection-level errors (no HTTP status).
|
|
122
|
+
raise HubError(
|
|
123
|
+
"could not reach GitHub while fetching hub profile",
|
|
124
|
+
status=0,
|
|
125
|
+
hint=(
|
|
126
|
+
"This is usually a transient network issue. Try:\n"
|
|
127
|
+
" 1. Re-run the command (often succeeds on retry).\n"
|
|
128
|
+
" 2. If your network requires a proxy, set HTTPS_PROXY.\n"
|
|
129
|
+
" 3. To avoid the network entirely, run against a local checkout:\n"
|
|
130
|
+
" agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
|
|
131
|
+
),
|
|
132
|
+
) from e
|
|
83
133
|
|
|
84
134
|
|
|
85
135
|
def _http_get_text(url: str, timeout: int = 30) -> str:
|
|
86
|
-
req = urllib.request.Request(url, headers=
|
|
87
|
-
|
|
88
|
-
|
|
136
|
+
req = urllib.request.Request(url, headers=_auth_headers(json=False))
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
139
|
+
return r.read().decode("utf-8")
|
|
140
|
+
except urllib.error.HTTPError as e:
|
|
141
|
+
body = ""
|
|
142
|
+
try:
|
|
143
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
raise _http_error_to_hub(e.code, body[:200], url, is_json=False) from e
|
|
147
|
+
except urllib.error.URLError as e:
|
|
148
|
+
raise HubError(
|
|
149
|
+
"could not reach GitHub while fetching hub profile",
|
|
150
|
+
status=0,
|
|
151
|
+
hint=(
|
|
152
|
+
"This is usually a transient network issue. Try:\n"
|
|
153
|
+
" 1. Re-run the command (often succeeds on retry).\n"
|
|
154
|
+
" 2. If your network requires a proxy, set HTTPS_PROXY.\n"
|
|
155
|
+
" 3. To avoid the network entirely, run against a local checkout:\n"
|
|
156
|
+
" agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
|
|
157
|
+
),
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> HubError:
|
|
162
|
+
"""Translate a GitHub HTTP error into a HubError with a remediation hint."""
|
|
163
|
+
authed = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
|
|
164
|
+
if status in (403, 429):
|
|
165
|
+
if authed:
|
|
166
|
+
hint = (
|
|
167
|
+
"Your GITHUB_TOKEN is set but still rate-limited. Wait a few minutes and retry,\n"
|
|
168
|
+
"or run against a local checkout instead:\n"
|
|
169
|
+
" agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
|
|
170
|
+
"\n"
|
|
171
|
+
"Not sure the profile name is right? Check with: agentproc hub list"
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
hint = (
|
|
175
|
+
"GitHub limits anonymous hub fetches to ~60/hour. To raise this to 5,000/hour:\n"
|
|
176
|
+
" export GITHUB_TOKEN=$(gh auth token) # if you have the GitHub CLI\n"
|
|
177
|
+
" # or set GITHUB_TOKEN to any personal access token\n"
|
|
178
|
+
"\n"
|
|
179
|
+
"To skip the network entirely, run against a local checkout:\n"
|
|
180
|
+
" git clone https://github.com/jeffkit/agentproc && cd agentproc\n"
|
|
181
|
+
" agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
|
|
182
|
+
"\n"
|
|
183
|
+
"Not sure the profile name is right? Check with: agentproc hub list"
|
|
184
|
+
)
|
|
185
|
+
return HubError(f"GitHub rate-limited the hub fetch (HTTP {status})", status=status, hint=hint)
|
|
186
|
+
if status == 404:
|
|
187
|
+
return HubError(
|
|
188
|
+
"profile not found on GitHub (HTTP 404)",
|
|
189
|
+
status=404,
|
|
190
|
+
hint="Check the profile name with `agentproc hub list`. (Typos are case-sensitive.)",
|
|
191
|
+
)
|
|
192
|
+
return HubError(
|
|
193
|
+
f"GitHub returned HTTP {status} for hub fetch",
|
|
194
|
+
status=status,
|
|
195
|
+
hint=body or "No additional detail from GitHub.",
|
|
196
|
+
)
|
|
89
197
|
|
|
90
198
|
|
|
91
199
|
def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
|
|
@@ -157,6 +265,85 @@ def _list_remote_profile_files(name: str) -> List[Dict[str, str]]:
|
|
|
157
265
|
return out
|
|
158
266
|
|
|
159
267
|
|
|
268
|
+
def _list_profile_names() -> List[str]:
|
|
269
|
+
"""List top-level profile names (directories directly under hub/).
|
|
270
|
+
|
|
271
|
+
Cheap: uses the same tree fetch as everything else, so this is one
|
|
272
|
+
network call at most (cached in-memory by urllib).
|
|
273
|
+
"""
|
|
274
|
+
url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
|
|
275
|
+
data = _http_get_json(url)
|
|
276
|
+
if not isinstance(data, dict) or "tree" not in data:
|
|
277
|
+
return []
|
|
278
|
+
names: List[str] = []
|
|
279
|
+
seen = set()
|
|
280
|
+
for entry in data["tree"]:
|
|
281
|
+
if not isinstance(entry, dict):
|
|
282
|
+
continue
|
|
283
|
+
p = str(entry.get("path", ""))
|
|
284
|
+
if not p.startswith("hub/"):
|
|
285
|
+
continue
|
|
286
|
+
seg = p[len("hub/"):].split("/")[0]
|
|
287
|
+
# Directories prefixed with `_` (e.g. `_shared`) hold bridge
|
|
288
|
+
# utilities, not profiles — exclude from listings and suggestions.
|
|
289
|
+
if seg and not seg.startswith("_") and seg not in seen:
|
|
290
|
+
seen.add(seg)
|
|
291
|
+
names.append(seg)
|
|
292
|
+
return sorted(names)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _suggest_close_name(input_name: str, candidates: List[str]) -> str:
|
|
296
|
+
"""Lightweight "did you mean" hint.
|
|
297
|
+
|
|
298
|
+
Two paths to a match:
|
|
299
|
+
1. Prefix match — `claude` matches `claude-code`, `echo` matches
|
|
300
|
+
`echo-agent`. Common typo pattern (user forgot a suffix).
|
|
301
|
+
Only accepts an unambiguous prefix.
|
|
302
|
+
2. Edit distance with length-scaled threshold (≤6 → 1 edit,
|
|
303
|
+
7-12 → 2, >12 → 3). Catches transpositions and small typos.
|
|
304
|
+
"""
|
|
305
|
+
if not input_name or not candidates:
|
|
306
|
+
return ""
|
|
307
|
+
n = input_name.lower()
|
|
308
|
+
# Path 1: unique prefix match.
|
|
309
|
+
prefix_matches = [c for c in candidates if c.lower().startswith(n)]
|
|
310
|
+
if len(prefix_matches) == 1:
|
|
311
|
+
return prefix_matches[0]
|
|
312
|
+
# Path 2: edit distance.
|
|
313
|
+
threshold = 1 if len(input_name) <= 6 else (2 if len(input_name) <= 12 else 3)
|
|
314
|
+
best = ""
|
|
315
|
+
best_dist = float("inf")
|
|
316
|
+
for c in candidates:
|
|
317
|
+
d = _edit_distance(n, c.lower())
|
|
318
|
+
if d < best_dist:
|
|
319
|
+
best_dist = d
|
|
320
|
+
best = c
|
|
321
|
+
if best and best_dist <= threshold:
|
|
322
|
+
return best
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _edit_distance(a: str, b: str) -> int:
|
|
327
|
+
m, n = len(a), len(b)
|
|
328
|
+
if m == 0:
|
|
329
|
+
return n
|
|
330
|
+
if n == 0:
|
|
331
|
+
return m
|
|
332
|
+
prev = list(range(n + 1))
|
|
333
|
+
curr = [0] * (n + 1)
|
|
334
|
+
for i in range(1, m + 1):
|
|
335
|
+
curr[0] = i
|
|
336
|
+
for j in range(1, n + 1):
|
|
337
|
+
cost = 0 if a[i - 1] == b[j - 1] else 1
|
|
338
|
+
curr[j] = min(
|
|
339
|
+
prev[j] + 1, # deletion
|
|
340
|
+
curr[j - 1] + 1, # insertion
|
|
341
|
+
prev[j - 1] + cost # substitution
|
|
342
|
+
)
|
|
343
|
+
prev, curr = curr, prev
|
|
344
|
+
return prev[n]
|
|
345
|
+
|
|
346
|
+
|
|
160
347
|
def _download_file(remote_path: str, local_path: Path) -> None:
|
|
161
348
|
"""Download a single file from raw.githubusercontent.com."""
|
|
162
349
|
url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=remote_path)
|
|
@@ -196,7 +383,27 @@ def fetch_profile(
|
|
|
196
383
|
|
|
197
384
|
entries = _list_remote_profile_files(name)
|
|
198
385
|
if not entries:
|
|
199
|
-
|
|
386
|
+
# getTree succeeded (otherwise _list_remote_profile_files would have
|
|
387
|
+
# raised HubError already). So the name is genuinely wrong — surface
|
|
388
|
+
# the list of available names so the user can correct the typo.
|
|
389
|
+
known: List[str] = []
|
|
390
|
+
try:
|
|
391
|
+
known = _list_profile_names()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
suggestion = _suggest_close_name(name, known)
|
|
395
|
+
lines = []
|
|
396
|
+
if suggestion:
|
|
397
|
+
lines.append(f"Did you mean `{suggestion}`?")
|
|
398
|
+
lines.append("")
|
|
399
|
+
lines.append("Available profiles:")
|
|
400
|
+
for k in known:
|
|
401
|
+
lines.append(f" - {k}")
|
|
402
|
+
raise HubError(
|
|
403
|
+
f"profile '{name}' not found in hub",
|
|
404
|
+
status=404,
|
|
405
|
+
hint="\n".join(lines),
|
|
406
|
+
)
|
|
200
407
|
|
|
201
408
|
# Clear cache, then re-download every file in the profile directory.
|
|
202
409
|
if cached.exists():
|
|
@@ -227,6 +434,10 @@ def list_profiles(
|
|
|
227
434
|
if entry["type"] != "dir":
|
|
228
435
|
continue
|
|
229
436
|
name = entry["name"]
|
|
437
|
+
# Skip utility directories like `_shared/` — they hold shared bridge
|
|
438
|
+
# helpers, not a runnable profile (no profile.yaml).
|
|
439
|
+
if name.startswith("_"):
|
|
440
|
+
continue
|
|
230
441
|
# Read profile.yaml from raw URL to get metadata.
|
|
231
442
|
try:
|
|
232
443
|
yaml_url = GITHUB_RAW.format(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""AgentProc runner — the canonical bridge-side engine.
|
|
2
2
|
|
|
3
3
|
This module is the canonical implementation of the AgentProc bridge-side
|
|
4
|
-
contract (spec/protocol.md). The CLI (cli.py) is a thin
|
|
4
|
+
contract (spec/protocol.md, wire protocol 0.1). The CLI (cli.py) is a thin
|
|
5
|
+
wrapper around it.
|
|
5
6
|
|
|
6
7
|
Responsibilities:
|
|
7
8
|
- Parse and validate a profile dict
|
|
@@ -91,12 +92,14 @@ class RunOptions:
|
|
|
91
92
|
streaming: Optional[bool] = None
|
|
92
93
|
extra_env: Dict[str, str] = field(default_factory=dict)
|
|
93
94
|
cwd: Optional[str] = None
|
|
95
|
+
profile_dir: Optional[str] = None
|
|
94
96
|
timeout_secs: Optional[int] = None
|
|
95
97
|
on_partial: Optional[Callable[[str], None]] = None
|
|
96
98
|
on_session: Optional[Callable[[str], None]] = None
|
|
97
99
|
on_error: Optional[Callable[[str], None]] = None
|
|
98
100
|
on_protocol_line: Optional[Callable[[str], None]] = None
|
|
99
101
|
on_stderr: Optional[Callable[[str], None]] = None
|
|
102
|
+
forward_stdin: Optional[bool] = None
|
|
100
103
|
|
|
101
104
|
|
|
102
105
|
# ---------------------------------------------------------------------------
|
|
@@ -114,24 +117,52 @@ def normalize_profile(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
114
117
|
if not isinstance(command, str) or not command.strip():
|
|
115
118
|
raise ValueError("profile.command must be a non-empty string")
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
raise ValueError("profile.command produced empty argv")
|
|
120
|
-
|
|
121
|
-
args_value = src.get("args", [])
|
|
120
|
+
has_args_field = "args" in src and src["args"] is not None
|
|
121
|
+
args_value = src.get("args") or []
|
|
122
122
|
if not isinstance(args_value, list):
|
|
123
123
|
raise ValueError("profile.args must be a list")
|
|
124
124
|
|
|
125
|
+
# Per spec: `command` is argv[0]; `args` is argv[1..]. Two mutually
|
|
126
|
+
# exclusive forms:
|
|
127
|
+
# (a) `args` absent + command has whitespace → split command into argv
|
|
128
|
+
# (the legacy shorthand: `command: python3 ./bridge.py`)
|
|
129
|
+
# (b) `args` present (even empty `[]`) → command is a single token,
|
|
130
|
+
# never split. This is the escape hatch for paths with whitespace:
|
|
131
|
+
# command: "/path with spaces/my agent"
|
|
132
|
+
# args: []
|
|
133
|
+
# `args: []` (explicit empty list) is DISTINCT from "args absent": the
|
|
134
|
+
# explicit form means "do not split command"; the absent form falls back
|
|
135
|
+
# to the whitespace-splitting shorthand.
|
|
136
|
+
if has_args_field:
|
|
137
|
+
argv = [command.strip()]
|
|
138
|
+
else:
|
|
139
|
+
argv = command.strip().split()
|
|
140
|
+
if not argv or not argv[0]:
|
|
141
|
+
raise ValueError("profile.command produced empty argv")
|
|
142
|
+
|
|
125
143
|
cwd_value = src.get("cwd")
|
|
126
144
|
env_value = src.get("env") or {}
|
|
127
145
|
if not isinstance(env_value, dict):
|
|
128
146
|
raise ValueError("profile.env must be a dict")
|
|
129
147
|
|
|
148
|
+
# env_allowlist (optional): when present, ${VAR} references in the env
|
|
149
|
+
# block whose name is NOT in the list expand to empty + a stderr warning.
|
|
150
|
+
# Absent ⇒ current behaviour (expand against the full bridge environment).
|
|
151
|
+
# Opt-in: existing profiles keep working unchanged.
|
|
152
|
+
allowlist_raw = src.get("env_allowlist")
|
|
153
|
+
if allowlist_raw is None:
|
|
154
|
+
env_allowlist: Optional[set] = None
|
|
155
|
+
elif isinstance(allowlist_raw, list):
|
|
156
|
+
env_allowlist = {str(x) for x in allowlist_raw}
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError("profile.env_allowlist must be a list")
|
|
159
|
+
|
|
130
160
|
return {
|
|
131
161
|
"argv": argv,
|
|
132
162
|
"args": [str(a) for a in args_value],
|
|
133
163
|
"cwd": expand_path(str(cwd_value)) if cwd_value else None,
|
|
134
164
|
"env": env_value,
|
|
165
|
+
"env_allowlist": env_allowlist,
|
|
135
166
|
"stdin": "message" if src.get("stdin") == "message" else "none",
|
|
136
167
|
"timeout_secs": (
|
|
137
168
|
int(src["timeout_secs"]) if _is_int_like(src.get("timeout_secs"))
|
|
@@ -173,15 +204,127 @@ def substitute(value: str, ctx: Dict[str, str]) -> str:
|
|
|
173
204
|
.replace("{{MESSAGE}}", ctx.get("message", ""))
|
|
174
205
|
.replace("{{SESSION_ID}}", ctx.get("session_id", ""))
|
|
175
206
|
.replace("{{SESSION_NAME}}", ctx.get("session_name", ""))
|
|
207
|
+
.replace("{{PROFILE_DIR}}", ctx.get("profile_dir", ""))
|
|
176
208
|
)
|
|
177
209
|
|
|
178
210
|
|
|
179
|
-
def expand_env_ref(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
def expand_env_ref(
|
|
212
|
+
value: str,
|
|
213
|
+
env: Dict[str, str],
|
|
214
|
+
allowlist: Optional[set] = None,
|
|
215
|
+
on_blocked: Optional[Callable[[str], None]] = None,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Expand ${VAR} references against ``env``.
|
|
218
|
+
|
|
219
|
+
When ``allowlist`` is a set of variable names, references to names NOT in
|
|
220
|
+
the set expand to empty string and ``on_blocked`` (if given) is called
|
|
221
|
+
with each blocked name. When ``allowlist`` is None, all references expand
|
|
222
|
+
normally (the default, pre-allowlist behaviour).
|
|
223
|
+
"""
|
|
224
|
+
def repl(m: "re.Match[str]") -> str:
|
|
225
|
+
name = m.group(1)
|
|
226
|
+
if allowlist is not None and name not in allowlist:
|
|
227
|
+
if on_blocked:
|
|
228
|
+
on_blocked(name)
|
|
229
|
+
return ""
|
|
230
|
+
return env.get(name, "")
|
|
231
|
+
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, str(value))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _diagnose_spawn_error(
|
|
235
|
+
err: BaseException,
|
|
236
|
+
*,
|
|
237
|
+
argv: List[str],
|
|
238
|
+
cwd: Optional[str],
|
|
239
|
+
env: Dict[str, str],
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Produce a human-friendly hint for a spawn FileNotFoundError / ENOENT.
|
|
242
|
+
|
|
243
|
+
Subprocess raises FileNotFoundError when either the command isn't on
|
|
244
|
+
PATH or the cwd doesn't exist — Python folds both into the same
|
|
245
|
+
exception attributed to argv[0], which is misleading. Disambiguate.
|
|
246
|
+
"""
|
|
247
|
+
# (a) cwd doesn't exist or isn't a directory
|
|
248
|
+
if cwd:
|
|
249
|
+
p = Path(cwd)
|
|
250
|
+
try:
|
|
251
|
+
if not p.is_dir():
|
|
252
|
+
return f"profile.cwd is not a directory: {cwd}"
|
|
253
|
+
except PermissionError:
|
|
254
|
+
return f"profile.cwd is not accessible (permission denied): {cwd}"
|
|
255
|
+
except OSError:
|
|
256
|
+
return f"profile.cwd does not exist: {cwd}. Pass --cwd <path> to point at a real directory."
|
|
257
|
+
|
|
258
|
+
# (b) the command (argv[0]) is not on PATH (bare name, no slash)
|
|
259
|
+
cmd = argv[0] if argv else ""
|
|
260
|
+
is_pathed = "/" in cmd or "\\" in cmd
|
|
261
|
+
if not is_pathed and cmd:
|
|
262
|
+
from shutil import which
|
|
263
|
+
if not which(cmd):
|
|
264
|
+
return (
|
|
265
|
+
f"'{cmd}' not found on PATH. Install it, or if it's installed, "
|
|
266
|
+
"make sure PATH is set correctly when the bridge spawns the agent."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# (c) argv[0] looks like a path — check whether the file itself exists
|
|
270
|
+
if is_pathed and cmd:
|
|
271
|
+
if not Path(cmd).exists():
|
|
272
|
+
return f"command path does not exist or is not executable: {cmd}"
|
|
273
|
+
|
|
274
|
+
# (d) Command exists; suspect an argv file argument (e.g. python3 ./bridge.py)
|
|
275
|
+
for a in argv[1:]:
|
|
276
|
+
if a.startswith("-"):
|
|
277
|
+
continue
|
|
278
|
+
if "/" in a or "\\" in a:
|
|
279
|
+
resolved = a if Path(a).is_absolute() else (
|
|
280
|
+
str(Path(cwd) / a) if cwd else str(Path(a).resolve())
|
|
281
|
+
)
|
|
282
|
+
if not Path(resolved).exists():
|
|
283
|
+
return (
|
|
284
|
+
f"argument file not found: {a} (resolved to {resolved}). "
|
|
285
|
+
"The profile likely needs --cwd or the bundled script path is wrong."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _diagnose_stderr_failure(stderr_text: str) -> str:
|
|
292
|
+
"""Best-effort pattern check against the agent's accumulated stderr.
|
|
293
|
+
|
|
294
|
+
Catches "bridge file not found" failures that the wrapped interpreter
|
|
295
|
+
writes to its own stderr before exiting non-zero. Returns a friendly
|
|
296
|
+
hint, or '' if nothing recognizable.
|
|
297
|
+
"""
|
|
298
|
+
if not stderr_text:
|
|
299
|
+
return ""
|
|
300
|
+
lower = stderr_text.lower()
|
|
301
|
+
|
|
302
|
+
# python3: "can't open file '/path/x.py': [Errno 2] No such file or directory"
|
|
303
|
+
m = re.search(r"(?:can'?t|cannot) open file '([^']+)': \[Errno 2\] No such file or directory", stderr_text)
|
|
304
|
+
if m:
|
|
305
|
+
return (
|
|
306
|
+
f"agent script not found: {m.group(1)}. Check the profile's command "
|
|
307
|
+
"path (likely a {{PROFILE_DIR}} issue or a typo)."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# node: "Error: Cannot find module '/path/x.js'"
|
|
311
|
+
m = re.search(r"Cannot find module '([^']+)'", stderr_text)
|
|
312
|
+
if m:
|
|
313
|
+
return (
|
|
314
|
+
f"agent script not found: {m.group(1)}. Check the profile's command "
|
|
315
|
+
"path (likely a {{PROFILE_DIR}} issue or a typo)."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# bash: "bash: line N: ./x.sh: No such file or directory"
|
|
319
|
+
m = re.search(r"(?:^|\n)[^:]+: line \d+: ([^:]+): No such file or directory", stderr_text)
|
|
320
|
+
if m:
|
|
321
|
+
return f"agent script not found: {m.group(1)}. Check the profile's command path."
|
|
322
|
+
|
|
323
|
+
# Generic errno 2 / ENOENT sentinel.
|
|
324
|
+
if re.search(r"errno 2|enoent|no such file or directory", lower):
|
|
325
|
+
return "agent reported a missing file. Check the profile's command and cwd."
|
|
326
|
+
|
|
327
|
+
return ""
|
|
185
328
|
|
|
186
329
|
|
|
187
330
|
# ---------------------------------------------------------------------------
|
|
@@ -194,9 +337,13 @@ def decode_json_value(raw: str) -> str:
|
|
|
194
337
|
return ""
|
|
195
338
|
try:
|
|
196
339
|
v = json.loads(text)
|
|
197
|
-
return v if isinstance(v, str) else str(v)
|
|
198
340
|
except json.JSONDecodeError:
|
|
199
341
|
return text
|
|
342
|
+
# Only JSON strings are meaningful payloads — a sentinel's value is text
|
|
343
|
+
# for the user. Non-string JSON (number/bool/null/array/object) means the
|
|
344
|
+
# agent misused the API; fall back to the raw text so the result is
|
|
345
|
+
# language-independent (str(True) != String(true) across runtimes).
|
|
346
|
+
return v if isinstance(v, str) else text
|
|
200
347
|
|
|
201
348
|
|
|
202
349
|
def classify_line(line: str) -> Dict[str, str]:
|
|
@@ -224,20 +371,39 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
224
371
|
options.timeout_secs if options.timeout_secs is not None else profile["timeout_secs"]
|
|
225
372
|
)
|
|
226
373
|
cwd = options.cwd or profile["cwd"]
|
|
374
|
+
# Resolve relative cwd against the profile's own directory (if known),
|
|
375
|
+
# so profiles written as `cwd: .` work no matter where the user invokes
|
|
376
|
+
# from. Absolute paths and ~-prefixed paths are already absolute.
|
|
377
|
+
if cwd and not Path(cwd).is_absolute() and options.profile_dir:
|
|
378
|
+
cwd = str(Path(options.profile_dir) / cwd)
|
|
227
379
|
|
|
228
380
|
subst_ctx = {
|
|
229
381
|
"message": options.message,
|
|
230
382
|
"session_id": options.session_id,
|
|
231
383
|
"session_name": options.session_name,
|
|
384
|
+
"profile_dir": options.profile_dir or "",
|
|
232
385
|
}
|
|
233
386
|
|
|
234
|
-
argv
|
|
387
|
+
# Substitute placeholders in argv (command) too, not just args — so
|
|
388
|
+
# `command: python3 {{PROFILE_DIR}}/bridge.py` resolves correctly.
|
|
389
|
+
argv = [substitute(a, subst_ctx) for a in profile["argv"]]
|
|
235
390
|
for a in profile["args"]:
|
|
236
391
|
argv.append(substitute(a, subst_ctx))
|
|
237
392
|
|
|
238
393
|
env = dict(os.environ)
|
|
394
|
+
allowlist = profile["env_allowlist"]
|
|
239
395
|
for k, v in profile["env"].items():
|
|
240
|
-
env[k] = expand_env_ref(
|
|
396
|
+
env[k] = expand_env_ref(
|
|
397
|
+
substitute(str(v), subst_ctx),
|
|
398
|
+
os.environ,
|
|
399
|
+
allowlist=allowlist,
|
|
400
|
+
on_blocked=(
|
|
401
|
+
lambda name: options.on_stderr(
|
|
402
|
+
f"[agentproc runner] env_allowlist blocked ${{{name}}} "
|
|
403
|
+
f"(not in allowlist); expanded to empty"
|
|
404
|
+
) if options.on_stderr else None
|
|
405
|
+
),
|
|
406
|
+
)
|
|
241
407
|
for k, v in options.extra_env.items():
|
|
242
408
|
env[k] = str(v)
|
|
243
409
|
|
|
@@ -248,10 +414,30 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
248
414
|
env["AGENT_STREAMING"] = "1" if streaming else "0"
|
|
249
415
|
env["AGENT_PROTOCOL_VERSION"] = PROTOCOL_VERSION
|
|
250
416
|
|
|
251
|
-
|
|
417
|
+
# forward_stdin overrides profile.stdin: when True, always write the
|
|
418
|
+
# message to stdin (used by the CLI's --stdin flag). When None/False,
|
|
419
|
+
# fall back to the profile's stdin setting.
|
|
420
|
+
use_stdin = profile["stdin"] == "message" or bool(options.forward_stdin)
|
|
421
|
+
stdin_payload = options.message if use_stdin else None
|
|
252
422
|
|
|
253
423
|
result = RunResult()
|
|
254
424
|
body_lines: List[str] = []
|
|
425
|
+
# Two views on stderr:
|
|
426
|
+
# - stderr_window: bounded sliding window (8 KB) for the on_stderr UI
|
|
427
|
+
# callback, so a noisy agent cannot exhaust memory.
|
|
428
|
+
# - stderr_full: unbounded capture used only for post-mortem pattern
|
|
429
|
+
# diagnosis. Without the full text, a chatty agent can push the real
|
|
430
|
+
# error out of the window and the friendly hint goes missing.
|
|
431
|
+
stderr_window: List[str] = []
|
|
432
|
+
stderr_full: List[str] = []
|
|
433
|
+
STDERR_CAP = 8192
|
|
434
|
+
|
|
435
|
+
def _append_stderr(text: str) -> None:
|
|
436
|
+
stderr_full.append(text)
|
|
437
|
+
stderr_window.append(text)
|
|
438
|
+
total = sum(len(s) for s in stderr_window)
|
|
439
|
+
while total > STDERR_CAP and len(stderr_window) > 1:
|
|
440
|
+
total -= len(stderr_window.pop(0))
|
|
255
441
|
|
|
256
442
|
try:
|
|
257
443
|
proc = subprocess.Popen(
|
|
@@ -265,13 +451,24 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
265
451
|
bufsize=1,
|
|
266
452
|
)
|
|
267
453
|
except FileNotFoundError as e:
|
|
454
|
+
tip = _diagnose_spawn_error(e, argv=argv, cwd=cwd, env=env)
|
|
268
455
|
if options.on_stderr:
|
|
269
456
|
options.on_stderr(f"[agentproc runner] spawn error: {e}")
|
|
457
|
+
if tip:
|
|
458
|
+
options.on_stderr(f"[agentproc runner] hint: {tip}")
|
|
459
|
+
if options.on_error:
|
|
460
|
+
options.on_error(f"failed to start agent: {tip or str(e)}")
|
|
461
|
+
if not result.error:
|
|
462
|
+
result.error = tip or str(e)
|
|
270
463
|
result.exit_code = EXIT_ERROR
|
|
271
464
|
return result
|
|
272
465
|
except PermissionError as e:
|
|
273
466
|
if options.on_stderr:
|
|
274
467
|
options.on_stderr(f"[agentproc runner] spawn error: {e}")
|
|
468
|
+
if options.on_error:
|
|
469
|
+
options.on_error(f"failed to start agent: {e}")
|
|
470
|
+
if not result.error:
|
|
471
|
+
result.error = str(e)
|
|
275
472
|
result.exit_code = EXIT_ERROR
|
|
276
473
|
return result
|
|
277
474
|
|
|
@@ -285,6 +482,7 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
285
482
|
def _drain_stderr() -> None:
|
|
286
483
|
assert proc.stderr is not None
|
|
287
484
|
for line in proc.stderr:
|
|
485
|
+
_append_stderr(line)
|
|
288
486
|
line = line.rstrip("\r\n")
|
|
289
487
|
if options.on_stderr:
|
|
290
488
|
options.on_stderr(line)
|
|
@@ -337,15 +535,18 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
337
535
|
except subprocess.TimeoutExpired:
|
|
338
536
|
if time.monotonic() >= deadline:
|
|
339
537
|
timed_out = True
|
|
538
|
+
# terminate() is SIGTERM on POSIX, TerminateProcess on
|
|
539
|
+
# Windows — both are the "polite shutdown" the spec's
|
|
540
|
+
# SIGTERM→SIGKILL contract refers to.
|
|
340
541
|
try:
|
|
341
|
-
proc.
|
|
542
|
+
proc.terminate()
|
|
342
543
|
except (ProcessLookupError, PermissionError):
|
|
343
544
|
pass
|
|
344
545
|
try:
|
|
345
546
|
proc.wait(timeout=profile["kill_grace_secs"])
|
|
346
547
|
except subprocess.TimeoutExpired:
|
|
347
548
|
try:
|
|
348
|
-
proc.
|
|
549
|
+
proc.kill()
|
|
349
550
|
except (ProcessLookupError, PermissionError):
|
|
350
551
|
pass
|
|
351
552
|
try:
|
|
@@ -356,20 +557,48 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
|
356
557
|
else:
|
|
357
558
|
exit_code = proc.wait()
|
|
358
559
|
except KeyboardInterrupt:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
560
|
+
# SIGINT only exists on POSIX. On Windows we fall back to terminate().
|
|
561
|
+
if hasattr(signal, "SIGINT"):
|
|
562
|
+
try:
|
|
563
|
+
proc.send_signal(signal.SIGINT)
|
|
564
|
+
except (ProcessLookupError, PermissionError):
|
|
565
|
+
pass
|
|
566
|
+
else:
|
|
567
|
+
try:
|
|
568
|
+
proc.terminate()
|
|
569
|
+
except (ProcessLookupError, PermissionError):
|
|
570
|
+
pass
|
|
363
571
|
exit_code = proc.wait()
|
|
364
572
|
|
|
365
573
|
stdout_thread.join(timeout=5)
|
|
366
|
-
|
|
574
|
+
# Drain stderr fully before running post-mortem diagnosis. The diagnosis
|
|
575
|
+
# reads the full capture, so we need the drain thread to have caught up.
|
|
576
|
+
# Generous timeout since the process has already exited — at most we wait
|
|
577
|
+
# for the pipe buffer to flush.
|
|
578
|
+
stderr_thread.join(timeout=10)
|
|
579
|
+
if stderr_thread.is_alive():
|
|
580
|
+
# Very rare; indicates a pathological agent that keeps stderr open.
|
|
581
|
+
# The diagnosis may be incomplete, but we don't want to hang forever.
|
|
582
|
+
if options.on_stderr:
|
|
583
|
+
options.on_stderr("[agentproc runner] warning: stderr drain timed out; diagnosis may be incomplete")
|
|
367
584
|
|
|
368
585
|
result.reply = "\n".join(body_lines)
|
|
369
586
|
if len(result.reply) > profile["max_reply_chars"]:
|
|
370
587
|
suffix = "\n\n…(truncated)" if profile["max_reply_chars"] == DEFAULT_MAX_REPLY_CHARS else ""
|
|
371
588
|
result.reply = result.reply[: profile["max_reply_chars"]] + suffix
|
|
372
589
|
|
|
590
|
+
# If the agent exited non-zero with no AGENT_ERROR, peek at its stderr for
|
|
591
|
+
# common "command/file not found" patterns and surface a friendly hint.
|
|
592
|
+
# Uses the FULL stderr — a noisy agent can fill the 8 KB window with
|
|
593
|
+
# progress junk before the real error lands at the end.
|
|
594
|
+
if not timed_out and not result.error and exit_code != 0:
|
|
595
|
+
stderr_text = "".join(stderr_full)
|
|
596
|
+
hint = _diagnose_stderr_failure(stderr_text)
|
|
597
|
+
if hint:
|
|
598
|
+
result.error = hint
|
|
599
|
+
if options.on_error:
|
|
600
|
+
options.on_error(hint)
|
|
601
|
+
|
|
373
602
|
if timed_out:
|
|
374
603
|
result.timed_out = True
|
|
375
604
|
result.exit_code = EXIT_TIMEOUT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Cross-implementation conformance tests.
|
|
2
|
+
|
|
3
|
+
Drives the shared `spec/conformance/cases.json` fixture through the Python
|
|
4
|
+
runner's `classify_line` and asserts the result matches the expected
|
|
5
|
+
{kind, value}. The Node SDK runs the same fixture through its `classifyLine`
|
|
6
|
+
in `sdk/node/src/conformance.test.js` — together they guarantee the two
|
|
7
|
+
reference implementations classify stdout identically.
|
|
8
|
+
|
|
9
|
+
When you change the spec's line-recognition rules, add a case here first;
|
|
10
|
+
both SDKs will fail until they agree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from agentproc.runner import classify_line
|
|
21
|
+
|
|
22
|
+
CASES_PATH = Path(__file__).resolve().parents[3] / "spec" / "conformance" / "cases.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_cases():
|
|
26
|
+
data = json.loads(CASES_PATH.read_text(encoding="utf-8"))
|
|
27
|
+
return [pytest.param(c["line"], c["expect"], id=c["line"][:60]) for c in data["cases"]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("line,expect", _load_cases())
|
|
31
|
+
def test_classify_line_conformance(line: str, expect: dict) -> None:
|
|
32
|
+
got = classify_line(line)
|
|
33
|
+
assert got == expect, f"line={line!r}: got {got}, expected {expect}"
|
|
@@ -32,6 +32,10 @@ from agentproc.hub import (
|
|
|
32
32
|
|
|
33
33
|
FAKE_TREE = [
|
|
34
34
|
{"path": "hub", "type": "tree"},
|
|
35
|
+
{"path": "hub/_shared", "type": "tree"},
|
|
36
|
+
{"path": "hub/_shared/stream_utils.py", "type": "blob"},
|
|
37
|
+
{"path": "hub/_shared/stream_utils.js", "type": "blob"},
|
|
38
|
+
{"path": "hub/_shared/README.md", "type": "blob"},
|
|
35
39
|
{"path": "hub/echo-agent", "type": "tree"},
|
|
36
40
|
{"path": "hub/echo-agent/profile.yaml", "type": "blob"},
|
|
37
41
|
{"path": "hub/echo-agent/bridge.py", "type": "blob"},
|
|
@@ -237,6 +241,15 @@ class TestListProfiles:
|
|
|
237
241
|
assert p["name"].startswith("") # just a sanity check
|
|
238
242
|
assert "/" not in p["name"]
|
|
239
243
|
|
|
244
|
+
def test_list_skips_underscore_utility_dirs(self, isolated_cache):
|
|
245
|
+
"""`_shared/` holds bridge helpers, not a profile — must be excluded."""
|
|
246
|
+
with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
|
|
247
|
+
patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
|
|
248
|
+
profiles = hub_mod.list_profiles()
|
|
249
|
+
names = [p["name"] for p in profiles]
|
|
250
|
+
assert not any(n.startswith("_") for n in names), names
|
|
251
|
+
assert "_shared" not in names
|
|
252
|
+
|
|
240
253
|
|
|
241
254
|
# ---------------------------------------------------------------------------
|
|
242
255
|
# show_readme
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|