anyagent-py 0.0.3__py3-none-win_amd64.whl

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.
anyagent/__init__.py ADDED
@@ -0,0 +1,350 @@
1
+ """The anyagent binary as a Python API: spawn `anyagent serve`, write
2
+ command lines, route reply and event lines. Every rule lives in the
3
+ binary; this file is a pipe (ticket 13, W1–W10)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import sys
11
+ import sysconfig
12
+ from asyncio.subprocess import PIPE
13
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
14
+ from typing import Any, Required, TypedDict, Unpack
15
+
16
+ from .types import (
17
+ AgentDetails,
18
+ AgentRef,
19
+ Answer,
20
+ ConfigValue,
21
+ Delivery,
22
+ DiscoveryReport,
23
+ ErrorBody,
24
+ Event,
25
+ McpServer,
26
+ PermissionMode,
27
+ PlanUsage,
28
+ RollbackScope,
29
+ SessionInfo,
30
+ SessionStatus,
31
+ )
32
+
33
+ __all__ = ["AnyagentError", "OpenOptions", "Runtime", "Session", "kind_of"]
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # PUBLIC TYPES
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class OpenOptions(TypedDict, total=False):
41
+ """What `open` and `generate` accept besides the agent: the `open` command's fields."""
42
+
43
+ dir: Required[str]
44
+ resume: str | None
45
+ fork: str | None
46
+ fork_at: str | None
47
+ permission_mode: PermissionMode | None
48
+ mcp_servers: list[McpServer]
49
+ configure: dict[str, ConfigValue]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # RUNTIME: one `anyagent serve` process
54
+ # ---------------------------------------------------------------------------
55
+
56
+ Settle = Callable[[Any], Any]
57
+ Pending = tuple["asyncio.Future[Any]", Settle | None]
58
+
59
+ # The wire protocol this package speaks; the binary's hello must match.
60
+ PROTOCOL = 1
61
+ # Longest stdout line accepted; a tool result carrying a big diff is one line.
62
+ LINE_LIMIT = 64 << 20
63
+
64
+
65
+ class Runtime:
66
+ """One `anyagent serve` process. Build it with `Runtime.start`."""
67
+
68
+ _proc: asyncio.subprocess.Process
69
+ _hello: asyncio.Future[None]
70
+ _exited: asyncio.Task[int | None]
71
+
72
+ def __init__(self) -> None:
73
+ self._next = 1
74
+ self._pending: dict[int, Pending] = {}
75
+ self._sessions: dict[str, Session] = {}
76
+ self._dead: AnyagentError | None = None
77
+
78
+ @classmethod
79
+ async def start(
80
+ cls, *, bin: str | None = None, mock: str | None = None, env: Mapping[str, str] | None = None
81
+ ) -> Runtime:
82
+ """Spawns the binary; returns after its hello line.
83
+
84
+ `bin`: path to the binary; default `ANYAGENT_BIN`, then the wheel's.
85
+ `mock`: a mock script (`packages/mock-scripts/*.json`): no real agents.
86
+ `env`: environment for the binary and the agents it spawns; default this process's.
87
+ """
88
+ rt = cls()
89
+ args = ["serve", "--mock", mock] if mock else ["serve"]
90
+ rt._proc = await asyncio.create_subprocess_exec(
91
+ resolve_binary(bin), *args, stdin=PIPE, stdout=PIPE, env=env, limit=LINE_LIMIT
92
+ )
93
+ rt._hello = asyncio.get_running_loop().create_future()
94
+ rt._exited = asyncio.create_task(rt._read()) # resolves the hello, or fails it on exit (W4)
95
+ await rt._hello
96
+ return rt
97
+
98
+ async def discover(self) -> DiscoveryReport:
99
+ return await self.call({"cmd": "discover"})
100
+
101
+ async def probe(self, agent: AgentRef) -> AgentDetails:
102
+ return await self.call({"cmd": "probe", "agent": agent})
103
+
104
+ async def plan_usage(self, agent: AgentRef) -> PlanUsage:
105
+ return await self.call({"cmd": "plan_usage", "agent": agent})
106
+
107
+ async def generate(self, agent: AgentRef, prompt: str, **opts: Unpack[OpenOptions]) -> str:
108
+ """One-shot text with no session to manage: titles, commit messages."""
109
+ return await self.call({"cmd": "generate", "agent": agent, "prompt": prompt, **opts})
110
+
111
+ async def open(self, agent: AgentRef, **opts: Unpack[OpenOptions]) -> Session:
112
+ """Opens a session. The Session is registered inside _on_line (W1)."""
113
+
114
+ def settle(info: SessionInfo) -> Session:
115
+ session = Session(self, info)
116
+ self._sessions[info["id"]] = session
117
+ return session
118
+
119
+ return await self.call({"cmd": "open", "agent": agent, **opts}, settle)
120
+
121
+ async def close(self) -> int | None:
122
+ """Graceful and idempotent (W5): close stdin, wait up to 5 s, then kill."""
123
+ if not self._dead:
124
+ self._proc.stdin.close() # type: ignore[union-attr]
125
+ try:
126
+ await asyncio.wait_for(asyncio.shield(self._exited), 5)
127
+ except TimeoutError:
128
+ self._kill()
129
+ return await self._exited
130
+
131
+ # Used by Session; not part of the API.
132
+ def call(self, cmd: dict[str, Any], settle: Settle | None = None) -> asyncio.Future[Any]:
133
+ """Writes one command line; the future gets the reply's `ok`, or `settle(ok)`."""
134
+ fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
135
+ if self._dead:
136
+ fut.set_exception(self._dead) # W4
137
+ return fut
138
+ id = self._next
139
+ self._next += 1
140
+ self._pending[id] = (fut, settle)
141
+ self._proc.stdin.write((json.dumps({"id": id, **cmd}) + "\n").encode()) # type: ignore[union-attr]
142
+ return fut
143
+
144
+ async def _read(self) -> int | None:
145
+ """The reader task: routes every stdout line, then reports the exit."""
146
+ async for raw in self._proc.stdout: # type: ignore[union-attr]
147
+ self._on_line(raw.decode(errors="replace").rstrip("\r\n"))
148
+ code = await self._proc.wait()
149
+ self._on_exit(code)
150
+ return code
151
+
152
+ def _on_line(self, line: str) -> None:
153
+ """Routes one stdout line. Synchronous on purpose: W1 depends on it."""
154
+ if self._dead:
155
+ return
156
+ try:
157
+ msg = json.loads(line)
158
+ except ValueError:
159
+ msg = None
160
+ if not isinstance(msg, dict):
161
+ return self._abort(f"not a frame: {line}")
162
+ if "hello" in msg:
163
+ hello = msg["hello"]
164
+ if not isinstance(hello, dict) or hello.get("protocol") != PROTOCOL:
165
+ return self._abort(f"protocol {hello}, this package speaks {PROTOCOL}")
166
+ if not self._hello.done():
167
+ self._hello.set_result(None)
168
+ return
169
+ if isinstance(msg.get("id"), int):
170
+ fut, settle = self._pending.pop(msg["id"], (None, None))
171
+ if fut is None or fut.done():
172
+ return
173
+ if "error" in msg:
174
+ fut.set_exception(AnyagentError(msg["error"]))
175
+ else:
176
+ fut.set_result(settle(msg["ok"]) if settle else msg["ok"])
177
+ return
178
+ if "event" in msg:
179
+ session = self._sessions.get(msg["event"]["session_id"])
180
+ if session:
181
+ session._push(msg["event"])
182
+ elif "session" in msg: # W3
183
+ session = self._sessions.get(msg["session"])
184
+ if session:
185
+ session._fail(AnyagentError(msg["error"]))
186
+ elif "closed" in msg:
187
+ session = self._sessions.pop(msg["closed"], None)
188
+ if session:
189
+ session._end()
190
+
191
+ def _abort(self, why: str) -> None:
192
+ """A binary that does not speak the protocol (W10): kill it; _on_exit fails the rest."""
193
+ self._dead = AnyagentError({"kind": "ProtocolFailed", "message": why})
194
+ self._kill()
195
+
196
+ def _on_exit(self, code: int | None) -> None:
197
+ """Process gone: fail everything still waiting (W4)."""
198
+ self._dead = self._dead or AnyagentError(
199
+ {"kind": "ProcessExited", "message": f"anyagent exited ({code})", "status": str(code), "stderr": ""}
200
+ )
201
+ if not self._hello.done():
202
+ self._hello.set_exception(self._dead)
203
+ for fut, _ in self._pending.values():
204
+ if not fut.done():
205
+ fut.set_exception(self._dead)
206
+ self._pending.clear()
207
+ for session in self._sessions.values():
208
+ session._fail(self._dead)
209
+ self._sessions.clear()
210
+
211
+ def _kill(self) -> None:
212
+ try:
213
+ self._proc.kill()
214
+ except ProcessLookupError:
215
+ pass # already gone
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # SESSION: one open session
220
+ # ---------------------------------------------------------------------------
221
+
222
+ # Unread events a session may hold before it is closed as lagging (W6).
223
+ CAP = 4096
224
+
225
+
226
+ class Session:
227
+ """One open session: commands in, an ordered event stream out."""
228
+
229
+ # Built by Runtime.open; not part of the API.
230
+ def __init__(self, rt: Runtime, info: SessionInfo) -> None:
231
+ self._rt = rt
232
+ self.id: str = info["id"]
233
+ #: Live: replaced on every `SessionUpdated` (W2).
234
+ self.info: SessionInfo = info
235
+ #: Live: replaced on every `StatusChanged` (W2).
236
+ self.status: SessionStatus = info.get("status", "Idle")
237
+ # Events, then one Exception (session error) or None (closed).
238
+ self._queue: asyncio.Queue[Event | Exception | None] = asyncio.Queue()
239
+ self._ended = False
240
+ self._closing: asyncio.Task[None] | None = None
241
+
242
+ async def prompt(self, text: str, attachments: Sequence[str] = ()) -> Delivery:
243
+ return await self._call({"cmd": "prompt", "text": text, "attachments": list(attachments)})
244
+
245
+ async def answer(self, request: str, answer: Answer) -> None:
246
+ await self._call({"cmd": "answer", "request": request, "answer": answer})
247
+
248
+ async def configure(self, option: str, value: ConfigValue) -> None:
249
+ await self._call({"cmd": "configure", "option": option, "value": value})
250
+
251
+ async def cancel(self, clear_queue: bool = False) -> None:
252
+ await self._call({"cmd": "cancel", "clear_queue": clear_queue})
253
+
254
+ async def dequeue(self, prompt: str) -> None:
255
+ await self._call({"cmd": "dequeue", "prompt": prompt})
256
+
257
+ async def rollback(self, turns: int, scope: RollbackScope) -> None:
258
+ await self._call({"cmd": "rollback", "turns": turns, "scope": scope})
259
+
260
+ async def compact(self) -> None:
261
+ await self._call({"cmd": "compact"})
262
+
263
+ async def close(self) -> None:
264
+ await self._call({"cmd": "close"})
265
+
266
+ async def events(self) -> AsyncIterator[Event]:
267
+ """This session's events in order. Ends after `closed`; raises once on a
268
+ session error, then ends (W3)."""
269
+ while not (self._ended and self._queue.empty()):
270
+ item = await self._queue.get()
271
+ if item is None:
272
+ return
273
+ if isinstance(item, Exception):
274
+ raise item
275
+ yield item
276
+
277
+ def _call(self, cmd: dict[str, Any]) -> asyncio.Future[Any]:
278
+ return self._rt.call({**cmd, "session": self.id})
279
+
280
+ # _push, _fail and _end are called by Runtime._on_line/_on_exit.
281
+ def _push(self, ev: Event) -> None:
282
+ """Keeps info and status live (W2), applies the cap (W6)."""
283
+ if self._ended:
284
+ return
285
+ kind = ev["kind"]
286
+ if isinstance(kind, dict):
287
+ if "SessionUpdated" in kind:
288
+ self.info = kind["SessionUpdated"]
289
+ if "StatusChanged" in kind:
290
+ self.status = kind["StatusChanged"]
291
+ self._queue.put_nowait(ev)
292
+ if self._queue.qsize() > CAP:
293
+ self._fail(AnyagentError({"kind": "ConsumerLagged", "message": f"{CAP} events unread"}))
294
+ self._closing = asyncio.create_task(self._close_quietly())
295
+
296
+ def _fail(self, error: Exception) -> None:
297
+ if not self._ended:
298
+ self._ended = True
299
+ self._queue.put_nowait(error)
300
+
301
+ def _end(self) -> None:
302
+ if not self._ended:
303
+ self._ended = True
304
+ self._queue.put_nowait(None)
305
+
306
+ async def _close_quietly(self) -> None:
307
+ try:
308
+ await self.close()
309
+ except AnyagentError:
310
+ pass
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # HELPERS
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def kind_of(ev: Event) -> str:
319
+ """The variant name of `event["kind"]`, for both `{"TextDelta": {..}}` and `"ContextCompacted"` (W7)."""
320
+ kind = ev["kind"]
321
+ return kind if isinstance(kind, str) else next(iter(kind))
322
+
323
+
324
+ class AnyagentError(Exception):
325
+ """`kind`, `message`, and every extra field from the wire in `data` (W8)."""
326
+
327
+ def __init__(self, body: ErrorBody) -> None:
328
+ super().__init__(body["message"])
329
+ self.kind: str = body["kind"]
330
+ self.message: str = body["message"]
331
+ self.data: dict[str, Any] = {k: v for k, v in body.items() if k not in ("kind", "message")}
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # INTERNAL: finding the binary
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def resolve_binary(bin: str | None) -> str:
340
+ """`bin`, then `ANYAGENT_BIN`, then the binary the wheel put on this environment's scripts path."""
341
+ explicit = bin or os.environ.get("ANYAGENT_BIN")
342
+ if explicit:
343
+ return explicit
344
+ exe = "anyagent" + (sysconfig.get_config_var("EXE") or "")
345
+ # The venv or system scripts dir, then `pip install --user`'s.
346
+ for scheme in (sysconfig.get_default_scheme(), f"{os.name}_user"):
347
+ path = os.path.join(sysconfig.get_path("scripts", scheme), exe)
348
+ if os.path.isfile(path):
349
+ return path
350
+ raise FileNotFoundError(f"no anyagent binary next to {sys.executable}: pip install anyagent-py, or set ANYAGENT_BIN")