okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix: guard closed child mail and remove inherited shim generationsff92766

main
9.4 KiB280 linesraw
1"""agents: spawn children, look up any session and read its messages, cancel
2or close your own children.
3Talking is mail.submit. Spawn returns once the child exists; its answer
4arrives later as mail and starts your next turn."""
5
6from __future__ import annotations
7
8import os
9import time
10from dataclasses import dataclass, field
11from typing import cast
12
13from albedo_api import Host, PythonApi, Record
14
15host: Host
16_models_seen = False
17_last_progress = 0.0
18
19
20class MessagePage(Record):
21    """page.content and page["content"] both work; next_offset is None at the end."""
22
23    seq: int
24    offset: int
25    content: str
26    next_offset: int | None
27
28
29class MessageMatches(Record):
30    """matches.rows is a list of {seq, preview}; read one with messages(seq=...)."""
31
32    pattern: str
33    offset: int
34    count: int
35    next_offset: int | None
36    rows: list[dict]
37
38
39@dataclass(frozen=True)
40class Agent:
41    """A handle to one agent. `running` and `closed` are set on snapshots
42    from get(), children(), siblings(), and sessions(); None on handles that
43    were not looked up. `cwd`, `model`, `last_active` (unix seconds of its
44    last answer), and `matches` are set only on snapshots from sessions().
45    `running=False` does not rule out a task waiting for kernel preparation."""
46
47    id: str
48    name: str
49    depth: int
50    parent: "Agent | None" = None
51    running: bool | None = None
52    closed: bool | None = None
53    cwd: str | None = None
54    model: str | None = None
55    last_active: int | None = None
56    matches: list[dict] | None = field(default=None, compare=False)
57
58    async def spawn(
59        self,
60        task: str,
61        *,
62        name: str,
63        model: str,
64        deliverable: str | None = None,
65        evidence_bar: str | None = None,
66        falsifier: str | None = None,
67    ) -> "Agent":
68        """Start a child with `task`. Returns at once; its answer comes as mail."""
69        if self.id != agents.self.id:
70            raise PermissionError(
71                "only agents.self.spawn(...) starts children: you spawn your own"
72            )
73        if not _models_seen:
74            raise RuntimeError(
75                "call await agents.models() first and pick a model from it"
76            )
77        for label, value in (("task", task), ("name", name), ("model", model)):
78            if not isinstance(value, str) or not value.strip():
79                raise TypeError(f"{label} must be a non-empty str")
80        brief = _brief(
81            task,
82            name=name,
83            deliverable=deliverable,
84            evidence_bar=evidence_bar,
85            falsifier=falsifier,
86        )
87        return _agent(
88            cast(
89                dict,
90                await host(
91                    "agents.spawn", {"task": brief, "name": name, "model": model}
92                ),
93            )
94        )
95
96    async def children(self) -> list["Agent"]:
97        if self.id != agents.self.id:
98            raise PermissionError(
99                "children() lists your own: use agents.self.children()"
100            )
101        return [_agent(item) for item in cast(list, await host("agents.children", {}))]
102
103    async def siblings(self) -> list["Agent"]:
104        if self.id != agents.self.id:
105            raise PermissionError(
106                "siblings() lists your own: use agents.self.siblings()"
107            )
108        return [_agent(item) for item in cast(list, await host("agents.siblings", {}))]
109
110    async def cancel(self, *, tree: bool = False) -> bool:
111        """Stop this child's running turn; it keeps its session and work. With
112        `tree=True` its own children, and theirs, are stopped too. True when a
113        turn was running."""
114        stopped = await host("agents.cancel", {"id": self.id, "tree": tree})
115        return bool(cast(list, stopped))
116
117    async def close(self) -> bool:
118        """Done with this child: stop it, keep its messages and files, free its kernel."""
119        return bool(await host("agents.close", {"id": self.id}))
120
121    async def messages(
122        self, seq: int = 0, *, offset: int = 0, limit: int = 4000
123    ) -> MessagePage:
124        """A page of this session's messages from row `seq` on (at most 8000 chars).
125        Any session may read any other."""
126        return MessagePage(
127            cast(
128                dict,
129                await host(
130                    "agents.messages",
131                    {"id": self.id, "seq": seq, "offset": offset, "limit": limit},
132                ),
133            )
134        )
135
136    async def search_messages(
137        self, pattern: str, *, limit: int = 10, offset: int = 0
138    ) -> MessageMatches:
139        """This session's message rows containing `pattern`, case-insensitively."""
140        return MessageMatches(
141            cast(
142                dict,
143                await host(
144                    "agents.search_messages",
145                    {
146                        "id": self.id,
147                        "pattern": pattern,
148                        "limit": limit,
149                        "offset": offset,
150                    },
151                ),
152            )
153        )
154
155    async def delete(self) -> None:
156        await host("agents.delete", {"id": self.id})
157
158
159def _agent(raw: dict) -> Agent:
160    parent = raw.get("parent")
161    return Agent(
162        id=raw["id"],
163        name=raw["name"],
164        depth=raw["depth"],
165        parent=_agent(parent) if isinstance(parent, dict) else None,
166        running=raw.get("running"),
167        closed=raw.get("closed"),
168        cwd=raw.get("cwd"),
169        model=raw.get("model"),
170        last_active=raw.get("last_active"),
171        matches=raw.get("matches"),
172    )
173
174
175def _directory(cwd: str | None) -> str:
176    """A cwd filter as sessions store it: absolute, or "" for every directory."""
177    return os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
178
179
180def _brief(
181    task: str,
182    *,
183    name: str,
184    deliverable: str | None,
185    evidence_bar: str | None,
186    falsifier: str | None,
187) -> str:
188    parts = [f"TASK:\n{task.strip()}"]
189    if deliverable:
190        parts.append(
191            f"DELIVERABLE: {deliverable.strip()}\nWrite it to {deliverable.strip()}.partial and rename it "
192            "into place when finished, so a reader never sees half of it."
193        )
194    if evidence_bar:
195        parts.append(f"EVIDENCE BAR:\n{evidence_bar.strip()}")
196    if falsifier:
197        parts.append(f"FALSIFIER:\n{falsifier.strip()}")
198    parts.append(
199        'WHEN DONE: await mail.submit("parent", <short summary, with paths to anything large>).'
200    )
201    return "\n\n".join(parts)
202
203
204class Agents:
205    self: Agent
206
207    async def models(self) -> list[str]:
208        """Models a child may run on. Required once before spawning."""
209        global _models_seen
210        _models_seen = True
211        return list(cast(list, await host("agents.models", {})))
212
213    async def cancel_all(self, *, tree: bool = True) -> list[str]:
214        """Stop the running turn of every one of your children, and with
215        `tree=True` (the default) everything beneath them. Returns the ids of the
216        sessions that were running. They keep their sessions and work."""
217        stopped = await host("agents.cancel_all", {"tree": tree})
218        return list(cast(list, stopped))
219
220    async def progress(self, text: str) -> bool:
221        """A short status (≤512 chars) the agents view shows without starting a
222        turn. At most one per 10 seconds; returns False when throttled."""
223        global _last_progress
224        if not isinstance(text, str) or not text.strip():
225            raise TypeError("progress(text) takes a non-empty str")
226        now = time.monotonic()
227        if now - _last_progress < 10:
228            return False
229        _last_progress = now
230        return bool(await host("agents.progress", {"text": text.strip()}))
231
232    async def get(self, to: object) -> Agent:
233        """A live snapshot of "parent", a child or sibling by name, an agent
234        handle, or any session id, resolved the way mail.submit resolves it."""
235        target = getattr(to, "id", to)
236        if not isinstance(target, str) or not target.strip():
237            raise TypeError(
238                'agents.get(to): to is "parent", a name, a session id, or an agent handle'
239            )
240        return _agent(cast(dict, await host("agents.get", {"to": target.strip()})))
241
242    async def sessions(
243        self,
244        query: str = "",
245        *,
246        cwd: str | None = None,
247        limit: int = 20,
248        offset: int = 0,
249    ) -> list[Agent]:
250        """Every session, most recently active first (at most 100 per call).
251        A `query` keeps those whose title or name contains it, or whose
252        messages do, case-insensitively; each snapshot's `matches` then holds
253        up to 3 of its newest matching rows as {seq, preview}, to read with
254        messages(seq=...). Your own messages are not searched. With `cwd`,
255        only sessions opened in that directory."""
256        found = await host(
257            "agents.sessions",
258            {
259                "query": query,
260                "cwd": _directory(cwd),
261                "limit": limit,
262                "offset": offset,
263            },
264        )
265        return [_agent(item) for item in cast(list, found)]
266
267    def spawn(self, *_: object, **__: object) -> None:
268        raise AttributeError(
269            "spawn lives on your handle: await agents.self.spawn(task, name=..., model=...)"
270        )
271
272
273agents = Agents()
274
275
276async def setup(api: PythonApi) -> dict[str, object]:
277    global host
278    host = api.host
279    agents.self = _agent(cast(dict, await host("agents.self", {})))
280    return {"agents": agents, "Agent": Agent, "AgentsError": api.HostError}