agentproc 0.1.1__tar.gz → 0.2.1__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.1.1 → agentproc-0.2.1}/PKG-INFO +3 -3
- {agentproc-0.1.1 → agentproc-0.2.1}/pyproject.toml +6 -3
- agentproc-0.2.1/src/agentproc/cli.py +373 -0
- agentproc-0.2.1/src/agentproc/runner.py +381 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/src/agentproc.egg-info/PKG-INFO +3 -3
- {agentproc-0.1.1 → agentproc-0.2.1}/src/agentproc.egg-info/SOURCES.txt +5 -1
- agentproc-0.2.1/src/agentproc.egg-info/entry_points.txt +2 -0
- agentproc-0.2.1/tests/test_runner.py +374 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/setup.cfg +0 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/src/agentproc/__init__.py +0 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.1.1 → agentproc-0.2.1}/tests/test_agentproc.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentproc
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: AgentProc SDK — connect any Agent CLI to a messaging platform
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffkit/agentproc
|
|
7
|
-
Project-URL: Documentation, https://
|
|
7
|
+
Project-URL: Documentation, https://agentproc.dev/
|
|
8
8
|
Project-URL: Repository, https://github.com/jeffkit/agentproc
|
|
9
9
|
Project-URL: Issues, https://github.com/jeffkit/agentproc/issues
|
|
10
10
|
Requires-Python: >=3.9
|
|
@@ -4,16 +4,19 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentproc"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "AgentProc SDK — connect any Agent CLI to a messaging platform
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
dependencies = []
|
|
13
13
|
|
|
14
|
+
[project.scripts]
|
|
15
|
+
agentproc = "agentproc.cli:main"
|
|
16
|
+
|
|
14
17
|
[project.urls]
|
|
15
18
|
Homepage = "https://github.com/jeffkit/agentproc"
|
|
16
|
-
Documentation = "https://
|
|
19
|
+
Documentation = "https://agentproc.dev/"
|
|
17
20
|
Repository = "https://github.com/jeffkit/agentproc"
|
|
18
21
|
Issues = "https://github.com/jeffkit/agentproc/issues"
|
|
19
22
|
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""agentproc CLI — run any AgentProc profile against a message.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
agentproc --profile <path.yaml> --prompt "hello" [options]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .runner import (
|
|
18
|
+
PROTOCOL_VERSION,
|
|
19
|
+
EXIT_ERROR,
|
|
20
|
+
EXIT_SUCCESS,
|
|
21
|
+
EXIT_TIMEOUT,
|
|
22
|
+
RunOptions,
|
|
23
|
+
run,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_pkg_version() -> str:
|
|
28
|
+
"""Read version from pyproject.toml at runtime (zero-dep)."""
|
|
29
|
+
try:
|
|
30
|
+
toml_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
31
|
+
if toml_path.exists():
|
|
32
|
+
text = toml_path.read_text(encoding="utf-8")
|
|
33
|
+
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
34
|
+
if m:
|
|
35
|
+
return m.group(1)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return "0.0.0+unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PKG_VERSION = _read_pkg_version()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Minimal YAML parser (zero-dep; covers hub profile subset)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def parse_yaml(text: str) -> Dict[str, Any]:
|
|
49
|
+
"""Parse a YAML subset into a Python dict.
|
|
50
|
+
|
|
51
|
+
Supports: nested maps, block scalars (|), inline flow sequences ([a, b]),
|
|
52
|
+
block sequences (- item), quoted strings, booleans, null, ints, floats.
|
|
53
|
+
"""
|
|
54
|
+
text_stripped = text.strip()
|
|
55
|
+
if text_stripped.startswith("{") or text_stripped.startswith("["):
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(text)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
lines = text.splitlines()
|
|
62
|
+
root: Dict[str, Any] = {}
|
|
63
|
+
stack: List[Tuple[int, Any]] = [(-1, root)]
|
|
64
|
+
|
|
65
|
+
def current(min_indent: int) -> Tuple[int, Any]:
|
|
66
|
+
while len(stack) > 1 and stack[-1][0] >= min_indent:
|
|
67
|
+
stack.pop()
|
|
68
|
+
return stack[-1]
|
|
69
|
+
|
|
70
|
+
i = 0
|
|
71
|
+
while i < len(lines):
|
|
72
|
+
raw = lines[i]
|
|
73
|
+
if raw.strip() == "" or raw.strip().startswith("#"):
|
|
74
|
+
i += 1
|
|
75
|
+
continue
|
|
76
|
+
indent = _leading_spaces(raw)
|
|
77
|
+
content = raw[indent:].rstrip("\r")
|
|
78
|
+
_, container = current(indent)
|
|
79
|
+
|
|
80
|
+
if content == "-" or content.startswith("- "):
|
|
81
|
+
if isinstance(container, list):
|
|
82
|
+
rest = "" if content == "-" else content[2:]
|
|
83
|
+
if rest.strip():
|
|
84
|
+
container.append(_strip_scalar(rest))
|
|
85
|
+
i += 1
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
m = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$", content)
|
|
89
|
+
if not m:
|
|
90
|
+
i += 1
|
|
91
|
+
continue
|
|
92
|
+
key, val = m.group(1), m.group(2)
|
|
93
|
+
|
|
94
|
+
if val == "":
|
|
95
|
+
j = i + 1
|
|
96
|
+
while j < len(lines) and (lines[j].strip() == "" or lines[j].strip().startswith("#")):
|
|
97
|
+
j += 1
|
|
98
|
+
if j < len(lines):
|
|
99
|
+
next_indent = _leading_spaces(lines[j])
|
|
100
|
+
next_content = lines[j][next_indent:]
|
|
101
|
+
if next_indent > indent and (next_content == "-" or next_content.startswith("- ")):
|
|
102
|
+
arr: List[Any] = []
|
|
103
|
+
if isinstance(container, dict):
|
|
104
|
+
container[key] = arr
|
|
105
|
+
stack.append((indent, arr))
|
|
106
|
+
i += 1
|
|
107
|
+
continue
|
|
108
|
+
elif next_indent > indent:
|
|
109
|
+
child: Dict[str, Any] = {}
|
|
110
|
+
if isinstance(container, dict):
|
|
111
|
+
container[key] = child
|
|
112
|
+
stack.append((indent, child))
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
if isinstance(container, dict):
|
|
116
|
+
container[key] = ""
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if val in ("|", "|-", ">"):
|
|
121
|
+
block_lines: List[str] = []
|
|
122
|
+
j = i + 1
|
|
123
|
+
while j < len(lines):
|
|
124
|
+
nr = lines[j]
|
|
125
|
+
if nr.strip() == "":
|
|
126
|
+
block_lines.append("")
|
|
127
|
+
j += 1
|
|
128
|
+
continue
|
|
129
|
+
ni = _leading_spaces(nr)
|
|
130
|
+
if ni <= indent:
|
|
131
|
+
break
|
|
132
|
+
block_lines.append(nr[min(indent + 2, len(nr)):])
|
|
133
|
+
j += 1
|
|
134
|
+
joined = "\n".join(block_lines)
|
|
135
|
+
if val == "|":
|
|
136
|
+
value = re.sub(r"\n*$", "\n", joined)
|
|
137
|
+
else:
|
|
138
|
+
value = re.sub(r"\n*$", "", joined)
|
|
139
|
+
if isinstance(container, dict):
|
|
140
|
+
container[key] = value
|
|
141
|
+
i = j
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if isinstance(container, dict):
|
|
145
|
+
container[key] = _strip_scalar(val)
|
|
146
|
+
i += 1
|
|
147
|
+
|
|
148
|
+
return root
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _leading_spaces(s: str) -> int:
|
|
152
|
+
n = 0
|
|
153
|
+
for ch in s:
|
|
154
|
+
if ch == " ":
|
|
155
|
+
n += 1
|
|
156
|
+
elif ch == "\t":
|
|
157
|
+
n += 2
|
|
158
|
+
else:
|
|
159
|
+
break
|
|
160
|
+
return n
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _strip_scalar(v: str) -> Any:
|
|
164
|
+
v = v.strip()
|
|
165
|
+
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
|
166
|
+
return v[1:-1]
|
|
167
|
+
if v.startswith("[") and v.endswith("]"):
|
|
168
|
+
inner = v[1:-1].strip()
|
|
169
|
+
if not inner:
|
|
170
|
+
return []
|
|
171
|
+
return [_strip_scalar(s.strip()) for s in inner.split(",")]
|
|
172
|
+
lv = v.lower()
|
|
173
|
+
if lv == "true":
|
|
174
|
+
return True
|
|
175
|
+
if lv == "false":
|
|
176
|
+
return False
|
|
177
|
+
if lv in ("null", "~", ""):
|
|
178
|
+
return None
|
|
179
|
+
if re.match(r"^[+-]?\d+$", v):
|
|
180
|
+
return int(v)
|
|
181
|
+
if re.match(r"^[+-]?\d+\.\d+$", v):
|
|
182
|
+
return float(v)
|
|
183
|
+
return v
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Argparse setup
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
191
|
+
p = argparse.ArgumentParser(
|
|
192
|
+
prog="agentproc",
|
|
193
|
+
description="Run an AgentProc profile against a message.",
|
|
194
|
+
add_help=False,
|
|
195
|
+
)
|
|
196
|
+
p.add_argument("-h", "--help", action="store_true")
|
|
197
|
+
p.add_argument("--version", action="store_true")
|
|
198
|
+
p.add_argument("-p", "--profile")
|
|
199
|
+
p.add_argument("--prompt")
|
|
200
|
+
p.add_argument("--session", default="")
|
|
201
|
+
p.add_argument("--session-name", default="default")
|
|
202
|
+
p.add_argument("--from", dest="from_user", default="")
|
|
203
|
+
p.add_argument("--cwd")
|
|
204
|
+
p.add_argument("--env", action="append", default=[])
|
|
205
|
+
p.add_argument("--timeout", type=int)
|
|
206
|
+
p.add_argument("--no-stream", action="store_true")
|
|
207
|
+
p.add_argument("--verbose", action="store_true")
|
|
208
|
+
p.add_argument("--quiet", action="store_true")
|
|
209
|
+
p.add_argument("--raw", action="store_true")
|
|
210
|
+
p.add_argument("--stdin", action="store_true")
|
|
211
|
+
return p
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def show_help() -> None:
|
|
215
|
+
sys.stdout.write(f"""agentproc v{PKG_VERSION} (protocol {PROTOCOL_VERSION})
|
|
216
|
+
|
|
217
|
+
Usage:
|
|
218
|
+
agentproc --profile <path.yaml> --prompt "hello" [options]
|
|
219
|
+
|
|
220
|
+
Required:
|
|
221
|
+
--profile, -p <path> Profile YAML path
|
|
222
|
+
--prompt <text> User message (or use --stdin)
|
|
223
|
+
|
|
224
|
+
Session:
|
|
225
|
+
--session <id> Previous session id (multi-turn)
|
|
226
|
+
--session-name <name> Human-readable session name (default: "default")
|
|
227
|
+
--from <user> Sender identifier
|
|
228
|
+
|
|
229
|
+
Execution:
|
|
230
|
+
--cwd <path> Override profile.cwd
|
|
231
|
+
--env KEY=VALUE Extra env var (repeatable)
|
|
232
|
+
--timeout <secs> Override profile.timeout_secs
|
|
233
|
+
--no-stream Set AGENT_STREAMING=0
|
|
234
|
+
|
|
235
|
+
Output:
|
|
236
|
+
--verbose Forward protocol lines to stderr (default)
|
|
237
|
+
--quiet Suppress protocol lines on stderr
|
|
238
|
+
--raw Don't parse stdout; forward agent output verbatim
|
|
239
|
+
--stdin Read prompt from stdin instead of --prompt
|
|
240
|
+
|
|
241
|
+
Other:
|
|
242
|
+
--version Print version and exit
|
|
243
|
+
--help, -h Show this help
|
|
244
|
+
|
|
245
|
+
Output semantics:
|
|
246
|
+
stderr → protocol lines (AGENT_PARTIAL:, AGENT_SESSION:, AGENT_ERROR:)
|
|
247
|
+
stdout → final reply body (non-protocol lines)
|
|
248
|
+
exit → 0 success · 1 error · 124 timeout (per spec)
|
|
249
|
+
|
|
250
|
+
The final session id is printed on stderr as: agentproc:session:<id>
|
|
251
|
+
|
|
252
|
+
Examples:
|
|
253
|
+
agentproc --profile hub/echo-agent/profile.yaml --prompt "hi"
|
|
254
|
+
agentproc -p hub/claude-code/profile.yaml --prompt "hello" --verbose
|
|
255
|
+
cat prompt.txt | agentproc -p prof.yaml --stdin
|
|
256
|
+
""")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def show_version() -> None:
|
|
260
|
+
sys.stdout.write(f"agentproc {PKG_VERSION} (protocol {PROTOCOL_VERSION})\n")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Main
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
268
|
+
if argv is None:
|
|
269
|
+
argv = sys.argv[1:]
|
|
270
|
+
|
|
271
|
+
parser = build_parser()
|
|
272
|
+
opts = parser.parse_args(argv)
|
|
273
|
+
|
|
274
|
+
if opts.help:
|
|
275
|
+
show_help()
|
|
276
|
+
return 0
|
|
277
|
+
if opts.version:
|
|
278
|
+
show_version()
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
if not opts.profile:
|
|
282
|
+
sys.stderr.write("error: --profile is required\n\n")
|
|
283
|
+
show_help()
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
prompt = opts.prompt
|
|
287
|
+
if opts.stdin:
|
|
288
|
+
try:
|
|
289
|
+
prompt = sys.stdin.read().rstrip("\n")
|
|
290
|
+
except KeyboardInterrupt:
|
|
291
|
+
return EXIT_ERROR
|
|
292
|
+
if prompt is None:
|
|
293
|
+
sys.stderr.write("error: --prompt (or --stdin) is required\n")
|
|
294
|
+
return 2
|
|
295
|
+
|
|
296
|
+
extra_env: Dict[str, str] = {}
|
|
297
|
+
for kv in opts.env:
|
|
298
|
+
eq = kv.find("=")
|
|
299
|
+
if eq < 0:
|
|
300
|
+
sys.stderr.write(f"error: --env expects KEY=VALUE, got: {kv}\n")
|
|
301
|
+
return 2
|
|
302
|
+
extra_env[kv[:eq]] = kv[eq + 1:]
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
yaml_text = Path(opts.profile).resolve().read_text(encoding="utf-8")
|
|
306
|
+
profile_raw = parse_yaml(yaml_text)
|
|
307
|
+
except FileNotFoundError:
|
|
308
|
+
sys.stderr.write(f"error: profile not found: {opts.profile}\n")
|
|
309
|
+
return 2
|
|
310
|
+
except Exception as e:
|
|
311
|
+
sys.stderr.write(f"error: failed to parse profile {opts.profile}: {e}\n")
|
|
312
|
+
return 2
|
|
313
|
+
|
|
314
|
+
streaming = False if opts.no_stream else None
|
|
315
|
+
|
|
316
|
+
if opts.raw:
|
|
317
|
+
r = run(
|
|
318
|
+
profile_raw,
|
|
319
|
+
RunOptions(
|
|
320
|
+
message=prompt,
|
|
321
|
+
session_id=opts.session,
|
|
322
|
+
session_name=opts.session_name,
|
|
323
|
+
from_user=opts.from_user,
|
|
324
|
+
streaming=streaming,
|
|
325
|
+
cwd=opts.cwd,
|
|
326
|
+
extra_env=extra_env,
|
|
327
|
+
timeout_secs=opts.timeout,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
sys.stdout.write(r.reply)
|
|
331
|
+
if r.reply and not r.reply.endswith("\n"):
|
|
332
|
+
sys.stdout.write("\n")
|
|
333
|
+
return 0 if r.exit_code == 0 else 1
|
|
334
|
+
|
|
335
|
+
verbose = opts.verbose or not opts.quiet
|
|
336
|
+
|
|
337
|
+
r = run(
|
|
338
|
+
profile_raw,
|
|
339
|
+
RunOptions(
|
|
340
|
+
message=prompt,
|
|
341
|
+
session_id=opts.session,
|
|
342
|
+
session_name=opts.session_name,
|
|
343
|
+
from_user=opts.from_user,
|
|
344
|
+
streaming=streaming,
|
|
345
|
+
cwd=opts.cwd,
|
|
346
|
+
extra_env=extra_env,
|
|
347
|
+
timeout_secs=opts.timeout,
|
|
348
|
+
on_partial=lambda t: verbose and sys.stderr.write(
|
|
349
|
+
f"AGENT_PARTIAL:{json.dumps(t, ensure_ascii=False)}\n"
|
|
350
|
+
),
|
|
351
|
+
on_session=lambda sid: verbose and sys.stderr.write(f"AGENT_SESSION:{sid}\n"),
|
|
352
|
+
on_error=lambda msg: verbose and sys.stderr.write(
|
|
353
|
+
f"AGENT_ERROR:{json.dumps(msg, ensure_ascii=False)}\n"
|
|
354
|
+
),
|
|
355
|
+
on_stderr=lambda line: verbose and sys.stderr.write(f"[agent stderr] {line}\n"),
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if r.reply:
|
|
360
|
+
sys.stdout.write(r.reply)
|
|
361
|
+
if not r.reply.endswith("\n"):
|
|
362
|
+
sys.stdout.write("\n")
|
|
363
|
+
|
|
364
|
+
if r.session_id:
|
|
365
|
+
sys.stderr.write(f"agentproc:session:{r.session_id}\n")
|
|
366
|
+
if r.error:
|
|
367
|
+
sys.stderr.write(f"agentproc:error:{r.error}\n")
|
|
368
|
+
|
|
369
|
+
return 0 if r.exit_code == 0 else 1
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
sys.exit(main())
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""AgentProc runner — the canonical bridge-side engine.
|
|
2
|
+
|
|
3
|
+
This module is the canonical implementation of the AgentProc bridge-side
|
|
4
|
+
contract (spec/protocol.md). The CLI (cli.py) is a thin wrapper around it.
|
|
5
|
+
|
|
6
|
+
Responsibilities:
|
|
7
|
+
- Parse and validate a profile dict
|
|
8
|
+
- Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
|
|
9
|
+
- Inject AGENT_* env vars + profile env block
|
|
10
|
+
- Spawn the agent command (no shell)
|
|
11
|
+
- Read stdout line by line, classify protocol lines vs reply body
|
|
12
|
+
- Forward AGENT_PARTIAL: in real time (via on_partial callback)
|
|
13
|
+
- Capture the last AGENT_SESSION: line (last-wins rule)
|
|
14
|
+
- Honor AGENT_ERROR: lines
|
|
15
|
+
- Enforce timeout_secs with SIGTERM → kill_grace_secs → SIGKILL
|
|
16
|
+
- Write message to stdin and close (when profile.stdin == 'message')
|
|
17
|
+
- Return RunResult(reply, session_id, error, exit_code, timed_out)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"PROTOCOL_VERSION",
|
|
36
|
+
"DEFAULT_TIMEOUT_SECS",
|
|
37
|
+
"DEFAULT_KILL_GRACE_SECS",
|
|
38
|
+
"DEFAULT_MAX_REPLY_CHARS",
|
|
39
|
+
"EXIT_SUCCESS",
|
|
40
|
+
"EXIT_ERROR",
|
|
41
|
+
"EXIT_TIMEOUT",
|
|
42
|
+
"EXIT_SIGINT",
|
|
43
|
+
"EXIT_SIGTERM",
|
|
44
|
+
"RunResult",
|
|
45
|
+
"RunOptions",
|
|
46
|
+
"run",
|
|
47
|
+
"normalize_profile",
|
|
48
|
+
"classify_line",
|
|
49
|
+
"decode_json_value",
|
|
50
|
+
"substitute",
|
|
51
|
+
"expand_env_ref",
|
|
52
|
+
"expand_path",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
PROTOCOL_VERSION = "0.1"
|
|
56
|
+
|
|
57
|
+
DEFAULT_TIMEOUT_SECS = 1800
|
|
58
|
+
DEFAULT_KILL_GRACE_SECS = 5
|
|
59
|
+
DEFAULT_MAX_REPLY_CHARS = 8000
|
|
60
|
+
|
|
61
|
+
PREFIX_SESSION = "AGENT_SESSION:"
|
|
62
|
+
PREFIX_PARTIAL = "AGENT_PARTIAL:"
|
|
63
|
+
PREFIX_ERROR = "AGENT_ERROR:"
|
|
64
|
+
|
|
65
|
+
EXIT_SUCCESS = 0
|
|
66
|
+
EXIT_ERROR = 1
|
|
67
|
+
EXIT_TIMEOUT = 124
|
|
68
|
+
EXIT_SIGINT = 130
|
|
69
|
+
EXIT_SIGTERM = 143
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RunResult:
|
|
74
|
+
"""Result of running an agent process."""
|
|
75
|
+
|
|
76
|
+
reply: str = ""
|
|
77
|
+
session_id: str = ""
|
|
78
|
+
error: str = ""
|
|
79
|
+
exit_code: int = 0
|
|
80
|
+
timed_out: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RunOptions:
|
|
85
|
+
"""Options passed to run()."""
|
|
86
|
+
|
|
87
|
+
message: str
|
|
88
|
+
session_id: str = ""
|
|
89
|
+
session_name: str = "default"
|
|
90
|
+
from_user: str = ""
|
|
91
|
+
streaming: Optional[bool] = None
|
|
92
|
+
extra_env: Dict[str, str] = field(default_factory=dict)
|
|
93
|
+
cwd: Optional[str] = None
|
|
94
|
+
timeout_secs: Optional[int] = None
|
|
95
|
+
on_partial: Optional[Callable[[str], None]] = None
|
|
96
|
+
on_session: Optional[Callable[[str], None]] = None
|
|
97
|
+
on_error: Optional[Callable[[str], None]] = None
|
|
98
|
+
on_protocol_line: Optional[Callable[[str], None]] = None
|
|
99
|
+
on_stderr: Optional[Callable[[str], None]] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Profile parsing & validation
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def normalize_profile(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
107
|
+
"""Validate and normalize a profile dict."""
|
|
108
|
+
if not isinstance(raw, dict):
|
|
109
|
+
raise ValueError("profile must be a dict")
|
|
110
|
+
|
|
111
|
+
src = raw.get("agentproc") if isinstance(raw.get("agentproc"), dict) else raw
|
|
112
|
+
|
|
113
|
+
command = src.get("command")
|
|
114
|
+
if not isinstance(command, str) or not command.strip():
|
|
115
|
+
raise ValueError("profile.command must be a non-empty string")
|
|
116
|
+
|
|
117
|
+
argv = command.strip().split()
|
|
118
|
+
if not argv:
|
|
119
|
+
raise ValueError("profile.command produced empty argv")
|
|
120
|
+
|
|
121
|
+
args_value = src.get("args", [])
|
|
122
|
+
if not isinstance(args_value, list):
|
|
123
|
+
raise ValueError("profile.args must be a list")
|
|
124
|
+
|
|
125
|
+
cwd_value = src.get("cwd")
|
|
126
|
+
env_value = src.get("env") or {}
|
|
127
|
+
if not isinstance(env_value, dict):
|
|
128
|
+
raise ValueError("profile.env must be a dict")
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"argv": argv,
|
|
132
|
+
"args": [str(a) for a in args_value],
|
|
133
|
+
"cwd": expand_path(str(cwd_value)) if cwd_value else None,
|
|
134
|
+
"env": env_value,
|
|
135
|
+
"stdin": "message" if src.get("stdin") == "message" else "none",
|
|
136
|
+
"timeout_secs": (
|
|
137
|
+
int(src["timeout_secs"]) if _is_int_like(src.get("timeout_secs"))
|
|
138
|
+
else DEFAULT_TIMEOUT_SECS
|
|
139
|
+
),
|
|
140
|
+
"kill_grace_secs": (
|
|
141
|
+
int(src["kill_grace_secs"]) if _is_int_like(src.get("kill_grace_secs"))
|
|
142
|
+
else DEFAULT_KILL_GRACE_SECS
|
|
143
|
+
),
|
|
144
|
+
"max_reply_chars": (
|
|
145
|
+
int(src["max_reply_chars"]) if _is_int_like(src.get("max_reply_chars"))
|
|
146
|
+
else DEFAULT_MAX_REPLY_CHARS
|
|
147
|
+
),
|
|
148
|
+
"streaming": src.get("streaming", True) is not False,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_int_like(v: Any) -> bool:
|
|
153
|
+
if isinstance(v, bool):
|
|
154
|
+
return False
|
|
155
|
+
if isinstance(v, int):
|
|
156
|
+
return True
|
|
157
|
+
if isinstance(v, str) and v.strip().lstrip("-").isdigit():
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def expand_path(p: str) -> str:
|
|
163
|
+
if p == "~":
|
|
164
|
+
return str(Path.home())
|
|
165
|
+
if p.startswith("~/"):
|
|
166
|
+
return str(Path.home() / p[2:])
|
|
167
|
+
return p
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def substitute(value: str, ctx: Dict[str, str]) -> str:
|
|
171
|
+
return (
|
|
172
|
+
str(value)
|
|
173
|
+
.replace("{{MESSAGE}}", ctx.get("message", ""))
|
|
174
|
+
.replace("{{SESSION_ID}}", ctx.get("session_id", ""))
|
|
175
|
+
.replace("{{SESSION_NAME}}", ctx.get("session_name", ""))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def expand_env_ref(value: str, env: Dict[str, str]) -> str:
|
|
180
|
+
return re.sub(
|
|
181
|
+
r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}",
|
|
182
|
+
lambda m: env.get(m.group(1), ""),
|
|
183
|
+
str(value),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Line classification (per spec)
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def decode_json_value(raw: str) -> str:
|
|
192
|
+
text = raw.strip()
|
|
193
|
+
if not text:
|
|
194
|
+
return ""
|
|
195
|
+
try:
|
|
196
|
+
v = json.loads(text)
|
|
197
|
+
return v if isinstance(v, str) else str(v)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def classify_line(line: str) -> Dict[str, str]:
|
|
203
|
+
if line.startswith(PREFIX_SESSION):
|
|
204
|
+
return {"kind": "session", "value": line[len(PREFIX_SESSION):].strip()}
|
|
205
|
+
if line.startswith(PREFIX_PARTIAL):
|
|
206
|
+
return {"kind": "partial", "value": decode_json_value(line[len(PREFIX_PARTIAL):])}
|
|
207
|
+
if line.startswith(PREFIX_ERROR):
|
|
208
|
+
return {"kind": "error", "value": decode_json_value(line[len(PREFIX_ERROR):])}
|
|
209
|
+
return {"kind": "body", "value": line}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# run() — the main entry point
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
|
|
217
|
+
"""Run an agent process per the AgentProc spec."""
|
|
218
|
+
profile = normalize_profile(profile_raw)
|
|
219
|
+
|
|
220
|
+
streaming = (
|
|
221
|
+
options.streaming if options.streaming is not None else profile["streaming"]
|
|
222
|
+
)
|
|
223
|
+
timeout_secs = (
|
|
224
|
+
options.timeout_secs if options.timeout_secs is not None else profile["timeout_secs"]
|
|
225
|
+
)
|
|
226
|
+
cwd = options.cwd or profile["cwd"]
|
|
227
|
+
|
|
228
|
+
subst_ctx = {
|
|
229
|
+
"message": options.message,
|
|
230
|
+
"session_id": options.session_id,
|
|
231
|
+
"session_name": options.session_name,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
argv = list(profile["argv"])
|
|
235
|
+
for a in profile["args"]:
|
|
236
|
+
argv.append(substitute(a, subst_ctx))
|
|
237
|
+
|
|
238
|
+
env = dict(os.environ)
|
|
239
|
+
for k, v in profile["env"].items():
|
|
240
|
+
env[k] = expand_env_ref(substitute(str(v), subst_ctx), os.environ)
|
|
241
|
+
for k, v in options.extra_env.items():
|
|
242
|
+
env[k] = str(v)
|
|
243
|
+
|
|
244
|
+
env["AGENT_MESSAGE"] = options.message
|
|
245
|
+
env["AGENT_SESSION_ID"] = options.session_id
|
|
246
|
+
env["AGENT_SESSION_NAME"] = options.session_name
|
|
247
|
+
env["AGENT_FROM_USER"] = options.from_user
|
|
248
|
+
env["AGENT_STREAMING"] = "1" if streaming else "0"
|
|
249
|
+
env["AGENT_PROTOCOL_VERSION"] = PROTOCOL_VERSION
|
|
250
|
+
|
|
251
|
+
stdin_payload = options.message if profile["stdin"] == "message" else None
|
|
252
|
+
|
|
253
|
+
result = RunResult()
|
|
254
|
+
body_lines: List[str] = []
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
proc = subprocess.Popen(
|
|
258
|
+
argv,
|
|
259
|
+
cwd=cwd,
|
|
260
|
+
env=env,
|
|
261
|
+
stdin=subprocess.PIPE if stdin_payload is not None else subprocess.DEVNULL,
|
|
262
|
+
stdout=subprocess.PIPE,
|
|
263
|
+
stderr=subprocess.PIPE,
|
|
264
|
+
text=True,
|
|
265
|
+
bufsize=1,
|
|
266
|
+
)
|
|
267
|
+
except FileNotFoundError as e:
|
|
268
|
+
if options.on_stderr:
|
|
269
|
+
options.on_stderr(f"[agentproc runner] spawn error: {e}")
|
|
270
|
+
result.exit_code = EXIT_ERROR
|
|
271
|
+
return result
|
|
272
|
+
except PermissionError as e:
|
|
273
|
+
if options.on_stderr:
|
|
274
|
+
options.on_stderr(f"[agentproc runner] spawn error: {e}")
|
|
275
|
+
result.exit_code = EXIT_ERROR
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
if stdin_payload is not None:
|
|
279
|
+
try:
|
|
280
|
+
proc.stdin.write(stdin_payload)
|
|
281
|
+
proc.stdin.close()
|
|
282
|
+
except BrokenPipeError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
def _drain_stderr() -> None:
|
|
286
|
+
assert proc.stderr is not None
|
|
287
|
+
for line in proc.stderr:
|
|
288
|
+
line = line.rstrip("\r\n")
|
|
289
|
+
if options.on_stderr:
|
|
290
|
+
options.on_stderr(line)
|
|
291
|
+
|
|
292
|
+
stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
|
|
293
|
+
stderr_thread.start()
|
|
294
|
+
|
|
295
|
+
assert proc.stdout is not None
|
|
296
|
+
|
|
297
|
+
def _handle_line(raw_line: str) -> None:
|
|
298
|
+
line = raw_line.rstrip("\r")
|
|
299
|
+
c = classify_line(line)
|
|
300
|
+
if c["kind"] == "session":
|
|
301
|
+
result.session_id = c["value"]
|
|
302
|
+
if options.on_session:
|
|
303
|
+
options.on_session(c["value"])
|
|
304
|
+
if options.on_protocol_line:
|
|
305
|
+
options.on_protocol_line(line)
|
|
306
|
+
elif c["kind"] == "partial":
|
|
307
|
+
if streaming and options.on_partial:
|
|
308
|
+
options.on_partial(c["value"])
|
|
309
|
+
if options.on_protocol_line:
|
|
310
|
+
options.on_protocol_line(line)
|
|
311
|
+
elif c["kind"] == "error":
|
|
312
|
+
result.error = c["value"]
|
|
313
|
+
if options.on_error:
|
|
314
|
+
options.on_error(c["value"])
|
|
315
|
+
if options.on_protocol_line:
|
|
316
|
+
options.on_protocol_line(line)
|
|
317
|
+
else:
|
|
318
|
+
body_lines.append(line)
|
|
319
|
+
|
|
320
|
+
def _drain_stdout() -> None:
|
|
321
|
+
assert proc.stdout is not None
|
|
322
|
+
for raw_line in proc.stdout:
|
|
323
|
+
_handle_line(raw_line.rstrip("\n"))
|
|
324
|
+
|
|
325
|
+
stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
|
|
326
|
+
stdout_thread.start()
|
|
327
|
+
|
|
328
|
+
exit_code: int
|
|
329
|
+
timed_out = False
|
|
330
|
+
try:
|
|
331
|
+
if timeout_secs and timeout_secs > 0:
|
|
332
|
+
deadline = time.monotonic() + timeout_secs
|
|
333
|
+
while True:
|
|
334
|
+
try:
|
|
335
|
+
exit_code = proc.wait(timeout=0.5)
|
|
336
|
+
break
|
|
337
|
+
except subprocess.TimeoutExpired:
|
|
338
|
+
if time.monotonic() >= deadline:
|
|
339
|
+
timed_out = True
|
|
340
|
+
try:
|
|
341
|
+
proc.send_signal(signal.SIGTERM)
|
|
342
|
+
except (ProcessLookupError, PermissionError):
|
|
343
|
+
pass
|
|
344
|
+
try:
|
|
345
|
+
proc.wait(timeout=profile["kill_grace_secs"])
|
|
346
|
+
except subprocess.TimeoutExpired:
|
|
347
|
+
try:
|
|
348
|
+
proc.send_signal(signal.SIGKILL)
|
|
349
|
+
except (ProcessLookupError, PermissionError):
|
|
350
|
+
pass
|
|
351
|
+
try:
|
|
352
|
+
exit_code = proc.wait(timeout=2)
|
|
353
|
+
except subprocess.TimeoutExpired:
|
|
354
|
+
exit_code = EXIT_TIMEOUT
|
|
355
|
+
break
|
|
356
|
+
else:
|
|
357
|
+
exit_code = proc.wait()
|
|
358
|
+
except KeyboardInterrupt:
|
|
359
|
+
try:
|
|
360
|
+
proc.send_signal(signal.SIGINT)
|
|
361
|
+
except (ProcessLookupError, PermissionError):
|
|
362
|
+
pass
|
|
363
|
+
exit_code = proc.wait()
|
|
364
|
+
|
|
365
|
+
stdout_thread.join(timeout=5)
|
|
366
|
+
stderr_thread.join(timeout=2)
|
|
367
|
+
|
|
368
|
+
result.reply = "\n".join(body_lines)
|
|
369
|
+
if len(result.reply) > profile["max_reply_chars"]:
|
|
370
|
+
suffix = "\n\n…(truncated)" if profile["max_reply_chars"] == DEFAULT_MAX_REPLY_CHARS else ""
|
|
371
|
+
result.reply = result.reply[: profile["max_reply_chars"]] + suffix
|
|
372
|
+
|
|
373
|
+
if timed_out:
|
|
374
|
+
result.timed_out = True
|
|
375
|
+
result.exit_code = EXIT_TIMEOUT
|
|
376
|
+
elif result.error:
|
|
377
|
+
result.exit_code = EXIT_ERROR if exit_code == 0 else exit_code
|
|
378
|
+
else:
|
|
379
|
+
result.exit_code = exit_code
|
|
380
|
+
|
|
381
|
+
return result
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentproc
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: AgentProc SDK — connect any Agent CLI to a messaging platform
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffkit/agentproc
|
|
7
|
-
Project-URL: Documentation, https://
|
|
7
|
+
Project-URL: Documentation, https://agentproc.dev/
|
|
8
8
|
Project-URL: Repository, https://github.com/jeffkit/agentproc
|
|
9
9
|
Project-URL: Issues, https://github.com/jeffkit/agentproc/issues
|
|
10
10
|
Requires-Python: >=3.9
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
2
|
src/agentproc/__init__.py
|
|
3
|
+
src/agentproc/cli.py
|
|
4
|
+
src/agentproc/runner.py
|
|
3
5
|
src/agentproc.egg-info/PKG-INFO
|
|
4
6
|
src/agentproc.egg-info/SOURCES.txt
|
|
5
7
|
src/agentproc.egg-info/dependency_links.txt
|
|
8
|
+
src/agentproc.egg-info/entry_points.txt
|
|
6
9
|
src/agentproc.egg-info/top_level.txt
|
|
7
|
-
tests/test_agentproc.py
|
|
10
|
+
tests/test_agentproc.py
|
|
11
|
+
tests/test_runner.py
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Tests for agentproc.runner — the canonical bridge-side implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from agentproc.runner import (
|
|
15
|
+
PROTOCOL_VERSION,
|
|
16
|
+
RunOptions,
|
|
17
|
+
classify_line,
|
|
18
|
+
decode_json_value,
|
|
19
|
+
expand_env_ref,
|
|
20
|
+
expand_path,
|
|
21
|
+
normalize_profile,
|
|
22
|
+
run,
|
|
23
|
+
substitute,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# 1. Pure-function tests
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
class TestClassifyLine:
|
|
32
|
+
def test_session_line(self):
|
|
33
|
+
assert classify_line("AGENT_SESSION:abc-123") == {"kind": "session", "value": "abc-123"}
|
|
34
|
+
|
|
35
|
+
def test_session_strips_whitespace(self):
|
|
36
|
+
assert classify_line("AGENT_SESSION: abc-123 ") == {"kind": "session", "value": "abc-123"}
|
|
37
|
+
|
|
38
|
+
def test_partial_json_string(self):
|
|
39
|
+
assert classify_line('AGENT_PARTIAL:"hello"') == {"kind": "partial", "value": "hello"}
|
|
40
|
+
|
|
41
|
+
def test_partial_with_newline_in_json(self):
|
|
42
|
+
assert classify_line('AGENT_PARTIAL:"line1\\nline2"') == {"kind": "partial", "value": "line1\nline2"}
|
|
43
|
+
|
|
44
|
+
def test_partial_lenient_on_bad_json(self):
|
|
45
|
+
assert classify_line("AGENT_PARTIAL:not json") == {"kind": "partial", "value": "not json"}
|
|
46
|
+
|
|
47
|
+
def test_partial_empty_value(self):
|
|
48
|
+
assert classify_line("AGENT_PARTIAL:") == {"kind": "partial", "value": ""}
|
|
49
|
+
|
|
50
|
+
def test_error_line(self):
|
|
51
|
+
assert classify_line('AGENT_ERROR:"rate limited"') == {"kind": "error", "value": "rate limited"}
|
|
52
|
+
|
|
53
|
+
def test_error_lenient_on_bad_json(self):
|
|
54
|
+
assert classify_line("AGENT_ERROR:boom") == {"kind": "error", "value": "boom"}
|
|
55
|
+
|
|
56
|
+
def test_body_line(self):
|
|
57
|
+
assert classify_line("hello world") == {"kind": "body", "value": "hello world"}
|
|
58
|
+
|
|
59
|
+
def test_line_starting_with_space_is_not_protocol(self):
|
|
60
|
+
assert classify_line(" AGENT_SESSION:foo") == {"kind": "body", "value": " AGENT_SESSION:foo"}
|
|
61
|
+
|
|
62
|
+
def test_prefix_like_but_not_exact(self):
|
|
63
|
+
assert classify_line("AGENT_SESSION") == {"kind": "body", "value": "AGENT_SESSION"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestDecodeJsonValue:
|
|
67
|
+
def test_json_string(self):
|
|
68
|
+
assert decode_json_value('"hi"') == "hi"
|
|
69
|
+
|
|
70
|
+
def test_json_string_with_newline(self):
|
|
71
|
+
assert decode_json_value('"a\\nb"') == "a\nb"
|
|
72
|
+
|
|
73
|
+
def test_empty(self):
|
|
74
|
+
assert decode_json_value("") == ""
|
|
75
|
+
|
|
76
|
+
def test_non_json_returns_trimmed_raw(self):
|
|
77
|
+
assert decode_json_value(" not json ") == "not json"
|
|
78
|
+
|
|
79
|
+
def test_json_number(self):
|
|
80
|
+
assert decode_json_value("42") == "42"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestSubstitute:
|
|
84
|
+
def test_message(self):
|
|
85
|
+
assert substitute("You said: {{MESSAGE}}", {"message": "hi"}) == "You said: hi"
|
|
86
|
+
|
|
87
|
+
def test_session_id(self):
|
|
88
|
+
assert substitute("s={{SESSION_ID}}", {"session_id": "abc"}) == "s=abc"
|
|
89
|
+
|
|
90
|
+
def test_session_name(self):
|
|
91
|
+
assert substitute("n={{SESSION_NAME}}", {"session_name": "work"}) == "n=work"
|
|
92
|
+
|
|
93
|
+
def test_empty_session_id(self):
|
|
94
|
+
assert substitute("s={{SESSION_ID}}", {"session_id": ""}) == "s="
|
|
95
|
+
|
|
96
|
+
def test_multiple_placeholders(self):
|
|
97
|
+
out = substitute(
|
|
98
|
+
"{{MESSAGE}} [{{SESSION_ID}}] ({{SESSION_NAME}})",
|
|
99
|
+
{"message": "hi", "session_id": "s1", "session_name": "work"},
|
|
100
|
+
)
|
|
101
|
+
assert out == "hi [s1] (work)"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestExpandEnvRef:
|
|
105
|
+
def test_known_var(self):
|
|
106
|
+
assert expand_env_ref("${HOME}", {"HOME": "/u/x"}) == "/u/x"
|
|
107
|
+
|
|
108
|
+
def test_unknown_var_becomes_empty(self):
|
|
109
|
+
assert expand_env_ref("${NOPE}", {}) == ""
|
|
110
|
+
|
|
111
|
+
def test_no_refs(self):
|
|
112
|
+
assert expand_env_ref("plain value", {}) == "plain value"
|
|
113
|
+
|
|
114
|
+
def test_mixed(self):
|
|
115
|
+
assert expand_env_ref("key=${HOME} and ${missing}", {"HOME": "/h"}) == "key=/h and "
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestExpandPath:
|
|
119
|
+
def test_tilde_alone(self, monkeypatch, tmp_path):
|
|
120
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
121
|
+
assert expand_path("~") == str(tmp_path)
|
|
122
|
+
|
|
123
|
+
def test_tilde_slash(self, monkeypatch, tmp_path):
|
|
124
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
125
|
+
assert expand_path("~/foo") == str(tmp_path / "foo")
|
|
126
|
+
|
|
127
|
+
def test_absolute_unchanged(self):
|
|
128
|
+
assert expand_path("/usr/bin") == "/usr/bin"
|
|
129
|
+
|
|
130
|
+
def test_relative_unchanged(self):
|
|
131
|
+
assert expand_path("./foo") == "./foo"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestNormalizeProfile:
|
|
135
|
+
def test_minimal_valid(self):
|
|
136
|
+
p = normalize_profile({"command": "bash ./x.sh"})
|
|
137
|
+
assert p["argv"] == ["bash", "./x.sh"]
|
|
138
|
+
assert p["stdin"] == "none"
|
|
139
|
+
assert p["streaming"] is True
|
|
140
|
+
|
|
141
|
+
def test_hub_form(self):
|
|
142
|
+
p = normalize_profile({"agentproc": {"command": "node ./x.js"}})
|
|
143
|
+
assert p["argv"] == ["node", "./x.js"]
|
|
144
|
+
|
|
145
|
+
def test_rejects_missing_command(self):
|
|
146
|
+
with pytest.raises(ValueError, match="command must be a non-empty string"):
|
|
147
|
+
normalize_profile({})
|
|
148
|
+
|
|
149
|
+
def test_rejects_empty_command(self):
|
|
150
|
+
with pytest.raises(ValueError, match="command must be a non-empty string"):
|
|
151
|
+
normalize_profile({"command": " "})
|
|
152
|
+
|
|
153
|
+
def test_rejects_non_dict(self):
|
|
154
|
+
with pytest.raises(ValueError, match="must be a dict"):
|
|
155
|
+
normalize_profile(None) # type: ignore
|
|
156
|
+
|
|
157
|
+
def test_argv_splits_on_whitespace(self):
|
|
158
|
+
p = normalize_profile({"command": "bash ./spaced.sh"})
|
|
159
|
+
assert p["argv"] == ["bash", "./spaced.sh"]
|
|
160
|
+
|
|
161
|
+
def test_args_default_empty(self):
|
|
162
|
+
p = normalize_profile({"command": "x"})
|
|
163
|
+
assert p["args"] == []
|
|
164
|
+
|
|
165
|
+
def test_args_cast_to_str(self):
|
|
166
|
+
p = normalize_profile({"command": "x", "args": ["--foo", 42]})
|
|
167
|
+
assert p["args"] == ["--foo", "42"]
|
|
168
|
+
|
|
169
|
+
def test_cwd_tilde_expanded(self, monkeypatch, tmp_path):
|
|
170
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
171
|
+
p = normalize_profile({"command": "x", "cwd": "~/proj"})
|
|
172
|
+
assert p["cwd"] == str(tmp_path / "proj")
|
|
173
|
+
|
|
174
|
+
def test_stdin_message_vs_other(self):
|
|
175
|
+
assert normalize_profile({"command": "x", "stdin": "message"})["stdin"] == "message"
|
|
176
|
+
assert normalize_profile({"command": "x", "stdin": "none"})["stdin"] == "none"
|
|
177
|
+
assert normalize_profile({"command": "x", "stdin": "bogus"})["stdin"] == "none"
|
|
178
|
+
assert normalize_profile({"command": "x"})["stdin"] == "none"
|
|
179
|
+
|
|
180
|
+
def test_streaming_false_honored(self):
|
|
181
|
+
assert normalize_profile({"command": "x", "streaming": False})["streaming"] is False
|
|
182
|
+
assert normalize_profile({"command": "x", "streaming": True})["streaming"] is True
|
|
183
|
+
assert normalize_profile({"command": "x"})["streaming"] is True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_protocol_version_is_0_1():
|
|
187
|
+
assert PROTOCOL_VERSION == "0.1"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# 2. run() end-to-end tests with tiny agent scripts
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def write_script(content: str, tmp_path: Path) -> Path:
|
|
195
|
+
f = tmp_path / "agent.sh"
|
|
196
|
+
f.write_text(content)
|
|
197
|
+
f.chmod(f.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
198
|
+
return f
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.fixture
|
|
202
|
+
def agent_script(tmp_path):
|
|
203
|
+
def _make(content: str) -> Path:
|
|
204
|
+
return write_script(content, tmp_path)
|
|
205
|
+
return _make
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestRunEndToEnd:
|
|
209
|
+
def test_simple_reply_body(self, agent_script):
|
|
210
|
+
agent = agent_script("#!/usr/bin/env bash\necho 'hello'\n")
|
|
211
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
212
|
+
assert r.reply.strip() == "hello"
|
|
213
|
+
assert r.session_id == ""
|
|
214
|
+
assert r.error == ""
|
|
215
|
+
assert r.exit_code == 0
|
|
216
|
+
|
|
217
|
+
def test_session_last_wins(self, agent_script):
|
|
218
|
+
agent = agent_script(textwrap.dedent("""\
|
|
219
|
+
#!/usr/bin/env bash
|
|
220
|
+
echo "AGENT_SESSION:first"
|
|
221
|
+
echo "AGENT_SESSION:second"
|
|
222
|
+
echo "done"
|
|
223
|
+
"""))
|
|
224
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
225
|
+
assert r.session_id == "second"
|
|
226
|
+
assert r.reply.strip() == "done"
|
|
227
|
+
|
|
228
|
+
def test_partial_triggers_callback(self, agent_script):
|
|
229
|
+
agent = agent_script(textwrap.dedent("""\
|
|
230
|
+
#!/usr/bin/env bash
|
|
231
|
+
echo 'AGENT_PARTIAL:"chunk1"'
|
|
232
|
+
echo 'AGENT_PARTIAL:"chunk2"'
|
|
233
|
+
echo "final"
|
|
234
|
+
"""))
|
|
235
|
+
partials: List[str] = []
|
|
236
|
+
r = run(
|
|
237
|
+
{"command": str(agent)},
|
|
238
|
+
RunOptions(message="hi", on_partial=partials.append),
|
|
239
|
+
)
|
|
240
|
+
assert partials == ["chunk1", "chunk2"]
|
|
241
|
+
assert r.reply.strip() == "final"
|
|
242
|
+
|
|
243
|
+
def test_partial_skipped_when_streaming_false(self, agent_script):
|
|
244
|
+
agent = agent_script(textwrap.dedent("""\
|
|
245
|
+
#!/usr/bin/env bash
|
|
246
|
+
echo 'AGENT_PARTIAL:"chunk1"'
|
|
247
|
+
echo "final"
|
|
248
|
+
"""))
|
|
249
|
+
partials: List[str] = []
|
|
250
|
+
r = run(
|
|
251
|
+
{"command": str(agent)},
|
|
252
|
+
RunOptions(message="hi", streaming=False, on_partial=partials.append),
|
|
253
|
+
)
|
|
254
|
+
assert partials == []
|
|
255
|
+
assert r.reply.strip() == "final"
|
|
256
|
+
|
|
257
|
+
def test_error_surfaces(self, agent_script):
|
|
258
|
+
agent = agent_script(textwrap.dedent("""\
|
|
259
|
+
#!/usr/bin/env bash
|
|
260
|
+
echo 'AGENT_PARTIAL:"thinking..."'
|
|
261
|
+
echo 'AGENT_ERROR:"rate limited"'
|
|
262
|
+
exit 1
|
|
263
|
+
"""))
|
|
264
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
265
|
+
assert r.error == "rate limited"
|
|
266
|
+
assert r.exit_code == 1
|
|
267
|
+
|
|
268
|
+
def test_error_marks_exit_1_even_if_process_exits_0(self, agent_script):
|
|
269
|
+
agent = agent_script(textwrap.dedent("""\
|
|
270
|
+
#!/usr/bin/env bash
|
|
271
|
+
echo 'AGENT_ERROR:"soft fail"'
|
|
272
|
+
exit 0
|
|
273
|
+
"""))
|
|
274
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
275
|
+
assert r.error == "soft fail"
|
|
276
|
+
assert r.exit_code == 1
|
|
277
|
+
|
|
278
|
+
def test_body_lines_with_leading_space_not_treated_as_protocol(self, agent_script):
|
|
279
|
+
agent = agent_script(textwrap.dedent("""\
|
|
280
|
+
#!/usr/bin/env bash
|
|
281
|
+
echo " AGENT_SESSION:foo"
|
|
282
|
+
echo "real reply"
|
|
283
|
+
"""))
|
|
284
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
285
|
+
assert r.session_id == ""
|
|
286
|
+
assert "\n" in r.reply
|
|
287
|
+
|
|
288
|
+
def test_exit_code_propagates(self, agent_script):
|
|
289
|
+
agent = agent_script("#!/usr/bin/env bash\nexit 3\n")
|
|
290
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
291
|
+
assert r.exit_code == 3
|
|
292
|
+
|
|
293
|
+
def test_message_injected_as_env(self, agent_script):
|
|
294
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"got: $AGENT_MESSAGE\"\n")
|
|
295
|
+
r = run({"command": str(agent)}, RunOptions(message="payload"))
|
|
296
|
+
assert r.reply.strip() == "got: payload"
|
|
297
|
+
|
|
298
|
+
def test_session_id_injected_from_options(self, agent_script):
|
|
299
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"prev: $AGENT_SESSION_ID\"\n")
|
|
300
|
+
r = run({"command": str(agent)}, RunOptions(message="hi", session_id="prev-123"))
|
|
301
|
+
assert r.reply.strip() == "prev: prev-123"
|
|
302
|
+
|
|
303
|
+
def test_protocol_version_injected(self, agent_script):
|
|
304
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"pv=$AGENT_PROTOCOL_VERSION\"\n")
|
|
305
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
306
|
+
assert r.reply.strip() == f"pv={PROTOCOL_VERSION}"
|
|
307
|
+
|
|
308
|
+
def test_streaming_reflects_option(self, agent_script):
|
|
309
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"stream=$AGENT_STREAMING\"\n")
|
|
310
|
+
r1 = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
311
|
+
assert r1.reply.strip() == "stream=1"
|
|
312
|
+
r2 = run({"command": str(agent)}, RunOptions(message="hi", streaming=False))
|
|
313
|
+
assert r2.reply.strip() == "stream=0"
|
|
314
|
+
|
|
315
|
+
def test_profile_env_with_var_ref(self, agent_script, monkeypatch):
|
|
316
|
+
monkeypatch.setenv("MY_TEST_VAR", "/some/path")
|
|
317
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"v=$MY_KEY\"\n")
|
|
318
|
+
r = run(
|
|
319
|
+
{"command": str(agent), "env": {"MY_KEY": "${MY_TEST_VAR}"}},
|
|
320
|
+
RunOptions(message="hi"),
|
|
321
|
+
)
|
|
322
|
+
assert r.reply.strip() == "v=/some/path"
|
|
323
|
+
|
|
324
|
+
def test_message_placeholder_in_args(self, agent_script):
|
|
325
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"args: $1\"\n")
|
|
326
|
+
r = run(
|
|
327
|
+
{"command": str(agent), "args": ["{{MESSAGE}}"]},
|
|
328
|
+
RunOptions(message="hello"),
|
|
329
|
+
)
|
|
330
|
+
assert r.reply.strip() == "args: hello"
|
|
331
|
+
|
|
332
|
+
def test_extra_env_applied(self, agent_script):
|
|
333
|
+
agent = agent_script("#!/usr/bin/env bash\necho \"x=$X\"\n")
|
|
334
|
+
r = run(
|
|
335
|
+
{"command": str(agent)},
|
|
336
|
+
RunOptions(message="hi", extra_env={"X": "extra"}),
|
|
337
|
+
)
|
|
338
|
+
assert r.reply.strip() == "x=extra"
|
|
339
|
+
|
|
340
|
+
def test_stdin_message_written_and_eof(self, tmp_path):
|
|
341
|
+
agent = tmp_path / "agent.sh"
|
|
342
|
+
agent.write_text("#!/usr/bin/env bash\nread line\necho \"stdin: $line\"\n")
|
|
343
|
+
agent.chmod(agent.stat().st_mode | 0o111)
|
|
344
|
+
r = run(
|
|
345
|
+
{"command": str(agent), "stdin": "message"},
|
|
346
|
+
RunOptions(message="via-stdin"),
|
|
347
|
+
)
|
|
348
|
+
assert r.reply.strip() == "stdin: via-stdin"
|
|
349
|
+
|
|
350
|
+
def test_timeout_kills_long_agent(self, agent_script):
|
|
351
|
+
agent = agent_script("#!/usr/bin/env bash\nsleep 30\necho 'should not reach'\n")
|
|
352
|
+
r = run(
|
|
353
|
+
{"command": str(agent), "kill_grace_secs": 1},
|
|
354
|
+
RunOptions(message="hi", timeout_secs=1),
|
|
355
|
+
)
|
|
356
|
+
assert r.timed_out is True
|
|
357
|
+
assert r.exit_code == 124
|
|
358
|
+
|
|
359
|
+
def test_multiline_reply_preserves_newlines(self, agent_script):
|
|
360
|
+
agent = agent_script(textwrap.dedent("""\
|
|
361
|
+
#!/usr/bin/env bash
|
|
362
|
+
echo "line 1"
|
|
363
|
+
echo "line 2"
|
|
364
|
+
echo "line 3"
|
|
365
|
+
"""))
|
|
366
|
+
r = run({"command": str(agent)}, RunOptions(message="hi"))
|
|
367
|
+
assert r.reply == "line 1\nline 2\nline 3"
|
|
368
|
+
|
|
369
|
+
def test_spawn_error_command_not_found(self):
|
|
370
|
+
r = run(
|
|
371
|
+
{"command": "/nonexistent/command/xyz"},
|
|
372
|
+
RunOptions(message="hi"),
|
|
373
|
+
)
|
|
374
|
+
assert r.exit_code == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|