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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.1.0
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://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.0"
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://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
- Output stdout:
11
- optional first line "AGENT_SESSION:<uuid>"
12
- optional partial lines "AGENT_PARTIAL:<json-string>"
13
- remaining lines = final reply text
14
- Exit 0 = success, non-zero = error
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
- "HistoryEntry",
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 UUID from the previous turn (AGENT_SESSION_ID). Empty = new 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
- """CLI session UUID to persist. Bridge will pass it back next turn as AGENT_SESSION_ID."""
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
- path = session_file_path(session_id, base_dir)
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: List[HistoryEntry],
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({"role": e.role, "content": e.content, "timestamp": e.timestamp}, ensure_ascii=False))
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 that takes an AgentContext and returns either
175
- a plain string or an AgentResult.
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 = AgentContext(
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
- # Emit session line first if we have one
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"):