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 +21 -0
- cowo-0.1.0/PKG-INFO +118 -0
- cowo-0.1.0/README.md +99 -0
- cowo-0.1.0/pyproject.toml +36 -0
- cowo-0.1.0/src/cowo/__init__.py +16 -0
- cowo-0.1.0/src/cowo/cli.py +40 -0
- cowo-0.1.0/src/cowo/core.py +198 -0
- cowo-0.1.0/src/cowo/graph.py +142 -0
- cowo-0.1.0/src/cowo/py.typed +0 -0
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
|