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")
|