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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.1.1
4
- Summary: AgentProc SDK — connect any Agent CLI to a messaging platform with a single function
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://jeffkit.github.io/agentproc/
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.1.1"
8
- description = "AgentProc SDK — connect any Agent CLI to a messaging platform with a single function"
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://jeffkit.github.io/agentproc/"
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.1.1
4
- Summary: AgentProc SDK — connect any Agent CLI to a messaging platform with a single function
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://jeffkit.github.io/agentproc/
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,2 @@
1
+ [console_scripts]
2
+ agentproc = agentproc.cli:main
@@ -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