agentproc 0.1.0__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.0 → agentproc-0.2.1}/PKG-INFO +3 -3
- {agentproc-0.1.0 → agentproc-0.2.1}/pyproject.toml +6 -3
- {agentproc-0.1.0 → agentproc-0.2.1}/src/agentproc/__init__.py +138 -35
- agentproc-0.2.1/src/agentproc/cli.py +373 -0
- agentproc-0.2.1/src/agentproc/runner.py +381 -0
- {agentproc-0.1.0 → agentproc-0.2.1}/src/agentproc.egg-info/PKG-INFO +3 -3
- agentproc-0.2.1/src/agentproc.egg-info/SOURCES.txt +11 -0
- agentproc-0.2.1/src/agentproc.egg-info/entry_points.txt +2 -0
- agentproc-0.2.1/tests/test_agentproc.py +340 -0
- agentproc-0.2.1/tests/test_runner.py +374 -0
- agentproc-0.1.0/src/agentproc.egg-info/SOURCES.txt +0 -6
- {agentproc-0.1.0 → agentproc-0.2.1}/setup.cfg +0 -0
- {agentproc-0.1.0 → agentproc-0.2.1}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.1.0 → agentproc-0.2.1}/src/agentproc.egg-info/top_level.txt +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentproc
|
|
3
|
-
Version: 0.1
|
|
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://agentproc.dev
|
|
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
|
|
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://agentproc.dev"
|
|
19
|
+
Documentation = "https://agentproc.dev/"
|
|
17
20
|
Repository = "https://github.com/jeffkit/agentproc"
|
|
18
21
|
Issues = "https://github.com/jeffkit/agentproc/issues"
|
|
19
22
|
|
|
@@ -4,14 +4,16 @@ agentproc — AgentProc Protocol SDK (Python)
|
|
|
4
4
|
Implements the AgentProc P0 protocol so you can write a single async handler
|
|
5
5
|
instead of manually reading env vars and formatting stdout.
|
|
6
6
|
|
|
7
|
-
Protocol contract:
|
|
7
|
+
Protocol contract (spec/protocol.md, v0.1.0):
|
|
8
8
|
Input — env vars: AGENT_MESSAGE, AGENT_SESSION_ID, AGENT_SESSION_NAME,
|
|
9
|
-
AGENT_FROM_USER, AGENT_STREAMING
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
AGENT_FROM_USER, AGENT_STREAMING, AGENT_PROTOCOL_VERSION,
|
|
10
|
+
AGENT_IMAGE_URL, AGENT_FILE_URL, AGENT_ATTACHMENTS (draft)
|
|
11
|
+
Output — stdout (sentinel-prefixed lines):
|
|
12
|
+
AGENT_SESSION:<opaque-id> — declare session id (last wins)
|
|
13
|
+
AGENT_PARTIAL:<json-string> — streaming chunk
|
|
14
|
+
AGENT_ERROR:<json-string> — error message to forward to user
|
|
15
|
+
everything else = final reply body
|
|
16
|
+
Exit — 0 success, 1 error, 124 timeout, 130 SIGINT, 143 SIGTERM
|
|
15
17
|
|
|
16
18
|
Example::
|
|
17
19
|
|
|
@@ -33,21 +35,43 @@ import sys
|
|
|
33
35
|
from dataclasses import dataclass, field
|
|
34
36
|
from datetime import datetime, timezone
|
|
35
37
|
from pathlib import Path
|
|
36
|
-
from typing import Awaitable, Callable, List, Optional, Union
|
|
38
|
+
from typing import Awaitable, Callable, List, Optional, Sequence, Union
|
|
37
39
|
|
|
38
40
|
__all__ = [
|
|
39
41
|
"AgentContext",
|
|
40
42
|
"AgentResult",
|
|
43
|
+
"Attachment",
|
|
44
|
+
"HistoryEntry",
|
|
45
|
+
"ProtocolError",
|
|
41
46
|
"create_profile",
|
|
42
47
|
"load_history",
|
|
43
48
|
"append_history",
|
|
44
|
-
"
|
|
49
|
+
"session_file_path",
|
|
45
50
|
]
|
|
46
51
|
|
|
52
|
+
PROTOCOL_VERSION = "0.1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProtocolError(Exception):
|
|
56
|
+
"""Raised by the handler to signal an error that should reach the user.
|
|
57
|
+
|
|
58
|
+
The SDK emits an ``AGENT_ERROR:`` line with the message and exits non-zero.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Attachment:
|
|
64
|
+
"""A single attachment on the user message (draft, multi-attachment)."""
|
|
65
|
+
|
|
66
|
+
type: str
|
|
67
|
+
"""Kind of attachment: ``image`` | ``file`` | ``audio`` | ``video``."""
|
|
68
|
+
|
|
69
|
+
url: str
|
|
70
|
+
"""URL the bridge has made available for fetching the attachment."""
|
|
71
|
+
|
|
72
|
+
name: str = ""
|
|
73
|
+
"""Optional filename or display name."""
|
|
47
74
|
|
|
48
|
-
# ---------------------------------------------------------------------------
|
|
49
|
-
# Data classes
|
|
50
|
-
# ---------------------------------------------------------------------------
|
|
51
75
|
|
|
52
76
|
@dataclass
|
|
53
77
|
class AgentContext:
|
|
@@ -57,7 +81,7 @@ class AgentContext:
|
|
|
57
81
|
"""User message text (AGENT_MESSAGE)."""
|
|
58
82
|
|
|
59
83
|
session_id: str
|
|
60
|
-
"""Session
|
|
84
|
+
"""Session ID from the previous turn (AGENT_SESSION_ID). Empty = new session."""
|
|
61
85
|
|
|
62
86
|
session_name: str
|
|
63
87
|
"""Human-readable session name (AGENT_SESSION_NAME)."""
|
|
@@ -68,12 +92,18 @@ class AgentContext:
|
|
|
68
92
|
streaming: bool
|
|
69
93
|
"""Whether the bridge expects streaming output (AGENT_STREAMING == "1")."""
|
|
70
94
|
|
|
95
|
+
protocol_version: str
|
|
96
|
+
"""Protocol version the bridge implements (AGENT_PROTOCOL_VERSION)."""
|
|
97
|
+
|
|
71
98
|
image_url: str
|
|
72
99
|
"""Image attachment URL (AGENT_IMAGE_URL). Empty if no image."""
|
|
73
100
|
|
|
74
101
|
file_url: str
|
|
75
102
|
"""File attachment URL (AGENT_FILE_URL). Empty if no file."""
|
|
76
103
|
|
|
104
|
+
attachments: List[Attachment]
|
|
105
|
+
"""Parsed attachments from AGENT_ATTACHMENTS (draft). Empty list when unset."""
|
|
106
|
+
|
|
77
107
|
async def send_partial(self, text: str) -> None:
|
|
78
108
|
"""Send a streaming chunk to the user immediately.
|
|
79
109
|
|
|
@@ -88,6 +118,20 @@ class AgentContext:
|
|
|
88
118
|
sys.stdout.write(line)
|
|
89
119
|
sys.stdout.flush()
|
|
90
120
|
|
|
121
|
+
async def send_error(self, text: str) -> None:
|
|
122
|
+
"""Send an error message to the user.
|
|
123
|
+
|
|
124
|
+
Writes an ``AGENT_ERROR:<json>`` line to stdout and flushes.
|
|
125
|
+
Honored regardless of ``streaming`` mode. After calling this, the
|
|
126
|
+
handler should typically raise ProtocolError or return — any reply
|
|
127
|
+
body produced alongside will be discarded by the bridge.
|
|
128
|
+
"""
|
|
129
|
+
if not text:
|
|
130
|
+
return
|
|
131
|
+
line = f"AGENT_ERROR:{json.dumps(text, ensure_ascii=False)}\n"
|
|
132
|
+
sys.stdout.write(line)
|
|
133
|
+
sys.stdout.flush()
|
|
134
|
+
|
|
91
135
|
|
|
92
136
|
@dataclass
|
|
93
137
|
class AgentResult:
|
|
@@ -97,7 +141,7 @@ class AgentResult:
|
|
|
97
141
|
"""Final reply text. Can be empty if all content was sent via send_partial."""
|
|
98
142
|
|
|
99
143
|
session_id: str = ""
|
|
100
|
-
"""
|
|
144
|
+
"""Session ID to persist. Bridge will pass it back next turn as AGENT_SESSION_ID."""
|
|
101
145
|
|
|
102
146
|
|
|
103
147
|
@dataclass
|
|
@@ -112,6 +156,11 @@ class HistoryEntry:
|
|
|
112
156
|
# ---------------------------------------------------------------------------
|
|
113
157
|
|
|
114
158
|
def session_file_path(session_id: str, base_dir: Optional[str] = None) -> Path:
|
|
159
|
+
"""Resolve the JSONL history file path for a session.
|
|
160
|
+
|
|
161
|
+
Returns the path even if the file does not yet exist. Raises ``ValueError``
|
|
162
|
+
when ``session_id`` is empty — callers should guard with ``if session_id``.
|
|
163
|
+
"""
|
|
115
164
|
if not session_id:
|
|
116
165
|
raise ValueError("session_id must be non-empty")
|
|
117
166
|
root = Path(base_dir) if base_dir else Path.home() / ".agentproc" / "sessions"
|
|
@@ -120,9 +169,14 @@ def session_file_path(session_id: str, base_dir: Optional[str] = None) -> Path:
|
|
|
120
169
|
|
|
121
170
|
|
|
122
171
|
def load_history(session_id: str, base_dir: Optional[str] = None) -> List[HistoryEntry]:
|
|
172
|
+
"""Load conversation history for a session. Returns ``[]`` if no session
|
|
173
|
+
or no file exists."""
|
|
123
174
|
if not session_id:
|
|
124
175
|
return []
|
|
125
|
-
|
|
176
|
+
try:
|
|
177
|
+
path = session_file_path(session_id, base_dir)
|
|
178
|
+
except ValueError:
|
|
179
|
+
return []
|
|
126
180
|
if not path.exists():
|
|
127
181
|
return []
|
|
128
182
|
entries: List[HistoryEntry] = []
|
|
@@ -132,30 +186,74 @@ def load_history(session_id: str, base_dir: Optional[str] = None) -> List[Histor
|
|
|
132
186
|
continue
|
|
133
187
|
try:
|
|
134
188
|
d = json.loads(line)
|
|
135
|
-
entries.append(HistoryEntry(
|
|
136
|
-
role=d.get("role", ""),
|
|
137
|
-
content=d.get("content", ""),
|
|
138
|
-
timestamp=d.get("timestamp", ""),
|
|
139
|
-
))
|
|
140
189
|
except json.JSONDecodeError:
|
|
141
190
|
continue
|
|
191
|
+
entries.append(HistoryEntry(
|
|
192
|
+
role=d.get("role", ""),
|
|
193
|
+
content=d.get("content", ""),
|
|
194
|
+
timestamp=d.get("timestamp", ""),
|
|
195
|
+
))
|
|
142
196
|
return entries
|
|
143
197
|
|
|
144
198
|
|
|
145
199
|
def append_history(
|
|
146
200
|
session_id: str,
|
|
147
|
-
entries:
|
|
201
|
+
entries: Sequence[HistoryEntry],
|
|
148
202
|
base_dir: Optional[str] = None,
|
|
149
203
|
) -> None:
|
|
204
|
+
"""Append entries to a session's JSONL history file. No-op if no session_id."""
|
|
150
205
|
if not session_id or not entries:
|
|
151
206
|
return
|
|
152
207
|
path = session_file_path(session_id, base_dir)
|
|
153
208
|
with path.open("a", encoding="utf-8") as f:
|
|
154
209
|
for e in entries:
|
|
155
|
-
f.write(json.dumps(
|
|
210
|
+
f.write(json.dumps(
|
|
211
|
+
{"role": e.role, "content": e.content, "timestamp": e.timestamp},
|
|
212
|
+
ensure_ascii=False,
|
|
213
|
+
))
|
|
156
214
|
f.write("\n")
|
|
157
215
|
|
|
158
216
|
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Env parsing helpers
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _parse_attachments(raw: str) -> List[Attachment]:
|
|
222
|
+
"""Parse AGENT_ATTACHMENTS JSON. Returns [] on parse failure."""
|
|
223
|
+
if not raw:
|
|
224
|
+
return []
|
|
225
|
+
try:
|
|
226
|
+
items = json.loads(raw)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
return []
|
|
229
|
+
if not isinstance(items, list):
|
|
230
|
+
return []
|
|
231
|
+
out: List[Attachment] = []
|
|
232
|
+
for it in items:
|
|
233
|
+
if not isinstance(it, dict):
|
|
234
|
+
continue
|
|
235
|
+
t = str(it.get("type", ""))
|
|
236
|
+
u = str(it.get("url", ""))
|
|
237
|
+
if not t or not u:
|
|
238
|
+
continue
|
|
239
|
+
out.append(Attachment(type=t, url=u, name=str(it.get("name", "") or "")))
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _context_from_env() -> AgentContext:
|
|
244
|
+
return AgentContext(
|
|
245
|
+
message=os.environ.get("AGENT_MESSAGE", ""),
|
|
246
|
+
session_id=os.environ.get("AGENT_SESSION_ID", ""),
|
|
247
|
+
session_name=os.environ.get("AGENT_SESSION_NAME", "default"),
|
|
248
|
+
from_user=os.environ.get("AGENT_FROM_USER", ""),
|
|
249
|
+
streaming=os.environ.get("AGENT_STREAMING", "1") != "0",
|
|
250
|
+
protocol_version=os.environ.get("AGENT_PROTOCOL_VERSION", PROTOCOL_VERSION),
|
|
251
|
+
image_url=os.environ.get("AGENT_IMAGE_URL", ""),
|
|
252
|
+
file_url=os.environ.get("AGENT_FILE_URL", ""),
|
|
253
|
+
attachments=_parse_attachments(os.environ.get("AGENT_ATTACHMENTS", "")),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
159
257
|
# ---------------------------------------------------------------------------
|
|
160
258
|
# Core entrypoint
|
|
161
259
|
# ---------------------------------------------------------------------------
|
|
@@ -171,34 +269,39 @@ def create_profile(handler: Handler) -> None:
|
|
|
171
269
|
and then exits the process.
|
|
172
270
|
|
|
173
271
|
Args:
|
|
174
|
-
handler: An async function
|
|
175
|
-
|
|
272
|
+
handler: An async function taking an :class:`AgentContext` and returning
|
|
273
|
+
either a plain string (treated as ``AgentResult(response=...)``)
|
|
274
|
+
or an :class:`AgentResult`.
|
|
176
275
|
"""
|
|
177
|
-
ctx =
|
|
178
|
-
message=os.environ.get("AGENT_MESSAGE", ""),
|
|
179
|
-
session_id=os.environ.get("AGENT_SESSION_ID", ""),
|
|
180
|
-
session_name=os.environ.get("AGENT_SESSION_NAME", "default"),
|
|
181
|
-
from_user=os.environ.get("AGENT_FROM_USER", ""),
|
|
182
|
-
streaming=os.environ.get("AGENT_STREAMING", "1") != "0",
|
|
183
|
-
image_url=os.environ.get("AGENT_IMAGE_URL", ""),
|
|
184
|
-
file_url=os.environ.get("AGENT_FILE_URL", ""),
|
|
185
|
-
)
|
|
276
|
+
ctx = _context_from_env()
|
|
186
277
|
|
|
187
278
|
try:
|
|
188
279
|
result = asyncio.run(handler(ctx))
|
|
280
|
+
except ProtocolError as e:
|
|
281
|
+
# Handler already-signaled error surfaced via exception.
|
|
282
|
+
sys.stdout.write(
|
|
283
|
+
f"AGENT_ERROR:{json.dumps(str(e) or 'unknown error', ensure_ascii=False)}\n"
|
|
284
|
+
)
|
|
285
|
+
sys.stdout.flush()
|
|
286
|
+
sys.exit(1)
|
|
189
287
|
except Exception as e:
|
|
190
288
|
sys.stderr.write(f"[agentproc] handler error: {e}\n")
|
|
191
289
|
sys.exit(1)
|
|
192
290
|
|
|
291
|
+
# Handler may return None when it has already signaled everything via
|
|
292
|
+
# send_partial / send_error. Treat None as an empty AgentResult.
|
|
293
|
+
if result is None:
|
|
294
|
+
sys.exit(0)
|
|
295
|
+
|
|
193
296
|
if isinstance(result, str):
|
|
194
297
|
result = AgentResult(response=result)
|
|
195
298
|
|
|
196
|
-
#
|
|
299
|
+
# session line — emitted last in the typical "I just learned it" flow,
|
|
300
|
+
# but the spec says last wins, so emitting it at the end is correct.
|
|
197
301
|
if result.session_id:
|
|
198
302
|
sys.stdout.write(f"AGENT_SESSION:{result.session_id}\n")
|
|
199
303
|
sys.stdout.flush()
|
|
200
304
|
|
|
201
|
-
# Emit final reply body
|
|
202
305
|
if result.response:
|
|
203
306
|
sys.stdout.write(result.response)
|
|
204
307
|
if not result.response.endswith("\n"):
|