cowo 0.1.0__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.

Potentially problematic release.


This version of cowo might be problematic. Click here for more details.

cowo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yaitso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cowo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: cowo
3
+ Version: 0.1.0
4
+ Summary: codex workflows — multi-agent orchestration over the codex app-server: fan out, pipeline, and build DAGs of agents
5
+ Keywords: llm,codex,agents,orchestration,workflows,cowo
6
+ Author: yaitso
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Typing :: Typed
12
+ Requires-Dist: cyclopts>=4.18.0
13
+ Requires-Dist: openai-codex>=0.1.0b2
14
+ Requires-Dist: pydantic>=2.13.4
15
+ Requires-Python: >=3.12
16
+ Project-URL: Homepage, https://github.com/yaitso/cowo
17
+ Project-URL: Repository, https://github.com/yaitso/cowo
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cowo
21
+
22
+ multi-agent orchestration for python, built on the official [codex app-server](https://developers.openai.com/codex/app-server). fan out, pipeline, and compose **DAGs of agents** — each agent is a full codex thread (tools, sandbox, structured output), not a bare completion — all multiplexed over one long-lived process.
23
+
24
+ ## why
25
+
26
+ the codex app-server already gives you threads, forking, mailboxes, structured output, and a whole agent-graph runtime — but its python SDK is a *single-client driver*. it has no conductor: no fan-out, no DAG, no tunable concurrency, no token budgeting across agents. `cowo` is that conductor.
27
+
28
+ - **`agent()`** — one codex thread, one turn, structured result. the leaf primitive.
29
+ - **`parallel` / `pipeline`** — imperative fan-out / staged flows.
30
+ - **`dag` + `task` + `>>`** — declarative agent graphs (airflow-style operators) scheduled over a **tunable concurrency** limiter — drain thousands of queued agents through N live-adjustable slots.
31
+ - **`session`** — a persistent multi-turn agent (a codex thread that remembers).
32
+ - **pydantic-native** — pass a `BaseModel` as the schema, get a validated instance back.
33
+ - **one process** — N agents = N threads multiplexed over a single app-server, not N subprocesses.
34
+
35
+ ## install
36
+
37
+ ```sh
38
+ uv add cowo # distribution name; you still `import cowo`
39
+ ```
40
+
41
+ the [`openai-codex`](https://pypi.org/project/openai-codex/) SDK (a dependency) bundles the codex binary — no separate install. you do need codex auth once:
42
+
43
+ ```sh
44
+ codex login # or set OPENAI_API_KEY
45
+ cowo doctor
46
+ ```
47
+
48
+ ## quickstart
49
+
50
+ ```python
51
+ import asyncio
52
+ from pydantic import BaseModel
53
+ from cowo import agent, parallel, spent
54
+
55
+ class Fact(BaseModel):
56
+ name: str
57
+ year: int
58
+
59
+ async def main():
60
+ answer = await agent("Reply with one word: pong")
61
+ fact = await agent("Founding year of Tokyo.", schema=Fact) # -> Fact(name='Tokyo', year=1457)
62
+ cities = await parallel([
63
+ (lambda c=c: agent(f"Capital of {c}? one word")) for c in ("France", "Japan")
64
+ ])
65
+ print(answer, fact, cities, "tokens:", spent.spent())
66
+
67
+ asyncio.run(main())
68
+ ```
69
+
70
+ structured output is enforced by the app-server (`outputSchema`) — a schema'd agent can't return non-conforming JSON.
71
+
72
+ ## DAGs of agents
73
+
74
+ declare the graph with airflow-style `>>`; the scheduler runs it over a concurrency limiter:
75
+
76
+ ```python
77
+ from cowo import dag, task
78
+
79
+ a = task("Analyze module A.")
80
+ b = task("Analyze module B.")
81
+ synth = task(lambda ups: f"Synthesize these analyses:\n{ups}")
82
+
83
+ [a, b] >> synth # a, b run concurrently; synth gets their results
84
+ result = dag(synth).run(concurrency=8) # drain the graph through 8 slots
85
+ ```
86
+
87
+ the DAG is explicit data before it runs — inspectable, schedulable, journalable. concurrency is live-adjustable (`d.concurrency = 32`) for draining large queues.
88
+
89
+ ## recursion
90
+
91
+ recursion isn't a primitive — it's a recursive python function whose leaves are `agent()` calls:
92
+
93
+ ```python
94
+ async def summarize(chunks):
95
+ if len(chunks) == 1:
96
+ return await agent(f"Summarize:\n{chunks[0]}")
97
+ mid = len(chunks) // 2
98
+ parts = await parallel([(lambda h=h: summarize(h)) for h in (chunks[:mid], chunks[mid:])])
99
+ return await agent("Merge these summaries:\n" + "\n".join(parts))
100
+ ```
101
+
102
+ ## api
103
+
104
+ - `agent(prompt, *, schema=None, model=None, effort=None, sandbox=None, cwd=None, label=None)` — one-shot agent (ephemeral thread). `schema` accepts a dict JSON Schema or a pydantic `BaseModel`. `sandbox=None` is yolo (full access, no approvals); pass `"read-only"`/`"workspace-write"` to restrict.
105
+ - `session(*, model=None, sandbox=None, cwd=None)` — `.send(prompt, schema=, effort=)`, a persistent thread across turns.
106
+ - `parallel(thunks)` / `pipeline(items, *stages)` — imperative fan-out / staged flow.
107
+ - `dag(*terminals).run(concurrency=N)` + `task(...)` + `>>` — declarative agent DAG.
108
+ - `spent` — `budget` with `.spent()` / `.remaining()` / `.total`, aggregated across all agents.
109
+ - `run(coro)` — `asyncio.run` that also closes the shared app-server.
110
+ - `COWO_CONCURRENCY` env — default global concurrency cap (`min(16, cpu-2)`).
111
+
112
+ ## roadmap
113
+
114
+ codex ships a full actor runtime (`multi_agents_v2`: spawn/send/wait/close over a tree of threads with mailboxes and supervision). v0.2 will expose the **actor substrate** — message-passing "soup of agents", cyclic feedback (critic ⇄ generator), and `let-it-crash` supervision — with `dag`/`parallel`/`pipeline` re-expressed as topologies over it. see [ACTORS.md](ACTORS.md).
115
+
116
+ ## license
117
+
118
+ MIT
cowo-0.1.0/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # cowo
2
+
3
+ multi-agent orchestration for python, built on the official [codex app-server](https://developers.openai.com/codex/app-server). fan out, pipeline, and compose **DAGs of agents** — each agent is a full codex thread (tools, sandbox, structured output), not a bare completion — all multiplexed over one long-lived process.
4
+
5
+ ## why
6
+
7
+ the codex app-server already gives you threads, forking, mailboxes, structured output, and a whole agent-graph runtime — but its python SDK is a *single-client driver*. it has no conductor: no fan-out, no DAG, no tunable concurrency, no token budgeting across agents. `cowo` is that conductor.
8
+
9
+ - **`agent()`** — one codex thread, one turn, structured result. the leaf primitive.
10
+ - **`parallel` / `pipeline`** — imperative fan-out / staged flows.
11
+ - **`dag` + `task` + `>>`** — declarative agent graphs (airflow-style operators) scheduled over a **tunable concurrency** limiter — drain thousands of queued agents through N live-adjustable slots.
12
+ - **`session`** — a persistent multi-turn agent (a codex thread that remembers).
13
+ - **pydantic-native** — pass a `BaseModel` as the schema, get a validated instance back.
14
+ - **one process** — N agents = N threads multiplexed over a single app-server, not N subprocesses.
15
+
16
+ ## install
17
+
18
+ ```sh
19
+ uv add cowo # distribution name; you still `import cowo`
20
+ ```
21
+
22
+ the [`openai-codex`](https://pypi.org/project/openai-codex/) SDK (a dependency) bundles the codex binary — no separate install. you do need codex auth once:
23
+
24
+ ```sh
25
+ codex login # or set OPENAI_API_KEY
26
+ cowo doctor
27
+ ```
28
+
29
+ ## quickstart
30
+
31
+ ```python
32
+ import asyncio
33
+ from pydantic import BaseModel
34
+ from cowo import agent, parallel, spent
35
+
36
+ class Fact(BaseModel):
37
+ name: str
38
+ year: int
39
+
40
+ async def main():
41
+ answer = await agent("Reply with one word: pong")
42
+ fact = await agent("Founding year of Tokyo.", schema=Fact) # -> Fact(name='Tokyo', year=1457)
43
+ cities = await parallel([
44
+ (lambda c=c: agent(f"Capital of {c}? one word")) for c in ("France", "Japan")
45
+ ])
46
+ print(answer, fact, cities, "tokens:", spent.spent())
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ structured output is enforced by the app-server (`outputSchema`) — a schema'd agent can't return non-conforming JSON.
52
+
53
+ ## DAGs of agents
54
+
55
+ declare the graph with airflow-style `>>`; the scheduler runs it over a concurrency limiter:
56
+
57
+ ```python
58
+ from cowo import dag, task
59
+
60
+ a = task("Analyze module A.")
61
+ b = task("Analyze module B.")
62
+ synth = task(lambda ups: f"Synthesize these analyses:\n{ups}")
63
+
64
+ [a, b] >> synth # a, b run concurrently; synth gets their results
65
+ result = dag(synth).run(concurrency=8) # drain the graph through 8 slots
66
+ ```
67
+
68
+ the DAG is explicit data before it runs — inspectable, schedulable, journalable. concurrency is live-adjustable (`d.concurrency = 32`) for draining large queues.
69
+
70
+ ## recursion
71
+
72
+ recursion isn't a primitive — it's a recursive python function whose leaves are `agent()` calls:
73
+
74
+ ```python
75
+ async def summarize(chunks):
76
+ if len(chunks) == 1:
77
+ return await agent(f"Summarize:\n{chunks[0]}")
78
+ mid = len(chunks) // 2
79
+ parts = await parallel([(lambda h=h: summarize(h)) for h in (chunks[:mid], chunks[mid:])])
80
+ return await agent("Merge these summaries:\n" + "\n".join(parts))
81
+ ```
82
+
83
+ ## api
84
+
85
+ - `agent(prompt, *, schema=None, model=None, effort=None, sandbox=None, cwd=None, label=None)` — one-shot agent (ephemeral thread). `schema` accepts a dict JSON Schema or a pydantic `BaseModel`. `sandbox=None` is yolo (full access, no approvals); pass `"read-only"`/`"workspace-write"` to restrict.
86
+ - `session(*, model=None, sandbox=None, cwd=None)` — `.send(prompt, schema=, effort=)`, a persistent thread across turns.
87
+ - `parallel(thunks)` / `pipeline(items, *stages)` — imperative fan-out / staged flow.
88
+ - `dag(*terminals).run(concurrency=N)` + `task(...)` + `>>` — declarative agent DAG.
89
+ - `spent` — `budget` with `.spent()` / `.remaining()` / `.total`, aggregated across all agents.
90
+ - `run(coro)` — `asyncio.run` that also closes the shared app-server.
91
+ - `COWO_CONCURRENCY` env — default global concurrency cap (`min(16, cpu-2)`).
92
+
93
+ ## roadmap
94
+
95
+ codex ships a full actor runtime (`multi_agents_v2`: spawn/send/wait/close over a tree of threads with mailboxes and supervision). v0.2 will expose the **actor substrate** — message-passing "soup of agents", cyclic feedback (critic ⇄ generator), and `let-it-crash` supervision — with `dag`/`parallel`/`pipeline` re-expressed as topologies over it. see [ACTORS.md](ACTORS.md).
96
+
97
+ ## license
98
+
99
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "cowo"
7
+ version = "0.1.0"
8
+ description = "codex workflows — multi-agent orchestration over the codex app-server: fan out, pipeline, and build DAGs of agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "yaitso" }]
14
+ keywords = ["llm", "codex", "agents", "orchestration", "workflows", "cowo"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Development Status :: 3 - Alpha",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "cyclopts>=4.18.0",
22
+ "openai-codex>=0.1.0b2",
23
+ "pydantic>=2.13.4",
24
+ ]
25
+
26
+ [project.scripts]
27
+ cowo = "cowo.cli:cli"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/yaitso/cowo"
31
+ Repository = "https://github.com/yaitso/cowo"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=9.1.0",
36
+ ]
@@ -0,0 +1,16 @@
1
+ from cowo.core import agent, budget, log, parallel, phase, pipeline, run, session, spent
2
+ from cowo.graph import dag, task
3
+
4
+ __all__ = [
5
+ "run",
6
+ "log",
7
+ "dag",
8
+ "task",
9
+ "agent",
10
+ "phase",
11
+ "spent",
12
+ "budget",
13
+ "session",
14
+ "parallel",
15
+ "pipeline",
16
+ ]
@@ -0,0 +1,40 @@
1
+ import json
2
+
3
+ import cyclopts
4
+
5
+ from cowo.core import agent, run
6
+
7
+ cli = cyclopts.App(
8
+ name="cowo",
9
+ help="codex workflows — multi-agent orchestration over the codex app-server",
10
+ )
11
+
12
+
13
+ @cli.command
14
+ def doctor():
15
+ """verify the codex app-server backend is reachable and authenticated."""
16
+ try:
17
+ reply = run(agent("Reply with the single word: ok"))
18
+ except Exception as e:
19
+ print(f"backend error: {e}")
20
+ print("hint: authenticate with `codex login` or set OPENAI_API_KEY")
21
+ raise SystemExit(1) from e
22
+ print(
23
+ f"backend ok: {reply!r}"
24
+ if reply
25
+ else "backend returned nothing — run `codex login`"
26
+ )
27
+
28
+
29
+ @cli.command
30
+ def ask(
31
+ prompt: str,
32
+ *,
33
+ schema: str | None = None,
34
+ model: str | None = None,
35
+ effort: str | None = None,
36
+ ):
37
+ """one-shot agent call; --schema PATH forces structured json output."""
38
+ spec = json.loads(open(schema).read()) if schema else None
39
+ out = run(agent(prompt, schema=spec, model=model, effort=effort))
40
+ print(json.dumps(out, indent=2) if isinstance(out, (dict, list)) else out)
@@ -0,0 +1,198 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from collections.abc import Awaitable, Callable, Iterable
5
+ from dataclasses import dataclass
6
+
7
+ from openai_codex import ApprovalMode, AsyncCodex, Sandbox
8
+ from pydantic import BaseModel
9
+
10
+ type schema = dict | type[BaseModel]
11
+ type result = str | dict | list | BaseModel | None
12
+
13
+ CAP = max(1, min(16, (os.cpu_count() or 4) - 2))
14
+ gate = asyncio.Semaphore(int(os.environ.get("COWO_CONCURRENCY", CAP)))
15
+
16
+ SANDBOX = {
17
+ None: Sandbox.full_access,
18
+ "read-only": Sandbox.read_only,
19
+ "workspace-write": Sandbox.workspace_write,
20
+ "danger-full-access": Sandbox.full_access,
21
+ "full-access": Sandbox.full_access,
22
+ }
23
+
24
+ live: AsyncCodex | None = None
25
+ opening = asyncio.Lock()
26
+
27
+
28
+ async def codex() -> AsyncCodex:
29
+ global live
30
+ if live is None:
31
+ async with opening:
32
+ if live is None:
33
+ client = AsyncCodex()
34
+ await client.__aenter__()
35
+ live = client
36
+ return live
37
+
38
+
39
+ async def shutdown() -> None:
40
+ global live
41
+ if live is not None:
42
+ await live.close()
43
+ live = None
44
+
45
+
46
+ @dataclass
47
+ class budget:
48
+ out: int = 0
49
+ total: int | None = None
50
+
51
+ def spend(self, n: int) -> None:
52
+ self.out += n
53
+
54
+ def spent(self) -> int:
55
+ return self.out
56
+
57
+ def remaining(self) -> float:
58
+ return float("inf") if self.total is None else max(0, self.total - self.out)
59
+
60
+
61
+ spent = budget()
62
+
63
+
64
+ def tokens(usage) -> int:
65
+ total = getattr(usage, "total", None)
66
+ return getattr(total, "output_tokens", 0) if total is not None else 0
67
+
68
+
69
+ def log(msg: str) -> None:
70
+ print(f" · {msg}", flush=True)
71
+
72
+
73
+ def phase(title: str) -> None:
74
+ print(f"\n━━ {title} ━━", flush=True)
75
+
76
+
77
+ def strictify(s: dict) -> dict:
78
+ if isinstance(s, dict):
79
+ if s.get("type") == "object":
80
+ s["additionalProperties"] = False
81
+ if "properties" in s:
82
+ s["required"] = list(s["properties"].keys())
83
+ for v in s.values():
84
+ strictify(v)
85
+ elif isinstance(s, list):
86
+ for v in s:
87
+ strictify(v)
88
+ return s
89
+
90
+
91
+ def normalize(spec: schema | None) -> tuple[dict | None, type[BaseModel] | None]:
92
+ if spec is None:
93
+ return None, None
94
+ if isinstance(spec, type) and issubclass(spec, BaseModel):
95
+ return strictify(spec.model_json_schema()), spec
96
+ return spec, None
97
+
98
+
99
+ def coerce(
100
+ final: str | None, spec: dict | None, model: type[BaseModel] | None
101
+ ) -> result:
102
+ if spec is None or not isinstance(final, str):
103
+ return final
104
+ try:
105
+ data = json.loads(final)
106
+ except json.JSONDecodeError:
107
+ return final
108
+ if model is not None:
109
+ try:
110
+ return model.model_validate(data)
111
+ except Exception:
112
+ return data
113
+ return data
114
+
115
+
116
+ def cfg(effort: str | None) -> dict | None:
117
+ return {"model_reasoning_effort": effort} if effort else None
118
+
119
+
120
+ async def agent(
121
+ prompt: str,
122
+ *,
123
+ schema: schema | None = None,
124
+ model: str | None = None,
125
+ effort: str | None = None,
126
+ sandbox: str | None = None,
127
+ cwd: str | None = None,
128
+ label: str | None = None,
129
+ ) -> result:
130
+ spec, model_cls = normalize(schema)
131
+ cx = await codex()
132
+ async with gate:
133
+ thread = await cx.thread_start(
134
+ model=model,
135
+ sandbox=SANDBOX[sandbox],
136
+ approval_mode=ApprovalMode.deny_all,
137
+ cwd=cwd,
138
+ ephemeral=True,
139
+ config=cfg(effort),
140
+ )
141
+ res = await thread.run(prompt, output_schema=spec)
142
+ spent.spend(tokens(res.usage))
143
+ return coerce(res.final_response, spec, model_cls)
144
+
145
+
146
+ class session:
147
+ def __init__(
148
+ self,
149
+ *,
150
+ model: str | None = None,
151
+ sandbox: str | None = None,
152
+ cwd: str | None = None,
153
+ ):
154
+ self.model = model
155
+ self.sandbox = sandbox
156
+ self.cwd = cwd
157
+ self.thread = None
158
+
159
+ async def send(
160
+ self, prompt: str, *, schema: schema | None = None, effort: str | None = None
161
+ ) -> result:
162
+ spec, model_cls = normalize(schema)
163
+ cx = await codex()
164
+ if self.thread is None:
165
+ self.thread = await cx.thread_start(
166
+ model=self.model,
167
+ sandbox=SANDBOX[self.sandbox],
168
+ approval_mode=ApprovalMode.deny_all,
169
+ cwd=self.cwd,
170
+ )
171
+ async with gate:
172
+ res = await self.thread.run(prompt, output_schema=spec, effort=effort)
173
+ spent.spend(tokens(res.usage))
174
+ return coerce(res.final_response, spec, model_cls)
175
+
176
+
177
+ async def parallel(thunks: Iterable[Callable[[], Awaitable]]) -> list:
178
+ return await asyncio.gather(*(t() for t in thunks))
179
+
180
+
181
+ async def pipeline(items: Iterable, *stages: Callable[[result], Awaitable]) -> list:
182
+ async def chain(item):
183
+ value = item
184
+ for stage in stages:
185
+ value = await stage(value)
186
+ return value
187
+
188
+ return await asyncio.gather(*(chain(it) for it in items))
189
+
190
+
191
+ def run(coro: Awaitable):
192
+ async def wrap():
193
+ try:
194
+ return await coro
195
+ finally:
196
+ await shutdown()
197
+
198
+ return asyncio.run(wrap())
@@ -0,0 +1,142 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+ from graphlib import TopologicalSorter
4
+
5
+ from cowo.core import agent, log, result, schema
6
+
7
+
8
+ class limiter:
9
+ def __init__(self, limit: int | None):
10
+ self.limit = limit
11
+ self.active = 0
12
+ self.cond = asyncio.Condition()
13
+
14
+ async def __aenter__(self):
15
+ async with self.cond:
16
+ while self.limit is not None and self.active >= self.limit:
17
+ await self.cond.wait()
18
+ self.active += 1
19
+
20
+ async def __aexit__(self, *_):
21
+ async with self.cond:
22
+ self.active -= 1
23
+ self.cond.notify_all()
24
+
25
+ async def resize(self, limit: int | None):
26
+ async with self.cond:
27
+ self.limit = limit
28
+ self.cond.notify_all()
29
+
30
+
31
+ class task:
32
+ def __init__(
33
+ self,
34
+ prompt: str | Callable[[list], str],
35
+ *,
36
+ schema: schema | None = None,
37
+ model: str | None = None,
38
+ effort: str | None = None,
39
+ sandbox: str | None = None,
40
+ cwd: str | None = None,
41
+ label: str | None = None,
42
+ ):
43
+ self.prompt = prompt
44
+ self.kw = dict(
45
+ schema=schema,
46
+ model=model,
47
+ effort=effort,
48
+ sandbox=sandbox,
49
+ cwd=cwd,
50
+ label=label,
51
+ )
52
+ self.deps: list[task] = []
53
+ self.result: result = None
54
+
55
+ def __rshift__(self, other: "task | list[task]"):
56
+ for t in other if isinstance(other, (list, tuple)) else [other]:
57
+ t.deps.append(self)
58
+ return other
59
+
60
+ def __rrshift__(self, others: "list[task]"):
61
+ for o in others:
62
+ self.deps.append(o)
63
+ return self
64
+
65
+ async def fire(self):
66
+ ups = [d.result for d in self.deps]
67
+ text = self.prompt(ups) if callable(self.prompt) else self.prompt
68
+ self.result = await agent(text, **self.kw)
69
+ return self
70
+
71
+
72
+ def reach(terminals: list[task]) -> list[task]:
73
+ seen, stack = {}, list(terminals)
74
+ while stack:
75
+ n = stack.pop()
76
+ if id(n) in seen:
77
+ continue
78
+ seen[id(n)] = n
79
+ stack.extend(n.deps)
80
+ return list(seen.values())
81
+
82
+
83
+ class dag:
84
+ def __init__(self, *terminals: task, concurrency: int | None = None):
85
+ self.terminals = list(terminals)
86
+ self.lim = limiter(concurrency)
87
+
88
+ @property
89
+ def concurrency(self) -> int | None:
90
+ return self.lim.limit
91
+
92
+ @concurrency.setter
93
+ def concurrency(self, n: int | None):
94
+ self.lim.limit = n
95
+
96
+ async def adjust(self, n: int | None):
97
+ await self.lim.resize(n)
98
+
99
+ async def arun(self) -> dict[task, result] | result:
100
+ nodes = reach(self.terminals)
101
+ ts = TopologicalSorter()
102
+ for n in nodes:
103
+ ts.add(n, *n.deps)
104
+ ts.prepare()
105
+ inflight: dict[int, asyncio.Task] = {}
106
+
107
+ async def gated(n: task):
108
+ async with self.lim:
109
+ return await n.fire()
110
+
111
+ while ts.is_active():
112
+ for n in ts.get_ready():
113
+ inflight[id(n)] = asyncio.create_task(gated(n))
114
+ done, _ = await asyncio.wait(
115
+ inflight.values(), return_when=asyncio.FIRST_COMPLETED
116
+ )
117
+ for t in done:
118
+ n = t.result()
119
+ ts.done(n)
120
+ del inflight[id(n)]
121
+ log(f"done {n.kw.get('label') or text_head(n)}")
122
+ if len(self.terminals) == 1:
123
+ return self.terminals[0].result
124
+ return {n: n.result for n in self.terminals}
125
+
126
+ def run(self, concurrency: int | None = None) -> dict[task, result] | result:
127
+ if concurrency is not None:
128
+ self.lim.limit = concurrency
129
+ from cowo.core import shutdown
130
+
131
+ async def wrap():
132
+ try:
133
+ return await self.arun()
134
+ finally:
135
+ await shutdown()
136
+
137
+ return asyncio.run(wrap())
138
+
139
+
140
+ def text_head(n: task) -> str:
141
+ p = n.prompt if isinstance(n.prompt, str) else "<dynamic>"
142
+ return p[:40]
File without changes