mad-bros 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.
- mad_bros-0.1.0/.gitignore +13 -0
- mad_bros-0.1.0/CHANGELOG.md +113 -0
- mad_bros-0.1.0/LICENSE +21 -0
- mad_bros-0.1.0/PKG-INFO +105 -0
- mad_bros-0.1.0/README.md +55 -0
- mad_bros-0.1.0/pyproject.toml +90 -0
- mad_bros-0.1.0/specs/v0.1/README.md +21 -0
- mad_bros-0.1.0/src/mad/__init__.py +1 -0
- mad_bros-0.1.0/src/mad/agent/__init__.py +0 -0
- mad_bros-0.1.0/src/mad/agent/loop.py +83 -0
- mad_bros-0.1.0/src/mad/agent/tools.py +60 -0
- mad_bros-0.1.0/src/mad/api/__init__.py +3 -0
- mad_bros-0.1.0/src/mad/api/app.py +24 -0
- mad_bros-0.1.0/src/mad/api/routes/__init__.py +0 -0
- mad_bros-0.1.0/src/mad/api/routes/sessions.py +160 -0
- mad_bros-0.1.0/src/mad/cli.py +25 -0
- mad_bros-0.1.0/src/mad/core/__init__.py +0 -0
- mad_bros-0.1.0/src/mad/core/log.py +44 -0
- mad_bros-0.1.0/src/mad/core/resources.py +56 -0
- mad_bros-0.1.0/src/mad/core/security.py +28 -0
- mad_bros-0.1.0/src/mad/core/sessions.py +31 -0
- mad_bros-0.1.0/src/mad/core/workspace.py +20 -0
- mad_bros-0.1.0/src/mad/providers/__init__.py +4 -0
- mad_bros-0.1.0/src/mad/providers/anthropic_api.py +8 -0
- mad_bros-0.1.0/src/mad/providers/base.py +27 -0
- mad_bros-0.1.0/src/mad/providers/claude_cli.py +8 -0
- mad_bros-0.1.0/src/mad/providers/factory.py +13 -0
- mad_bros-0.1.0/src/mad/providers/fake.py +20 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## v0.1.0 (2026-04-15)
|
|
5
|
+
|
|
6
|
+
### Build System
|
|
7
|
+
|
|
8
|
+
- **pypi**: Rename package to mad-bros
|
|
9
|
+
([`fbb828c`](https://github.com/jlsaco/mad/commit/fbb828cc0e8501fa846725bb1d2d430cecc479e4))
|
|
10
|
+
|
|
11
|
+
Update PyPI project name from 'mad' to 'mad-bros' across release workflows, documentation, and
|
|
12
|
+
project configuration. Modify build command to ensure build dependency installation. This rename
|
|
13
|
+
aligns with the new project identity.
|
|
14
|
+
|
|
15
|
+
BREAKING CHANGE: Package name change requires users to install 'mad-bros' instead of 'mad'
|
|
16
|
+
|
|
17
|
+
### Chores
|
|
18
|
+
|
|
19
|
+
- Add Makefile with common targets
|
|
20
|
+
([`73e33d5`](https://github.com/jlsaco/mad/commit/73e33d585ba36dd59e2997cc97a2184d6487570e))
|
|
21
|
+
|
|
22
|
+
Wraps the day-to-day commands (install, test, serve, clean) behind `make` so operators and future
|
|
23
|
+
Claude runs have a single entry point. Targets honor HOST=/PORT= overrides for `make serve`.
|
|
24
|
+
CLAUDE.md and README now point at the Makefile as the source of truth for commands.
|
|
25
|
+
|
|
26
|
+
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
27
|
+
|
|
28
|
+
### Continuous Integration
|
|
29
|
+
|
|
30
|
+
- Implement automated release pipeline with semantic versioning
|
|
31
|
+
([`f8eb874`](https://github.com/jlsaco/mad/commit/f8eb87491f1fa80e98f9db9d0f56d31b09a30803))
|
|
32
|
+
|
|
33
|
+
Add GitHub Actions workflows for CI builds, artifact verification, and automated releases using
|
|
34
|
+
python-semantic-release. Configure pyproject.toml for packaging, dependencies, and release
|
|
35
|
+
settings. Include Makefile targets for building and dry-run releases. Add CHANGELOG.md for version
|
|
36
|
+
tracking and docs/releasing.md for release process documentation. Update .gitignore to exclude
|
|
37
|
+
venv directories.
|
|
38
|
+
|
|
39
|
+
### Documentation
|
|
40
|
+
|
|
41
|
+
- Add initial project documentation and v0.1 specs
|
|
42
|
+
([`ee74d08`](https://github.com/jlsaco/mad/commit/ee74d082c04d2aec421a0abcf4c64f77aa726426))
|
|
43
|
+
|
|
44
|
+
Introduce comprehensive documentation for the Mad project, including an overview in README.md,
|
|
45
|
+
future improvements in docs/backlog.md, sandbox hardening guide in docs/sandbox-bwrap.md, and a
|
|
46
|
+
complete spec-driven development package for v0.1 in specs/v0.1/ covering requirements, design,
|
|
47
|
+
API contract, and implementation plan. This establishes the project's foundation and guides
|
|
48
|
+
development towards the first functional version.
|
|
49
|
+
|
|
50
|
+
- **v0.1**: Mandate src/mad/ package layout
|
|
51
|
+
([`92d5d17`](https://github.com/jlsaco/mad/commit/92d5d17f8460ec7215d86305105bd7cc14c93d36))
|
|
52
|
+
|
|
53
|
+
- Rewrite CLAUDE.md hard rule #4 from "Single-file MVP" to a package layout split by concern (api,
|
|
54
|
+
core, agent, providers) with create_app(store=...) and no module-level globals; update Key files,
|
|
55
|
+
Commands, and LLMProvider sections accordingly. - Update specs/v0.1 requirements NFR-1, plan rule
|
|
56
|
+
2, and the design diagram so the spec no longer contradicts the new convention. - Update the 4
|
|
57
|
+
subagents and /implement command to point at src/mad/ instead of app.py and to enforce the layout
|
|
58
|
+
in reviews. - Extend README with an Install section (pip install -e .) and a project structure
|
|
59
|
+
tree.
|
|
60
|
+
|
|
61
|
+
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
62
|
+
|
|
63
|
+
### Features
|
|
64
|
+
|
|
65
|
+
- Initialize project infrastructure for Mad v0.1
|
|
66
|
+
([`1494569`](https://github.com/jlsaco/mad/commit/1494569f02344b9b0a923446f765801e37f728ec))
|
|
67
|
+
|
|
68
|
+
Add core components including Claude agent definitions, slash commands, CI pipeline, FastAPI app
|
|
69
|
+
skeleton, test fixtures, and security tests. Establish spec-driven development workflow with TDD
|
|
70
|
+
support, enforcing hard rules for token hygiene, path traversal prevention, and native tool use.
|
|
71
|
+
|
|
72
|
+
BREAKING CHANGE: Introduces new project structure requiring spec-first development process.
|
|
73
|
+
|
|
74
|
+
- **api**: Implement session management and provider interfaces
|
|
75
|
+
([`b232a75`](https://github.com/jlsaco/mad/commit/b232a756af10e05e32bfd8e635380bdb3f6c2aff))
|
|
76
|
+
|
|
77
|
+
Introduce core session lifecycle handling including creation, logging, and SSE streaming. Add stub
|
|
78
|
+
implementations for ClaudeCLIProvider and AnthropicAPIProvider. Expand acceptance tests to cover
|
|
79
|
+
MVP criteria such as repo cloning, event handling, and session resumption. Enhance security tests
|
|
80
|
+
with comprehensive path traversal validations and token hygiene checks.
|
|
81
|
+
|
|
82
|
+
BREAKING CHANGE: Updates session response structure to include workspace and resources_mounted
|
|
83
|
+
details. Requires client adjustments for new fields.
|
|
84
|
+
|
|
85
|
+
### Refactoring
|
|
86
|
+
|
|
87
|
+
- **v0.1**: Migrate app.py into src/mad/ package
|
|
88
|
+
([`c652791`](https://github.com/jlsaco/mad/commit/c652791e55f4f333ecaeb597b483eebcb7f65bf8))
|
|
89
|
+
|
|
90
|
+
Split the monolithic app.py into a pip-installable src/mad/ package: - mad.api: FastAPI app factory
|
|
91
|
+
(create_app) + routes/sessions.py. No module-level globals; per-process state lives on a
|
|
92
|
+
SessionStore held in app.state.store so every create_app() call is isolated. - mad.core: log,
|
|
93
|
+
security (path validation), workspace, resources, sessions (SessionStore). - mad.agent: loop and
|
|
94
|
+
tools (run_agent_loop takes the store as a parameter). - mad.providers: base (Protocol +
|
|
95
|
+
ProviderResponse + ToolUse), factory, claude_cli, anthropic_api, fake (FakeScriptedProvider moved
|
|
96
|
+
out of conftest so tests and production share one implementation). - mad.cli: `mad serve` console
|
|
97
|
+
entry-point.
|
|
98
|
+
|
|
99
|
+
pyproject.toml gains build-system (hatchling), [project] metadata and dependencies, a `mad` console
|
|
100
|
+
script, and pytest pythonpath=["src"]. Tests now import from mad.* and TestClient wraps
|
|
101
|
+
create_app().
|
|
102
|
+
|
|
103
|
+
All 35 tests green. No functional changes — this is a pure refactor; FR-7 recovery, FR-10 provider
|
|
104
|
+
stubs, and the sse-starlette gap are carried over from the previous state as pre-existing debt.
|
|
105
|
+
|
|
106
|
+
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
107
|
+
|
|
108
|
+
### Breaking Changes
|
|
109
|
+
|
|
110
|
+
- **api**: Updates session response structure to include workspace and resources_mounted details.
|
|
111
|
+
Requires client adjustments for new fields.
|
|
112
|
+
|
|
113
|
+
- **pypi**: Package name change requires users to install 'mad-bros' instead of 'mad'
|
mad_bros-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Salamanca
|
|
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.
|
mad_bros-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mad-bros
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi Agent Develop — self-hosted autonomous Claude sessions against GitHub repos.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jlsaco/mad
|
|
6
|
+
Project-URL: Issues, https://github.com/jlsaco/mad/issues
|
|
7
|
+
Author-email: Jose Salamanca <jose.salamancacoy@gmail.com>, Cristian Moreno <cristianfmoreno95@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Jose Salamanca
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: agents,automation,claude,github
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Framework :: FastAPI
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Requires-Dist: anthropic>=0.39
|
|
40
|
+
Requires-Dist: fastapi>=0.110
|
|
41
|
+
Requires-Dist: httpx>=0.27
|
|
42
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: python-semantic-release>=9.8; extra == 'dev'
|
|
48
|
+
Requires-Dist: twine>=5.1; extra == 'dev'
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# Mad About
|
|
52
|
+
|
|
53
|
+
> That's mad!
|
|
54
|
+
|
|
55
|
+
**M**ulti **A**gent **D**evelop — a multi-agent system designed to build software autonomously. It takes an idea and drives it end-to-end: from the first line of code to a working product.
|
|
56
|
+
|
|
57
|
+
## What is this?
|
|
58
|
+
|
|
59
|
+
Mad About orchestrates a team of AI agents that collaborate to design, implement, test, and ship software without a human in the loop for every step. You give it a goal; it figures out the rest.
|
|
60
|
+
|
|
61
|
+
## Status
|
|
62
|
+
|
|
63
|
+
Early days. The first milestone is **Mad** — a self-hosted API that provisions workspaces, clones repos, and runs Claude agents autonomously against them. See [`specs/v0.1/`](specs/v0.1/README.md) for the full spec-driven package.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
Mad ships as a pip-installable Python package (`mad`). From a checkout:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
make install # create venv + editable install with dev deps
|
|
71
|
+
make test # run the pytest suite
|
|
72
|
+
make serve # uvicorn factory (override HOST=/PORT= if needed)
|
|
73
|
+
make help # list every target
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
All commands are wrapped by the `Makefile`; the raw equivalents live in `pyproject.toml` (the `mad` console script) and in the project documentation.
|
|
77
|
+
|
|
78
|
+
## Project structure
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
mad/
|
|
82
|
+
├── pyproject.toml # package metadata, deps, `mad` console script
|
|
83
|
+
├── src/mad/
|
|
84
|
+
│ ├── api/ # FastAPI app + routes (thin HTTP layer)
|
|
85
|
+
│ │ ├── app.py # create_app(store=...) factory
|
|
86
|
+
│ │ └── routes/ # sessions, events, stream
|
|
87
|
+
│ ├── core/ # domain — session log, workspace, security, SessionStore
|
|
88
|
+
│ ├── agent/ # harness loop + tool execution
|
|
89
|
+
│ ├── providers/ # LLMProvider protocol + claude_cli / anthropic_api / fake
|
|
90
|
+
│ └── cli.py # `mad` console entry-point
|
|
91
|
+
├── specs/v0.1/ # spec-driven package for the current milestone
|
|
92
|
+
└── tests/ # pytest acceptance + security tests
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Hard rules and conventions that govern every change live in [`CLAUDE.md`](CLAUDE.md).
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
- [`specs/v0.1/`](specs/v0.1/README.md) — spec-driven development package for v0.1 (requirements, design, API contract, implementation plan).
|
|
100
|
+
- [`docs/backlog.md`](docs/backlog.md) — known improvements deferred past v0.1.
|
|
101
|
+
- [`docs/sandbox-bwrap.md`](docs/sandbox-bwrap.md) — hardening guide for the execution sandbox using bubblewrap.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
See [`LICENSE`](LICENSE).
|
mad_bros-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Mad About
|
|
2
|
+
|
|
3
|
+
> That's mad!
|
|
4
|
+
|
|
5
|
+
**M**ulti **A**gent **D**evelop — a multi-agent system designed to build software autonomously. It takes an idea and drives it end-to-end: from the first line of code to a working product.
|
|
6
|
+
|
|
7
|
+
## What is this?
|
|
8
|
+
|
|
9
|
+
Mad About orchestrates a team of AI agents that collaborate to design, implement, test, and ship software without a human in the loop for every step. You give it a goal; it figures out the rest.
|
|
10
|
+
|
|
11
|
+
## Status
|
|
12
|
+
|
|
13
|
+
Early days. The first milestone is **Mad** — a self-hosted API that provisions workspaces, clones repos, and runs Claude agents autonomously against them. See [`specs/v0.1/`](specs/v0.1/README.md) for the full spec-driven package.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Mad ships as a pip-installable Python package (`mad`). From a checkout:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
make install # create venv + editable install with dev deps
|
|
21
|
+
make test # run the pytest suite
|
|
22
|
+
make serve # uvicorn factory (override HOST=/PORT= if needed)
|
|
23
|
+
make help # list every target
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
All commands are wrapped by the `Makefile`; the raw equivalents live in `pyproject.toml` (the `mad` console script) and in the project documentation.
|
|
27
|
+
|
|
28
|
+
## Project structure
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
mad/
|
|
32
|
+
├── pyproject.toml # package metadata, deps, `mad` console script
|
|
33
|
+
├── src/mad/
|
|
34
|
+
│ ├── api/ # FastAPI app + routes (thin HTTP layer)
|
|
35
|
+
│ │ ├── app.py # create_app(store=...) factory
|
|
36
|
+
│ │ └── routes/ # sessions, events, stream
|
|
37
|
+
│ ├── core/ # domain — session log, workspace, security, SessionStore
|
|
38
|
+
│ ├── agent/ # harness loop + tool execution
|
|
39
|
+
│ ├── providers/ # LLMProvider protocol + claude_cli / anthropic_api / fake
|
|
40
|
+
│ └── cli.py # `mad` console entry-point
|
|
41
|
+
├── specs/v0.1/ # spec-driven package for the current milestone
|
|
42
|
+
└── tests/ # pytest acceptance + security tests
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Hard rules and conventions that govern every change live in [`CLAUDE.md`](CLAUDE.md).
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
- [`specs/v0.1/`](specs/v0.1/README.md) — spec-driven development package for v0.1 (requirements, design, API contract, implementation plan).
|
|
50
|
+
- [`docs/backlog.md`](docs/backlog.md) — known improvements deferred past v0.1.
|
|
51
|
+
- [`docs/sandbox-bwrap.md`](docs/sandbox-bwrap.md) — hardening guide for the execution sandbox using bubblewrap.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
See [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mad-bros"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Multi Agent Develop — self-hosted autonomous Claude sessions against GitHub repos."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Jose Salamanca", email = "jose.salamancacoy@gmail.com" },
|
|
13
|
+
{ name = "Cristian Moreno", email = "cristianfmoreno95@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
keywords = ["claude", "agents", "automation", "github"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
24
|
+
"Framework :: FastAPI",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"fastapi>=0.110",
|
|
28
|
+
"uvicorn[standard]>=0.29",
|
|
29
|
+
"anthropic>=0.39",
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"build>=1.2",
|
|
38
|
+
"twine>=5.1",
|
|
39
|
+
"python-semantic-release>=9.8",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
mad = "mad.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/jlsaco/mad"
|
|
47
|
+
Issues = "https://github.com/jlsaco/mad/issues"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/mad"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = [
|
|
54
|
+
"src/mad",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"CHANGELOG.md",
|
|
58
|
+
"pyproject.toml",
|
|
59
|
+
]
|
|
60
|
+
exclude = [
|
|
61
|
+
"venv",
|
|
62
|
+
".venv",
|
|
63
|
+
"sessions",
|
|
64
|
+
"tests/fixtures/tmp",
|
|
65
|
+
"**/__pycache__",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
asyncio_mode = "auto"
|
|
71
|
+
addopts = "-ra"
|
|
72
|
+
pythonpath = ["src"]
|
|
73
|
+
|
|
74
|
+
[tool.semantic_release]
|
|
75
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
76
|
+
version_variables = ["src/mad/__init__.py:__version__"]
|
|
77
|
+
branch = "main"
|
|
78
|
+
upload_to_vcs_release = true
|
|
79
|
+
build_command = "python -m pip install build && python -m build"
|
|
80
|
+
commit_message = "chore(release): {version}\n\nCo-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
|
81
|
+
commit_parser = "conventional"
|
|
82
|
+
major_on_zero = false
|
|
83
|
+
tag_format = "v{version}"
|
|
84
|
+
|
|
85
|
+
[tool.semantic_release.changelog]
|
|
86
|
+
changelog_file = "CHANGELOG.md"
|
|
87
|
+
|
|
88
|
+
[tool.semantic_release.branches.main]
|
|
89
|
+
match = "main"
|
|
90
|
+
prerelease = false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Mad v0.1 — Spec
|
|
2
|
+
|
|
3
|
+
This folder is the **spec-driven development** package for the first functional version of **Mad**: a self-hosted system that replicates the core of Anthropic Managed Agents on your own hardware.
|
|
4
|
+
|
|
5
|
+
Mad receives a JSON request that defines an agent and a set of resources (GitHub repositories, inline files), provisions a local workspace, clones the repos, and runs an autonomous session where Claude (via headless CLI or API) works against them.
|
|
6
|
+
|
|
7
|
+
## How to read this spec
|
|
8
|
+
|
|
9
|
+
Read the files in order. Each one answers a different question.
|
|
10
|
+
|
|
11
|
+
| File | Question it answers |
|
|
12
|
+
|---|---|
|
|
13
|
+
| [`requirements.md`](requirements.md) | **What** must be true for v0.1 to be considered done? Functional requirements, constraints, and the MVP acceptance criteria. |
|
|
14
|
+
| [`design.md`](design.md) | **How** does it work internally? Architecture, components, end-to-end request flow. |
|
|
15
|
+
| [`api.md`](api.md) | **What does the outside see?** HTTP contract: endpoints, request/response schemas, headers, events. |
|
|
16
|
+
| [`plan.md`](plan.md) | **How do we build it?** Implementation rules, stack, conventions, out-of-scope items. |
|
|
17
|
+
|
|
18
|
+
## Related
|
|
19
|
+
|
|
20
|
+
- [`../../docs/backlog.md`](../../docs/backlog.md) — improvements deliberately deferred past v0.1.
|
|
21
|
+
- [`../../docs/sandbox-bwrap.md`](../../docs/sandbox-bwrap.md) — hardening guide for the execution sandbox.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mad.agent.tools import AGENT_TOOLS, execute_tool
|
|
4
|
+
from mad.core import log
|
|
5
|
+
from mad.core.sessions import SessionStore
|
|
6
|
+
from mad.providers import factory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def run_agent_loop(
|
|
10
|
+
store: SessionStore,
|
|
11
|
+
session_id: str,
|
|
12
|
+
session: dict,
|
|
13
|
+
user_message: str,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Run the agent loop for one user message.
|
|
16
|
+
|
|
17
|
+
Every event is appended to the session log (source of truth — hard rule 6)
|
|
18
|
+
and pushed to the SSE queue so subscribers see it in real time.
|
|
19
|
+
"""
|
|
20
|
+
store.emit_and_push(session_id, "session.status_running")
|
|
21
|
+
session["status"] = "running"
|
|
22
|
+
|
|
23
|
+
provider = factory.get_provider(session["agent"]["provider"])
|
|
24
|
+
system = session["agent"].get("system", "")
|
|
25
|
+
|
|
26
|
+
messages: list[dict] = []
|
|
27
|
+
for event in log.get_events(session_id):
|
|
28
|
+
if event["type"] == "user.message":
|
|
29
|
+
messages.append({"role": "user", "content": event["content"]})
|
|
30
|
+
elif event["type"] == "agent.message" and event.get("content"):
|
|
31
|
+
messages.append({"role": "assistant", "content": event["content"]})
|
|
32
|
+
|
|
33
|
+
if not messages or messages[-1] != {"role": "user", "content": user_message}:
|
|
34
|
+
messages.append({"role": "user", "content": user_message})
|
|
35
|
+
|
|
36
|
+
stop_reason = "end_turn"
|
|
37
|
+
try:
|
|
38
|
+
while True:
|
|
39
|
+
response = await provider.complete(system=system, messages=messages, tools=AGENT_TOOLS)
|
|
40
|
+
stop_reason = response.stop_reason
|
|
41
|
+
|
|
42
|
+
if response.text:
|
|
43
|
+
store.emit_and_push(session_id, "agent.message", {"content": response.text})
|
|
44
|
+
messages.append({"role": "assistant", "content": response.text})
|
|
45
|
+
|
|
46
|
+
if not response.tool_uses:
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
# Hard rule 1: only structured tool_use blocks are honored.
|
|
50
|
+
tool_results = []
|
|
51
|
+
for tu in response.tool_uses:
|
|
52
|
+
store.emit_and_push(session_id, "agent.tool_use", {
|
|
53
|
+
"tool": tu.name,
|
|
54
|
+
"input": tu.input,
|
|
55
|
+
"tool_use_id": tu.id,
|
|
56
|
+
})
|
|
57
|
+
result = execute_tool(session_id, tu)
|
|
58
|
+
store.emit_and_push(session_id, "agent.tool_result", {
|
|
59
|
+
"tool": tu.name,
|
|
60
|
+
"result": result,
|
|
61
|
+
"tool_use_id": tu.id,
|
|
62
|
+
})
|
|
63
|
+
tool_results.append({
|
|
64
|
+
"type": "tool_result",
|
|
65
|
+
"tool_use_id": tu.id,
|
|
66
|
+
"content": result,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
messages.append({"role": "user", "content": tool_results})
|
|
70
|
+
|
|
71
|
+
if stop_reason != "tool_use":
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
store.emit_and_push(session_id, "session.error", {"error": str(exc)})
|
|
76
|
+
stop_reason = "error"
|
|
77
|
+
|
|
78
|
+
store.emit_and_push(session_id, "session.status_idle", {"stop_reason": stop_reason})
|
|
79
|
+
session["status"] = "idle"
|
|
80
|
+
|
|
81
|
+
q = store.sse_queues.get(session_id)
|
|
82
|
+
if q is not None:
|
|
83
|
+
await q.put(None)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from mad.core.workspace import workspace_path
|
|
6
|
+
from mad.providers.base import ToolUse
|
|
7
|
+
|
|
8
|
+
AGENT_TOOLS = [
|
|
9
|
+
{
|
|
10
|
+
"name": "bash",
|
|
11
|
+
"description": "Execute a bash command in the workspace sandbox.",
|
|
12
|
+
"input_schema": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {"command": {"type": "string"}},
|
|
15
|
+
"required": ["command"],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "read_file",
|
|
20
|
+
"description": "Read a file from the workspace.",
|
|
21
|
+
"input_schema": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {"path": {"type": "string"}},
|
|
24
|
+
"required": ["path"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "write_file",
|
|
29
|
+
"description": "Write content to a file in the workspace.",
|
|
30
|
+
"input_schema": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
33
|
+
"required": ["path", "content"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def execute_tool(session_id: str, tool_use: ToolUse) -> str:
|
|
40
|
+
"""Execute a structured tool call in the session workspace."""
|
|
41
|
+
workspace = workspace_path(session_id)
|
|
42
|
+
if tool_use.name == "bash":
|
|
43
|
+
command = tool_use.input.get("command", "")
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
command, shell=True, cwd=str(workspace),
|
|
46
|
+
capture_output=True, text=True, timeout=60,
|
|
47
|
+
)
|
|
48
|
+
return result.stdout + (("\n" + result.stderr) if result.stderr else "")
|
|
49
|
+
if tool_use.name == "read_file":
|
|
50
|
+
path = workspace / tool_use.input.get("path", "").lstrip("/")
|
|
51
|
+
try:
|
|
52
|
+
return path.read_text()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
return f"error: {exc}"
|
|
55
|
+
if tool_use.name == "write_file":
|
|
56
|
+
path = workspace / tool_use.input.get("path", "").lstrip("/")
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path.write_text(tool_use.input.get("content", ""))
|
|
59
|
+
return "ok"
|
|
60
|
+
return f"unknown tool: {tool_use.name}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from mad.api.routes.sessions import router as sessions_router
|
|
6
|
+
from mad.core import log
|
|
7
|
+
from mad.core.sessions import SessionStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_app(store: SessionStore | None = None) -> FastAPI:
|
|
11
|
+
"""Build a FastAPI app with an injected SessionStore.
|
|
12
|
+
|
|
13
|
+
Every call creates an isolated instance — tests get a fresh store
|
|
14
|
+
so state never leaks across cases.
|
|
15
|
+
"""
|
|
16
|
+
app = FastAPI(title="Mad", version="0.1.0")
|
|
17
|
+
app.state.store = store or SessionStore()
|
|
18
|
+
|
|
19
|
+
@app.on_event("startup")
|
|
20
|
+
async def _startup() -> None:
|
|
21
|
+
log.ensure_sessions_dir()
|
|
22
|
+
|
|
23
|
+
app.include_router(sessions_router)
|
|
24
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Header, HTTPException, Request
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
|
|
12
|
+
from mad.agent.loop import run_agent_loop
|
|
13
|
+
from mad.core import log
|
|
14
|
+
from mad.core.resources import provision_file, provision_github_repo
|
|
15
|
+
from mad.core.security import validate_mount_path
|
|
16
|
+
from mad.core.sessions import SessionStore
|
|
17
|
+
from mad.core.workspace import workspace_path
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _store(request: Request) -> SessionStore:
|
|
23
|
+
return request.app.state.store
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post("/v1/sessions")
|
|
27
|
+
async def create_session(
|
|
28
|
+
request: Request,
|
|
29
|
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
30
|
+
) -> dict:
|
|
31
|
+
log.ensure_sessions_dir()
|
|
32
|
+
store = _store(request)
|
|
33
|
+
|
|
34
|
+
if idempotency_key and idempotency_key in store.idempotency:
|
|
35
|
+
existing_id = store.idempotency[idempotency_key]
|
|
36
|
+
return store.sessions[existing_id]["response"]
|
|
37
|
+
|
|
38
|
+
body = await request.json()
|
|
39
|
+
agent = body["agent"]
|
|
40
|
+
resources = body.get("resources", [])
|
|
41
|
+
|
|
42
|
+
for res in resources:
|
|
43
|
+
validate_mount_path(res["mount_path"])
|
|
44
|
+
|
|
45
|
+
session_id = "sesn_" + uuid.uuid4().hex[:12]
|
|
46
|
+
workspace = workspace_path(session_id)
|
|
47
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
log.emit(session_id, "session.created", {"agent": agent["name"]})
|
|
50
|
+
|
|
51
|
+
resources_mounted = []
|
|
52
|
+
for res in resources:
|
|
53
|
+
if res["type"] == "github_repository":
|
|
54
|
+
mounted = provision_github_repo(session_id, res)
|
|
55
|
+
elif res["type"] == "file":
|
|
56
|
+
mounted = provision_file(session_id, res)
|
|
57
|
+
else:
|
|
58
|
+
raise HTTPException(status_code=400, detail=f"Unknown resource type: {res['type']!r}")
|
|
59
|
+
resources_mounted.append(mounted)
|
|
60
|
+
|
|
61
|
+
response = {
|
|
62
|
+
"session_id": session_id,
|
|
63
|
+
"status": "created",
|
|
64
|
+
"workspace": str(workspace),
|
|
65
|
+
"resources_mounted": resources_mounted,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
store.sessions[session_id] = {
|
|
69
|
+
"session_id": session_id,
|
|
70
|
+
"agent": agent,
|
|
71
|
+
"workspace": str(workspace),
|
|
72
|
+
"status": "created",
|
|
73
|
+
"response": response,
|
|
74
|
+
}
|
|
75
|
+
store.get_or_create_queue(session_id)
|
|
76
|
+
|
|
77
|
+
if idempotency_key:
|
|
78
|
+
store.idempotency[idempotency_key] = session_id
|
|
79
|
+
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.post("/v1/sessions/{session_id}/events")
|
|
84
|
+
async def send_events(session_id: str, request: Request) -> dict:
|
|
85
|
+
store = _store(request)
|
|
86
|
+
if session_id not in store.sessions:
|
|
87
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
88
|
+
|
|
89
|
+
body = await request.json()
|
|
90
|
+
events = body.get("events", [])
|
|
91
|
+
session = store.sessions[session_id]
|
|
92
|
+
|
|
93
|
+
for event in events:
|
|
94
|
+
event_type = event.get("type")
|
|
95
|
+
if event_type == "user.message":
|
|
96
|
+
content = event.get("content", "")
|
|
97
|
+
log.emit(session_id, "user.message", {"content": content})
|
|
98
|
+
asyncio.create_task(run_agent_loop(store, session_id, session, content))
|
|
99
|
+
|
|
100
|
+
return {"status": "accepted"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get("/v1/sessions/{session_id}/stream")
|
|
104
|
+
async def stream_session(session_id: str, request: Request) -> StreamingResponse:
|
|
105
|
+
store = _store(request)
|
|
106
|
+
if session_id not in store.sessions:
|
|
107
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
108
|
+
|
|
109
|
+
queue = store.get_or_create_queue(session_id)
|
|
110
|
+
|
|
111
|
+
async def event_generator():
|
|
112
|
+
while True:
|
|
113
|
+
event = await queue.get()
|
|
114
|
+
if event is None:
|
|
115
|
+
break
|
|
116
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
117
|
+
|
|
118
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.get("/v1/sessions/{session_id}")
|
|
122
|
+
async def get_session(session_id: str, request: Request) -> dict:
|
|
123
|
+
store = _store(request)
|
|
124
|
+
if session_id not in store.sessions:
|
|
125
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
126
|
+
session = store.sessions[session_id]
|
|
127
|
+
events = log.get_events(session_id)
|
|
128
|
+
return {
|
|
129
|
+
"session_id": session_id,
|
|
130
|
+
"status": session["status"],
|
|
131
|
+
"workspace": session["workspace"],
|
|
132
|
+
"events": events,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.get("/v1/sessions")
|
|
137
|
+
async def list_sessions(request: Request) -> list:
|
|
138
|
+
store = _store(request)
|
|
139
|
+
return [
|
|
140
|
+
{"session_id": sid, "status": s["status"]}
|
|
141
|
+
for sid, s in store.sessions.items()
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.delete("/v1/sessions/{session_id}")
|
|
146
|
+
async def delete_session(session_id: str, request: Request) -> dict:
|
|
147
|
+
store = _store(request)
|
|
148
|
+
if session_id not in store.sessions:
|
|
149
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
150
|
+
|
|
151
|
+
session = store.sessions[session_id]
|
|
152
|
+
workspace = Path(session["workspace"])
|
|
153
|
+
|
|
154
|
+
if workspace.exists():
|
|
155
|
+
shutil.rmtree(workspace)
|
|
156
|
+
|
|
157
|
+
session["status"] = "deleted"
|
|
158
|
+
store.sse_queues.pop(session_id, None)
|
|
159
|
+
|
|
160
|
+
return {"status": "deleted", "session_id": session_id}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
argv = sys.argv[1:]
|
|
8
|
+
if not argv or argv[0] in {"-h", "--help"}:
|
|
9
|
+
print("usage: mad serve [--host HOST] [--port PORT]")
|
|
10
|
+
return
|
|
11
|
+
if argv[0] == "serve":
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
host = "0.0.0.0"
|
|
15
|
+
port = 8000
|
|
16
|
+
it = iter(argv[1:])
|
|
17
|
+
for token in it:
|
|
18
|
+
if token == "--host":
|
|
19
|
+
host = next(it)
|
|
20
|
+
elif token == "--port":
|
|
21
|
+
port = int(next(it))
|
|
22
|
+
uvicorn.run("mad.api.app:create_app", host=host, port=port, factory=True)
|
|
23
|
+
return
|
|
24
|
+
print(f"unknown command: {argv[0]!r}", file=sys.stderr)
|
|
25
|
+
sys.exit(2)
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
SESSIONS_DIR = Path("sessions")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_sessions_dir() -> None:
|
|
12
|
+
SESSIONS_DIR.mkdir(exist_ok=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_path(session_id: str) -> Path:
|
|
16
|
+
return SESSIONS_DIR / f"{session_id}.jsonl"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit(session_id: str, event_type: str, data: dict[str, Any] | None = None) -> dict:
|
|
20
|
+
"""Print an event to stdout AND append it to the session JSONL log.
|
|
21
|
+
|
|
22
|
+
The log is the source of truth (CLAUDE.md hard rule 6).
|
|
23
|
+
"""
|
|
24
|
+
event = {"type": event_type, "timestamp": datetime.now(timezone.utc).isoformat()}
|
|
25
|
+
if data:
|
|
26
|
+
event.update(data)
|
|
27
|
+
line = json.dumps(event)
|
|
28
|
+
print(line)
|
|
29
|
+
ensure_sessions_dir()
|
|
30
|
+
with log_path(session_id).open("a") as f:
|
|
31
|
+
f.write(line + "\n")
|
|
32
|
+
return event
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_events(session_id: str) -> list[dict]:
|
|
36
|
+
p = log_path(session_id)
|
|
37
|
+
if not p.exists():
|
|
38
|
+
return []
|
|
39
|
+
events: list[dict] = []
|
|
40
|
+
for ln in p.read_text().splitlines():
|
|
41
|
+
ln = ln.strip()
|
|
42
|
+
if ln:
|
|
43
|
+
events.append(json.loads(ln))
|
|
44
|
+
return events
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from mad.core.workspace import local_path_for_mount
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def provision_github_repo(session_id: str, resource: dict) -> dict:
|
|
10
|
+
url: str = resource["url"]
|
|
11
|
+
mount_path: str = resource["mount_path"]
|
|
12
|
+
token: str | None = resource.get("authorization_token")
|
|
13
|
+
checkout: dict | None = resource.get("checkout")
|
|
14
|
+
|
|
15
|
+
local_path = local_path_for_mount(session_id, mount_path)
|
|
16
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
clone_url = url
|
|
19
|
+
if token and url.startswith("https://"):
|
|
20
|
+
clone_url = url.replace("https://", f"https://{token}@", 1)
|
|
21
|
+
|
|
22
|
+
cmd = ["git", "clone", "-q", clone_url, str(local_path)]
|
|
23
|
+
if checkout and checkout.get("type") == "branch":
|
|
24
|
+
cmd = ["git", "clone", "-q", "-b", checkout["name"], clone_url, str(local_path)]
|
|
25
|
+
|
|
26
|
+
shutil.rmtree(local_path)
|
|
27
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
28
|
+
|
|
29
|
+
# Strip token from remote after clone (CLAUDE.md hard rule 2)
|
|
30
|
+
subprocess.run(
|
|
31
|
+
["git", "-C", str(local_path), "remote", "set-url", "origin", url],
|
|
32
|
+
check=True,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"type": "github_repository",
|
|
38
|
+
"url": url,
|
|
39
|
+
"mount_path": mount_path,
|
|
40
|
+
"local_path": str(local_path),
|
|
41
|
+
"status": "cloned",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def provision_file(session_id: str, resource: dict) -> dict:
|
|
46
|
+
mount_path: str = resource["mount_path"]
|
|
47
|
+
content: str = resource.get("content", "")
|
|
48
|
+
local_path = local_path_for_mount(session_id, mount_path)
|
|
49
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
local_path.write_text(content)
|
|
51
|
+
return {
|
|
52
|
+
"type": "file",
|
|
53
|
+
"mount_path": mount_path,
|
|
54
|
+
"local_path": str(local_path),
|
|
55
|
+
"status": "written",
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
|
|
7
|
+
WORKSPACE_PREFIX = "/workspace"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_mount_path(mount_path: str) -> None:
|
|
11
|
+
"""Reject any mount_path that doesn't resolve inside /workspace.
|
|
12
|
+
|
|
13
|
+
Enforces CLAUDE.md hard rule 3 (path traversal prevention).
|
|
14
|
+
"""
|
|
15
|
+
if not mount_path.startswith("/"):
|
|
16
|
+
raise HTTPException(status_code=400, detail=f"mount_path {mount_path!r} must be absolute")
|
|
17
|
+
pure = PurePosixPath(mount_path)
|
|
18
|
+
stack: list[str] = []
|
|
19
|
+
for part in pure.parts[1:]:
|
|
20
|
+
if part == "..":
|
|
21
|
+
if not stack:
|
|
22
|
+
raise HTTPException(status_code=400, detail=f"mount_path {mount_path!r} escapes workspace")
|
|
23
|
+
stack.pop()
|
|
24
|
+
elif part and part != ".":
|
|
25
|
+
stack.append(part)
|
|
26
|
+
logical = "/" + "/".join(stack)
|
|
27
|
+
if not (logical == WORKSPACE_PREFIX or logical.startswith(WORKSPACE_PREFIX + "/")):
|
|
28
|
+
raise HTTPException(status_code=400, detail=f"mount_path {mount_path!r} escapes workspace")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from mad.core import log
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionStore:
|
|
9
|
+
"""Holds all per-process session state. Injected into create_app() so
|
|
10
|
+
tests (and other embeddings) get a fresh instance with no global leakage.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.sessions: dict[str, dict] = {}
|
|
15
|
+
self.idempotency: dict[str, str] = {}
|
|
16
|
+
self.sse_queues: dict[str, asyncio.Queue] = {}
|
|
17
|
+
|
|
18
|
+
def get_or_create_queue(self, session_id: str) -> asyncio.Queue:
|
|
19
|
+
if session_id not in self.sse_queues:
|
|
20
|
+
self.sse_queues[session_id] = asyncio.Queue()
|
|
21
|
+
return self.sse_queues[session_id]
|
|
22
|
+
|
|
23
|
+
def push_event(self, session_id: str, event: dict) -> None:
|
|
24
|
+
q = self.sse_queues.get(session_id)
|
|
25
|
+
if q is not None:
|
|
26
|
+
q.put_nowait(event)
|
|
27
|
+
|
|
28
|
+
def emit_and_push(self, session_id: str, event_type: str, data: dict | None = None) -> dict:
|
|
29
|
+
event = log.emit(session_id, event_type, data)
|
|
30
|
+
self.push_event(session_id, event)
|
|
31
|
+
return event
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def workspace_path(session_id: str) -> Path:
|
|
8
|
+
return Path(tempfile.gettempdir()) / f"mad_{session_id}"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def local_path_for_mount(session_id: str, mount_path: str) -> Path:
|
|
12
|
+
"""Map a /workspace/... mount_path into the real temp workspace directory."""
|
|
13
|
+
relative = mount_path.lstrip("/")
|
|
14
|
+
if relative.startswith("workspace/") or relative == "workspace":
|
|
15
|
+
relative = relative[len("workspace"):]
|
|
16
|
+
relative = relative.lstrip("/")
|
|
17
|
+
base = workspace_path(session_id)
|
|
18
|
+
if relative:
|
|
19
|
+
return base / relative
|
|
20
|
+
return base
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mad.providers.base import LLMProvider, ProviderResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AnthropicAPIProvider(LLMProvider):
|
|
7
|
+
async def complete(self, system: str, messages: list[dict], tools: list[dict]) -> ProviderResponse:
|
|
8
|
+
raise NotImplementedError("AnthropicAPIProvider not implemented in MVP")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ToolUse:
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
input: dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ProviderResponse:
|
|
16
|
+
text: str | None = None
|
|
17
|
+
tool_uses: list[ToolUse] = field(default_factory=list)
|
|
18
|
+
stop_reason: str = "end_turn"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMProvider(Protocol):
|
|
22
|
+
async def complete(
|
|
23
|
+
self,
|
|
24
|
+
system: str,
|
|
25
|
+
messages: list[dict],
|
|
26
|
+
tools: list[dict],
|
|
27
|
+
) -> ProviderResponse: ...
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mad.providers.base import LLMProvider, ProviderResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClaudeCLIProvider(LLMProvider):
|
|
7
|
+
async def complete(self, system: str, messages: list[dict], tools: list[dict]) -> ProviderResponse:
|
|
8
|
+
raise NotImplementedError("ClaudeCLIProvider not implemented in MVP")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mad.providers.anthropic_api import AnthropicAPIProvider
|
|
4
|
+
from mad.providers.base import LLMProvider
|
|
5
|
+
from mad.providers.claude_cli import ClaudeCLIProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_provider(name: str) -> LLMProvider:
|
|
9
|
+
if name == "claude_cli":
|
|
10
|
+
return ClaudeCLIProvider()
|
|
11
|
+
if name == "anthropic_api":
|
|
12
|
+
return AnthropicAPIProvider()
|
|
13
|
+
raise NotImplementedError(f"Unknown provider: {name!r}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
from mad.providers.base import LLMProvider, ProviderResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeScriptedProvider(LLMProvider):
|
|
9
|
+
"""Test double that replays a pre-recorded sequence of ProviderResponses."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._queue: deque[ProviderResponse] = deque()
|
|
13
|
+
|
|
14
|
+
def script(self, responses: list[ProviderResponse]) -> None:
|
|
15
|
+
self._queue = deque(responses)
|
|
16
|
+
|
|
17
|
+
async def complete(self, system: str, messages: list[dict], tools: list[dict]) -> ProviderResponse:
|
|
18
|
+
if not self._queue:
|
|
19
|
+
return ProviderResponse(text="(fake provider exhausted)", stop_reason="end_turn")
|
|
20
|
+
return self._queue.popleft()
|