agentic-loop 0.3.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.
- agentic_loop-0.3.0/LICENSE +21 -0
- agentic_loop-0.3.0/PKG-INFO +110 -0
- agentic_loop-0.3.0/README.md +65 -0
- agentic_loop-0.3.0/agentic_loop/__init__.py +31 -0
- agentic_loop-0.3.0/agentic_loop/abort.py +19 -0
- agentic_loop-0.3.0/agentic_loop/api.py +119 -0
- agentic_loop-0.3.0/agentic_loop/cli.py +299 -0
- agentic_loop-0.3.0/agentic_loop/config.py +73 -0
- agentic_loop-0.3.0/agentic_loop/connectors/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/connectors/base.py +39 -0
- agentic_loop-0.3.0/agentic_loop/connectors/mcp.py +99 -0
- agentic_loop-0.3.0/agentic_loop/llm/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/llm/client.py +44 -0
- agentic_loop-0.3.0/agentic_loop/llm/openai_compat.py +163 -0
- agentic_loop-0.3.0/agentic_loop/llm/retry.py +41 -0
- agentic_loop-0.3.0/agentic_loop/loop.py +228 -0
- agentic_loop-0.3.0/agentic_loop/observability/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/observability/journal.py +46 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/automations.py +54 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/goal.py +114 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/memory.py +145 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/orchestrator.py +119 -0
- agentic_loop-0.3.0/agentic_loop/orchestration/subagents.py +66 -0
- agentic_loop-0.3.0/agentic_loop/skills/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/skills/loader.py +52 -0
- agentic_loop-0.3.0/agentic_loop/state.py +28 -0
- agentic_loop-0.3.0/agentic_loop/terminal.py +55 -0
- agentic_loop-0.3.0/agentic_loop/tools/__init__.py +3 -0
- agentic_loop-0.3.0/agentic_loop/tools/builtin.py +3 -0
- agentic_loop-0.3.0/agentic_loop/tools/mcp_bridge.py +36 -0
- agentic_loop-0.3.0/agentic_loop/tools/registry.py +222 -0
- agentic_loop-0.3.0/agentic_loop/worktree/__init__.py +0 -0
- agentic_loop-0.3.0/agentic_loop/worktree/manager.py +47 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/PKG-INFO +110 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/SOURCES.txt +44 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/dependency_links.txt +1 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/entry_points.txt +2 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/requires.txt +25 -0
- agentic_loop-0.3.0/agentic_loop.egg-info/top_level.txt +1 -0
- agentic_loop-0.3.0/pyproject.toml +71 -0
- agentic_loop-0.3.0/setup.cfg +4 -0
- agentic_loop-0.3.0/tests/test_loop.py +206 -0
- agentic_loop-0.3.0/tests/test_mcp.py +60 -0
- agentic_loop-0.3.0/tests/test_orchestration.py +74 -0
- agentic_loop-0.3.0/tests/test_tools.py +42 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agentic-Loop contributors
|
|
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.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentic-loop
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Lightweight Loop Engineering orchestrator for self-hosted agent loops
|
|
5
|
+
Author: Agentic-Loop contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/WenSongWang/Agentic-Loop
|
|
8
|
+
Project-URL: Repository, https://github.com/WenSongWang/Agentic-Loop
|
|
9
|
+
Project-URL: Documentation, https://github.com/WenSongWang/Agentic-Loop#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/WenSongWang/Agentic-Loop/issues
|
|
11
|
+
Keywords: agent,llm,loop-engineering,orchestrator,mcp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: openai>=1.40.0
|
|
24
|
+
Requires-Dist: pydantic>=2.7.0
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
29
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
30
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
31
|
+
Provides-Extra: webhook
|
|
32
|
+
Requires-Dist: fastapi>=0.115.0; extra == "webhook"
|
|
33
|
+
Requires-Dist: uvicorn>=0.32.0; extra == "webhook"
|
|
34
|
+
Provides-Extra: mcp
|
|
35
|
+
Requires-Dist: mcp>=1.0.0; extra == "mcp"
|
|
36
|
+
Provides-Extra: all
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == "all"
|
|
38
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "all"
|
|
39
|
+
Requires-Dist: build>=1.0; extra == "all"
|
|
40
|
+
Requires-Dist: twine>=5.0; extra == "all"
|
|
41
|
+
Requires-Dist: fastapi>=0.115.0; extra == "all"
|
|
42
|
+
Requires-Dist: uvicorn>=0.32.0; extra == "all"
|
|
43
|
+
Requires-Dist: mcp>=1.0.0; extra == "all"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Agentic Loop
|
|
47
|
+
|
|
48
|
+
Lightweight **Loop Engineering** orchestrator — self-hosted agent loops with tools, state memory, sub-agents, `/goal`, and scheduled automations.
|
|
49
|
+
|
|
50
|
+
Fork-friendly (MIT). See [docs/architecture.md](docs/architecture.md) and [docs/extending.md](docs/extending.md).
|
|
51
|
+
|
|
52
|
+
## Install from PyPI
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
pip install agentic-loop
|
|
56
|
+
pip install "agentic-loop[webhook]" # FastAPI server example
|
|
57
|
+
pip install "agentic-loop[mcp]" # MCP connector
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [docs/publishing.md](docs/publishing.md) for maintainers.
|
|
61
|
+
|
|
62
|
+
## Quick Start (development)
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
python -m venv .venv
|
|
66
|
+
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
|
|
67
|
+
copy .env.example .env # set OPENAI_API_KEY + DMXAPI base URL
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
agentic-loop run "Summarize README" --dry-run
|
|
72
|
+
agentic-loop loop --every 5m "Triage issues" --once --dry-run
|
|
73
|
+
agentic-loop goal "tests pass" "Fix tests" --dry-run
|
|
74
|
+
agentic-loop state show
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Example project: [examples/daily-triage](examples/daily-triage/)
|
|
78
|
+
|
|
79
|
+
## Commands
|
|
80
|
+
|
|
81
|
+
| Command | Purpose |
|
|
82
|
+
| :--- | :--- |
|
|
83
|
+
| `run` | Single agent loop with tools |
|
|
84
|
+
| `loop --every 5m` | Automation (Addy: Automations) |
|
|
85
|
+
| `goal "condition" "prompt"` | Run until evaluator confirms goal |
|
|
86
|
+
| `state show` | Persistent memory (state.json + triage) |
|
|
87
|
+
|
|
88
|
+
Common flags: `--cwd`, `--max-turns`, `--skill`, `--agent`, `--allow-bash`, `--dry-run`, `--no-stream`, `--json`
|
|
89
|
+
|
|
90
|
+
## Loop Engineering modules
|
|
91
|
+
|
|
92
|
+
| Module | CLI / path |
|
|
93
|
+
| :--- | :--- |
|
|
94
|
+
| Memory | `state show` → `.agentic-loop/state.json` |
|
|
95
|
+
| Skills | `--skill name` → `skills/*/SKILL.md` |
|
|
96
|
+
| Sub-agents | `--agent name` → `.agentic-loop/agents/*.toml` |
|
|
97
|
+
| Goal | `goal` + `EVALUATOR_MODEL` |
|
|
98
|
+
| Automations | `loop --every` |
|
|
99
|
+
|
|
100
|
+
Run logs: `.agentic-loop/runs/<run_id>.jsonl`
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
pytest tests/ -q
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Agentic Loop
|
|
2
|
+
|
|
3
|
+
Lightweight **Loop Engineering** orchestrator — self-hosted agent loops with tools, state memory, sub-agents, `/goal`, and scheduled automations.
|
|
4
|
+
|
|
5
|
+
Fork-friendly (MIT). See [docs/architecture.md](docs/architecture.md) and [docs/extending.md](docs/extending.md).
|
|
6
|
+
|
|
7
|
+
## Install from PyPI
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
pip install agentic-loop
|
|
11
|
+
pip install "agentic-loop[webhook]" # FastAPI server example
|
|
12
|
+
pip install "agentic-loop[mcp]" # MCP connector
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
See [docs/publishing.md](docs/publishing.md) for maintainers.
|
|
16
|
+
|
|
17
|
+
## Quick Start (development)
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
python -m venv .venv
|
|
21
|
+
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
|
|
22
|
+
copy .env.example .env # set OPENAI_API_KEY + DMXAPI base URL
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
agentic-loop run "Summarize README" --dry-run
|
|
27
|
+
agentic-loop loop --every 5m "Triage issues" --once --dry-run
|
|
28
|
+
agentic-loop goal "tests pass" "Fix tests" --dry-run
|
|
29
|
+
agentic-loop state show
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Example project: [examples/daily-triage](examples/daily-triage/)
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | Purpose |
|
|
37
|
+
| :--- | :--- |
|
|
38
|
+
| `run` | Single agent loop with tools |
|
|
39
|
+
| `loop --every 5m` | Automation (Addy: Automations) |
|
|
40
|
+
| `goal "condition" "prompt"` | Run until evaluator confirms goal |
|
|
41
|
+
| `state show` | Persistent memory (state.json + triage) |
|
|
42
|
+
|
|
43
|
+
Common flags: `--cwd`, `--max-turns`, `--skill`, `--agent`, `--allow-bash`, `--dry-run`, `--no-stream`, `--json`
|
|
44
|
+
|
|
45
|
+
## Loop Engineering modules
|
|
46
|
+
|
|
47
|
+
| Module | CLI / path |
|
|
48
|
+
| :--- | :--- |
|
|
49
|
+
| Memory | `state show` → `.agentic-loop/state.json` |
|
|
50
|
+
| Skills | `--skill name` → `skills/*/SKILL.md` |
|
|
51
|
+
| Sub-agents | `--agent name` → `.agentic-loop/agents/*.toml` |
|
|
52
|
+
| Goal | `goal` + `EVALUATOR_MODEL` |
|
|
53
|
+
| Automations | `loop --every` |
|
|
54
|
+
|
|
55
|
+
Run logs: `.agentic-loop/runs/<run_id>.jsonl`
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
pytest tests/ -q
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agentic Loop — lightweight Loop Engineering orchestrator."""
|
|
2
|
+
|
|
3
|
+
from agentic_loop.api import execute_run, iter_run
|
|
4
|
+
from agentic_loop.config import RunConfig
|
|
5
|
+
from agentic_loop.connectors.base import ConnectorRegistry, ConnectorResult
|
|
6
|
+
from agentic_loop.connectors.mcp import MCPConnector, MCPServerConfig
|
|
7
|
+
from agentic_loop.loop import query_loop, run_loop
|
|
8
|
+
from agentic_loop.orchestration.orchestrator import Orchestrator
|
|
9
|
+
from agentic_loop.terminal import Terminal, TerminalKind
|
|
10
|
+
from agentic_loop.tools.mcp_bridge import register_mcp_tools
|
|
11
|
+
from agentic_loop.tools.registry import ToolRegistry, build_default_registry
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ConnectorRegistry",
|
|
15
|
+
"ConnectorResult",
|
|
16
|
+
"MCPConnector",
|
|
17
|
+
"MCPServerConfig",
|
|
18
|
+
"Orchestrator",
|
|
19
|
+
"RunConfig",
|
|
20
|
+
"Terminal",
|
|
21
|
+
"TerminalKind",
|
|
22
|
+
"ToolRegistry",
|
|
23
|
+
"build_default_registry",
|
|
24
|
+
"execute_run",
|
|
25
|
+
"iter_run",
|
|
26
|
+
"query_loop",
|
|
27
|
+
"register_mcp_tools",
|
|
28
|
+
"run_loop",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AbortController:
|
|
7
|
+
"""Cooperative cancellation signal checked between loop steps."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._event = asyncio.Event()
|
|
11
|
+
|
|
12
|
+
def abort(self) -> None:
|
|
13
|
+
self._event.set()
|
|
14
|
+
|
|
15
|
+
def is_set(self) -> bool:
|
|
16
|
+
return self._event.is_set()
|
|
17
|
+
|
|
18
|
+
def clear(self) -> None:
|
|
19
|
+
self._event.clear()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncIterator, Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentic_loop.config import RunConfig
|
|
8
|
+
from agentic_loop.llm.openai_compat import OpenAICompatClient
|
|
9
|
+
from agentic_loop.loop import LoopEvent, query_loop, terminal_from_event
|
|
10
|
+
from agentic_loop.observability.journal import RunJournal
|
|
11
|
+
from agentic_loop.terminal import Terminal
|
|
12
|
+
from agentic_loop.tools.registry import build_default_registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def execute_run(
|
|
16
|
+
prompt: str,
|
|
17
|
+
*,
|
|
18
|
+
config: RunConfig,
|
|
19
|
+
system_prompt: str | None = None,
|
|
20
|
+
on_event: Callable[[LoopEvent], None] | None = None,
|
|
21
|
+
) -> tuple[Terminal, RunJournal]:
|
|
22
|
+
if config.dry_run:
|
|
23
|
+
journal = RunJournal(config.runs_dir, run_id="dry-run")
|
|
24
|
+
terminal = Terminal.completed(
|
|
25
|
+
f"[dry-run] Would run with model={config.model}, tools={build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash).list_names()}, stream={config.stream}",
|
|
26
|
+
turns=0,
|
|
27
|
+
)
|
|
28
|
+
return terminal, journal
|
|
29
|
+
|
|
30
|
+
config.require_api_key()
|
|
31
|
+
journal = RunJournal(config.runs_dir)
|
|
32
|
+
started = time.perf_counter()
|
|
33
|
+
|
|
34
|
+
journal.started(
|
|
35
|
+
prompt=prompt,
|
|
36
|
+
config={
|
|
37
|
+
"model": config.model,
|
|
38
|
+
"cwd": str(config.cwd),
|
|
39
|
+
"max_turns": config.max_turns,
|
|
40
|
+
"allow_bash": config.allow_bash,
|
|
41
|
+
"stream": config.stream,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
messages: list[dict[str, Any]] = []
|
|
46
|
+
if system_prompt:
|
|
47
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
48
|
+
messages.append({"role": "user", "content": prompt})
|
|
49
|
+
|
|
50
|
+
llm = OpenAICompatClient(
|
|
51
|
+
api_key=config.api_key or "",
|
|
52
|
+
base_url=config.base_url,
|
|
53
|
+
model=config.model,
|
|
54
|
+
max_retries=config.max_retries,
|
|
55
|
+
)
|
|
56
|
+
tools = build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash)
|
|
57
|
+
|
|
58
|
+
terminal: Terminal | None = None
|
|
59
|
+
async for event in query_loop(
|
|
60
|
+
messages,
|
|
61
|
+
tools=tools,
|
|
62
|
+
llm=llm,
|
|
63
|
+
max_turns=config.max_turns,
|
|
64
|
+
journal=journal,
|
|
65
|
+
stream=config.stream,
|
|
66
|
+
tool_timeout=config.tool_timeout,
|
|
67
|
+
):
|
|
68
|
+
if on_event:
|
|
69
|
+
on_event(event)
|
|
70
|
+
if event.kind == "terminal":
|
|
71
|
+
terminal = terminal_from_event(event.data)
|
|
72
|
+
|
|
73
|
+
if terminal is None:
|
|
74
|
+
terminal = Terminal.failed("Run ended without terminal event", turns=0)
|
|
75
|
+
|
|
76
|
+
journal.finished(
|
|
77
|
+
terminal=terminal.to_dict(),
|
|
78
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
79
|
+
)
|
|
80
|
+
return terminal, journal
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def iter_run(
|
|
84
|
+
prompt: str,
|
|
85
|
+
*,
|
|
86
|
+
config: RunConfig,
|
|
87
|
+
system_prompt: str | None = None,
|
|
88
|
+
) -> AsyncIterator[LoopEvent]:
|
|
89
|
+
"""Public API: stream loop events for integrations."""
|
|
90
|
+
if config.dry_run:
|
|
91
|
+
yield LoopEvent(
|
|
92
|
+
kind="terminal",
|
|
93
|
+
data=Terminal.completed("[dry-run]", turns=0).to_dict(),
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
config.require_api_key()
|
|
98
|
+
messages: list[dict[str, Any]] = []
|
|
99
|
+
if system_prompt:
|
|
100
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
101
|
+
messages.append({"role": "user", "content": prompt})
|
|
102
|
+
|
|
103
|
+
llm = OpenAICompatClient(
|
|
104
|
+
api_key=config.api_key or "",
|
|
105
|
+
base_url=config.base_url,
|
|
106
|
+
model=config.model,
|
|
107
|
+
max_retries=config.max_retries,
|
|
108
|
+
)
|
|
109
|
+
tools = build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash)
|
|
110
|
+
|
|
111
|
+
async for event in query_loop(
|
|
112
|
+
messages,
|
|
113
|
+
tools=tools,
|
|
114
|
+
llm=llm,
|
|
115
|
+
max_turns=config.max_turns,
|
|
116
|
+
stream=config.stream,
|
|
117
|
+
tool_timeout=config.tool_timeout,
|
|
118
|
+
):
|
|
119
|
+
yield event
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from agentic_loop.abort import AbortController
|
|
10
|
+
from agentic_loop.api import execute_run
|
|
11
|
+
from agentic_loop.config import RunConfig
|
|
12
|
+
from agentic_loop.loop import LoopEvent
|
|
13
|
+
from agentic_loop.orchestration.automations import parse_interval
|
|
14
|
+
from agentic_loop.orchestration.orchestrator import Orchestrator
|
|
15
|
+
|
|
16
|
+
RUN_EPILOG = """
|
|
17
|
+
Examples:
|
|
18
|
+
agentic-loop run "List Python files" --dry-run
|
|
19
|
+
agentic-loop run "Find TODO comments" --max-turns 10
|
|
20
|
+
agentic-loop run "Explain README" --no-stream
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
LOOP_EPILOG = """
|
|
24
|
+
Examples:
|
|
25
|
+
agentic-loop loop --every 5m "Triage open issues" --once
|
|
26
|
+
agentic-loop loop --every 30s "Check deploy" --skill triage --dry-run
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
GOAL_EPILOG = """
|
|
30
|
+
Examples:
|
|
31
|
+
agentic-loop goal "pytest tests/ passes" "Fix failing tests" --max-rounds 5
|
|
32
|
+
agentic-loop goal "lint is clean" "Run linter and fix issues" --dry-run
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
STATE_EPILOG = """
|
|
36
|
+
Examples:
|
|
37
|
+
agentic-loop state show
|
|
38
|
+
agentic-loop state reset
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="agentic-loop",
|
|
45
|
+
description="Lightweight Loop Engineering orchestrator",
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
)
|
|
48
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
49
|
+
|
|
50
|
+
run = sub.add_parser("run", help="Single agent run", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=RUN_EPILOG)
|
|
51
|
+
run.add_argument("prompt")
|
|
52
|
+
_add_common_run_flags(run)
|
|
53
|
+
|
|
54
|
+
loop = sub.add_parser("loop", help="Scheduled automation", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=LOOP_EPILOG)
|
|
55
|
+
loop.add_argument("prompt")
|
|
56
|
+
loop.add_argument("--every", required=True, help="Interval: 30s, 5m, 2h, 1d")
|
|
57
|
+
loop.add_argument("--once", action="store_true", help="Run once instead of forever")
|
|
58
|
+
_add_common_run_flags(loop)
|
|
59
|
+
|
|
60
|
+
goal = sub.add_parser("goal", help="Run until goal condition passes", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=GOAL_EPILOG)
|
|
61
|
+
goal.add_argument("condition", help="Verifiable stopping condition")
|
|
62
|
+
goal.add_argument("prompt", help="Worker task prompt")
|
|
63
|
+
goal.add_argument("--max-rounds", type=int, default=10)
|
|
64
|
+
_add_common_run_flags(goal)
|
|
65
|
+
|
|
66
|
+
state = sub.add_parser("state", help="Project memory", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=STATE_EPILOG)
|
|
67
|
+
state_sub = state.add_subparsers(dest="state_cmd", required=True)
|
|
68
|
+
state_show = state_sub.add_parser("show", help="Print state markdown")
|
|
69
|
+
state_show.add_argument("--cwd", type=Path, default=Path.cwd())
|
|
70
|
+
state_show.add_argument("--json", action="store_true")
|
|
71
|
+
state_reset = state_sub.add_parser("reset", help="Clear state and triage")
|
|
72
|
+
state_reset.add_argument("--cwd", type=Path, default=Path.cwd())
|
|
73
|
+
state_reset.add_argument("--yes", action="store_true", help="Skip confirmation")
|
|
74
|
+
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_common_run_flags(parser: argparse.ArgumentParser) -> None:
|
|
79
|
+
parser.add_argument("--cwd", type=Path, default=Path.cwd())
|
|
80
|
+
parser.add_argument("--max-turns", type=int, default=20)
|
|
81
|
+
parser.add_argument("--model")
|
|
82
|
+
parser.add_argument("--api-base", dest="base_url")
|
|
83
|
+
parser.add_argument("--allow-bash", action="store_true")
|
|
84
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
85
|
+
parser.add_argument("--no-stream", action="store_true")
|
|
86
|
+
parser.add_argument("--json", action="store_true")
|
|
87
|
+
parser.add_argument("--skill", help="Skill to load into system prompt")
|
|
88
|
+
parser.add_argument("--agent", help="Sub-agent role")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _print_error(message: str) -> None:
|
|
92
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _config_from_args(args: argparse.Namespace) -> RunConfig:
|
|
96
|
+
overrides = {
|
|
97
|
+
"cwd": args.cwd.resolve(),
|
|
98
|
+
"max_turns": getattr(args, "max_turns", 20),
|
|
99
|
+
"allow_bash": getattr(args, "allow_bash", False),
|
|
100
|
+
"dry_run": getattr(args, "dry_run", False),
|
|
101
|
+
"stream": not getattr(args, "no_stream", False),
|
|
102
|
+
}
|
|
103
|
+
if getattr(args, "model", None):
|
|
104
|
+
overrides["model"] = args.model
|
|
105
|
+
if getattr(args, "base_url", None):
|
|
106
|
+
overrides["base_url"] = args.base_url
|
|
107
|
+
return RunConfig.from_env(overrides=overrides)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _make_event_handler(*, stream_output: bool, json_mode: bool):
|
|
111
|
+
streamed_header = False
|
|
112
|
+
|
|
113
|
+
def on_event(event: LoopEvent) -> None:
|
|
114
|
+
nonlocal streamed_header
|
|
115
|
+
if json_mode:
|
|
116
|
+
return
|
|
117
|
+
if event.kind == "turn_start":
|
|
118
|
+
print(f"\n--- turn {event.data.get('turn')} ---", file=sys.stderr)
|
|
119
|
+
elif event.kind == "assistant_delta" and stream_output:
|
|
120
|
+
if not streamed_header:
|
|
121
|
+
print("\n--- assistant ---", file=sys.stderr)
|
|
122
|
+
streamed_header = True
|
|
123
|
+
print(event.data.get("text", ""), end="", flush=True)
|
|
124
|
+
elif event.kind == "tool_result":
|
|
125
|
+
print(f"\n[tool:{event.data.get('tool')}]", file=sys.stderr)
|
|
126
|
+
|
|
127
|
+
return on_event
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _print_terminal(terminal, journal, *, config: RunConfig, json_mode: bool) -> int:
|
|
131
|
+
if json_mode:
|
|
132
|
+
print(json.dumps({"run_id": journal.run_id, "terminal": terminal.to_dict()}, ensure_ascii=False, indent=2))
|
|
133
|
+
return terminal.exit_code
|
|
134
|
+
if config.stream:
|
|
135
|
+
print()
|
|
136
|
+
print(f"run_id: {journal.run_id}")
|
|
137
|
+
print(f"terminal: {terminal.kind.value}")
|
|
138
|
+
print(f"turns: {terminal.turns}")
|
|
139
|
+
if terminal.content:
|
|
140
|
+
print("\n--- result ---\n")
|
|
141
|
+
print(terminal.content)
|
|
142
|
+
if terminal.error and terminal.kind.value != "completed":
|
|
143
|
+
print(f"\nerror: {terminal.error}", file=sys.stderr)
|
|
144
|
+
return terminal.exit_code
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _cmd_run(args: argparse.Namespace) -> int:
|
|
148
|
+
config = _config_from_args(args)
|
|
149
|
+
on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
|
|
150
|
+
try:
|
|
151
|
+
if args.skill or args.agent:
|
|
152
|
+
orch = Orchestrator(config)
|
|
153
|
+
terminal, run_id = await orch.run(
|
|
154
|
+
args.prompt,
|
|
155
|
+
skill=args.skill,
|
|
156
|
+
agent=args.agent,
|
|
157
|
+
on_event=on_event,
|
|
158
|
+
)
|
|
159
|
+
if args.json:
|
|
160
|
+
print(json.dumps({"run_id": run_id, "terminal": terminal.to_dict()}, ensure_ascii=False, indent=2))
|
|
161
|
+
return terminal.exit_code
|
|
162
|
+
print(f"run_id: {run_id}")
|
|
163
|
+
print(f"terminal: {terminal.kind.value}")
|
|
164
|
+
if terminal.content:
|
|
165
|
+
print(terminal.content)
|
|
166
|
+
return terminal.exit_code
|
|
167
|
+
|
|
168
|
+
terminal, journal = await execute_run(args.prompt, config=config, on_event=on_event)
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
_print_error(str(exc))
|
|
171
|
+
return 1
|
|
172
|
+
except KeyboardInterrupt:
|
|
173
|
+
print("\nAborted.", file=sys.stderr)
|
|
174
|
+
return 130
|
|
175
|
+
return _print_terminal(terminal, journal, config=config, json_mode=args.json)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _cmd_loop(args: argparse.Namespace) -> int:
|
|
179
|
+
try:
|
|
180
|
+
parse_interval(args.every)
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
_print_error(str(exc))
|
|
183
|
+
return 1
|
|
184
|
+
|
|
185
|
+
config = _config_from_args(args)
|
|
186
|
+
orch = Orchestrator(config)
|
|
187
|
+
on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
|
|
188
|
+
|
|
189
|
+
if args.dry_run:
|
|
190
|
+
print(f"[dry-run] Would run every {args.every}: {args.prompt}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
print(f"Automation started: every {args.every}" + (" (once)" if args.once else ""), file=sys.stderr)
|
|
194
|
+
try:
|
|
195
|
+
result = await orch.automation(
|
|
196
|
+
args.prompt,
|
|
197
|
+
every=args.every,
|
|
198
|
+
skill=args.skill,
|
|
199
|
+
once=args.once,
|
|
200
|
+
on_event=on_event,
|
|
201
|
+
)
|
|
202
|
+
except KeyboardInterrupt:
|
|
203
|
+
print("\nAutomation stopped.", file=sys.stderr)
|
|
204
|
+
return 130
|
|
205
|
+
|
|
206
|
+
if args.json:
|
|
207
|
+
print(json.dumps({"runs": result.runs, "last_error": result.last_error}, indent=2))
|
|
208
|
+
else:
|
|
209
|
+
print(f"runs: {result.runs}")
|
|
210
|
+
if result.last_error:
|
|
211
|
+
print(f"last_error: {result.last_error}", file=sys.stderr)
|
|
212
|
+
return 0 if not result.last_error else 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _cmd_goal(args: argparse.Namespace) -> int:
|
|
216
|
+
config = _config_from_args(args)
|
|
217
|
+
orch = Orchestrator(config)
|
|
218
|
+
on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
|
|
219
|
+
try:
|
|
220
|
+
terminal, evaluations = await orch.run_goal(
|
|
221
|
+
condition=args.condition,
|
|
222
|
+
prompt=args.prompt,
|
|
223
|
+
skill=args.skill,
|
|
224
|
+
agent=args.agent,
|
|
225
|
+
max_rounds=args.max_rounds,
|
|
226
|
+
on_event=on_event,
|
|
227
|
+
)
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
_print_error(str(exc))
|
|
230
|
+
return 1
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
print("\nAborted.", file=sys.stderr)
|
|
233
|
+
return 130
|
|
234
|
+
|
|
235
|
+
if args.json:
|
|
236
|
+
print(
|
|
237
|
+
json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"terminal": terminal.to_dict(),
|
|
240
|
+
"evaluations": [e.__dict__ for e in evaluations],
|
|
241
|
+
},
|
|
242
|
+
ensure_ascii=False,
|
|
243
|
+
indent=2,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
print(f"terminal: {terminal.kind.value}")
|
|
248
|
+
for idx, ev in enumerate(evaluations, start=1):
|
|
249
|
+
print(f"round {idx}: satisfied={ev.satisfied} reason={ev.reason}")
|
|
250
|
+
if terminal.content:
|
|
251
|
+
print("\n--- result ---\n")
|
|
252
|
+
print(terminal.content)
|
|
253
|
+
return terminal.exit_code
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _cmd_state(args: argparse.Namespace) -> int:
|
|
257
|
+
config = RunConfig.from_env(overrides={"cwd": args.cwd.resolve()})
|
|
258
|
+
store = Orchestrator(config).memory
|
|
259
|
+
|
|
260
|
+
if args.state_cmd == "show":
|
|
261
|
+
if args.json:
|
|
262
|
+
print(json.dumps(store.load_state().__dict__, ensure_ascii=False, indent=2))
|
|
263
|
+
else:
|
|
264
|
+
print(store.format_markdown())
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
if args.state_cmd == "reset":
|
|
268
|
+
if not args.yes:
|
|
269
|
+
_print_error("Pass --yes to clear state.json and triage.json")
|
|
270
|
+
return 1
|
|
271
|
+
if store.state_path.exists():
|
|
272
|
+
store.state_path.unlink()
|
|
273
|
+
if store.triage_path.exists():
|
|
274
|
+
store.triage_path.unlink()
|
|
275
|
+
print("State cleared.")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
return 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main(argv: list[str] | None = None) -> int:
|
|
282
|
+
parser = build_parser()
|
|
283
|
+
args = parser.parse_args(argv)
|
|
284
|
+
|
|
285
|
+
if args.command == "run":
|
|
286
|
+
return asyncio.run(_cmd_run(args))
|
|
287
|
+
if args.command == "loop":
|
|
288
|
+
return asyncio.run(_cmd_loop(args))
|
|
289
|
+
if args.command == "goal":
|
|
290
|
+
return asyncio.run(_cmd_goal(args))
|
|
291
|
+
if args.command == "state":
|
|
292
|
+
return asyncio.run(_cmd_state(args))
|
|
293
|
+
|
|
294
|
+
parser.print_help()
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
raise SystemExit(main())
|