agentpanel 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.
- agentpanel-0.1.0/.gitignore +22 -0
- agentpanel-0.1.0/LICENSE +21 -0
- agentpanel-0.1.0/PKG-INFO +113 -0
- agentpanel-0.1.0/README.md +77 -0
- agentpanel-0.1.0/pyproject.toml +49 -0
- agentpanel-0.1.0/src/agentpanel/__init__.py +22 -0
- agentpanel-0.1.0/src/agentpanel/agents.py +38 -0
- agentpanel-0.1.0/src/agentpanel/config.py +126 -0
- agentpanel-0.1.0/src/agentpanel/graph.py +180 -0
- agentpanel-0.1.0/src/agentpanel/llm.py +139 -0
- agentpanel-0.1.0/src/agentpanel/llm_agents.py +98 -0
- agentpanel-0.1.0/src/agentpanel/py.typed +0 -0
- agentpanel-0.1.0/src/agentpanel/state.py +84 -0
- agentpanel-0.1.0/src/agentpanel/structured.py +146 -0
- agentpanel-0.1.0/src/agentpanel/testing.py +44 -0
- agentpanel-0.1.0/tests/conftest.py +12 -0
- agentpanel-0.1.0/tests/test_boundaries.py +30 -0
- agentpanel-0.1.0/tests/test_config.py +83 -0
- agentpanel-0.1.0/tests/test_graph.py +193 -0
- agentpanel-0.1.0/tests/test_live_ollama.py +30 -0
- agentpanel-0.1.0/tests/test_llm.py +92 -0
- agentpanel-0.1.0/tests/test_llm_agents.py +92 -0
- agentpanel-0.1.0/tests/test_structured.py +71 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# secrets: never commit real keys
|
|
15
|
+
.env
|
|
16
|
+
|
|
17
|
+
# generated harnesses
|
|
18
|
+
shakedown-output/
|
|
19
|
+
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
.DS_Store
|
agentpanel-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anzar Ahsan
|
|
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,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentpanel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Domain-agnostic multi-agent orchestration core (LangGraph) with pluggable local/cloud LLM providers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/anzar-ahsan-commits/api-shakedown/tree/main/packages/agentpanel
|
|
6
|
+
Project-URL: Source, https://github.com/anzar-ahsan-commits/api-shakedown
|
|
7
|
+
Project-URL: Issues, https://github.com/anzar-ahsan-commits/api-shakedown/issues
|
|
8
|
+
Author: Anzar Ahsan
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,langgraph,llm,multi-agent,ollama,structured-output
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
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 :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: langchain-core>=1.0
|
|
24
|
+
Requires-Dist: langchain-ollama>=1.0
|
|
25
|
+
Requires-Dist: langgraph>=1.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Provides-Extra: anthropic
|
|
29
|
+
Requires-Dist: langchain-anthropic>=1.0; extra == 'anthropic'
|
|
30
|
+
Provides-Extra: cloud
|
|
31
|
+
Requires-Dist: langchain-anthropic>=1.0; extra == 'cloud'
|
|
32
|
+
Requires-Dist: langchain-openai>=1.0; extra == 'cloud'
|
|
33
|
+
Provides-Extra: openai
|
|
34
|
+
Requires-Dist: langchain-openai>=1.0; extra == 'openai'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# agentpanel
|
|
38
|
+
|
|
39
|
+
A small, domain-agnostic core for multi-agent LLM workflows on
|
|
40
|
+
[LangGraph](https://github.com/langchain-ai/langgraph):
|
|
41
|
+
|
|
42
|
+
- **Specialists** produce artifacts in parallel.
|
|
43
|
+
- **Reviewers** judge each other's artifacts; rejections send the author back to revise,
|
|
44
|
+
with the feedback, for a bounded number of rounds.
|
|
45
|
+
- A **coordinator** assembles the result, including anything still unresolved.
|
|
46
|
+
|
|
47
|
+
It runs on local models by default ([Ollama](https://ollama.com)), with bring-your-own-key
|
|
48
|
+
OpenAI and Anthropic support, and gets reliable structured output from small models.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install agentpanel # Ollama only
|
|
52
|
+
pip install "agentpanel[cloud]" # plus OpenAI and Anthropic
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Example
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from agentpanel import AgentContext, LLMReviewer, LLMSpecialist, Workflow
|
|
60
|
+
from agentpanel.config import LLMSettings
|
|
61
|
+
from agentpanel.llm import create_chat_model
|
|
62
|
+
|
|
63
|
+
model = create_chat_model(LLMSettings()) # qwen3:8b on local Ollama by default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Join:
|
|
67
|
+
async def assemble(self, ctx: AgentContext) -> str:
|
|
68
|
+
return ctx.artifacts["writer"].content
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
workflow = Workflow(
|
|
72
|
+
specialists=[
|
|
73
|
+
LLMSpecialist(
|
|
74
|
+
name="writer",
|
|
75
|
+
kind="product description",
|
|
76
|
+
model=model,
|
|
77
|
+
instructions="You write concise product descriptions.",
|
|
78
|
+
render=lambda ctx: f"Describe {ctx.inputs['product']} in two sentences.",
|
|
79
|
+
)
|
|
80
|
+
],
|
|
81
|
+
reviewers=[LLMReviewer("editor", model, "You edit marketing copy for accuracy.")],
|
|
82
|
+
coordinator=Join(),
|
|
83
|
+
max_revisions=1,
|
|
84
|
+
)
|
|
85
|
+
result = asyncio.run(workflow.run({"product": "a solar-powered kettle"}))
|
|
86
|
+
print(result.result, result.unresolved)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Pieces
|
|
90
|
+
|
|
91
|
+
| Module | What it gives you |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `agentpanel.graph` | `Workflow`: parallel specialists, cross-review, bounded revision, progress callbacks |
|
|
94
|
+
| `agentpanel.agents` | `Specialist`, `Reviewer`, `Coordinator` protocols: bring any implementation |
|
|
95
|
+
| `agentpanel.llm_agents` | `LLMSpecialist`, `LLMReviewer`: prompt-driven agents with revision handling |
|
|
96
|
+
| `agentpanel.structured` | `ainvoke_structured`: pydantic-validated output with error feedback and domain `check`s; constrained decoding on Ollama |
|
|
97
|
+
| `agentpanel.llm` | `create_chat_model`, `check_provider`: provider factory and health checks |
|
|
98
|
+
| `agentpanel.config` | `LLMSettings`: env/.env settings, per-agent model and reasoning overrides |
|
|
99
|
+
| `agentpanel.testing` | `ScriptedChatModel`: a deterministic fake chat model for tests |
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
`LLMSettings` reads `AGENTPANEL_*` environment variables (subclass it to use your own
|
|
104
|
+
prefix): `PROVIDER` (`ollama`, `openai`, `anthropic`), `MODEL`, `BASE_URL`, `TIMEOUT`,
|
|
105
|
+
`REASONING`, `CONTEXT_WINDOW`, `AGENT_MODELS`, `AGENT_REASONING`. Cloud keys use the
|
|
106
|
+
standard `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`.
|
|
107
|
+
|
|
108
|
+
Built for and used by [API Shakedown](https://github.com/anzar-ahsan-commits/api-shakedown), where the
|
|
109
|
+
source lives. Issues and contributions: see that repository.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# agentpanel
|
|
2
|
+
|
|
3
|
+
A small, domain-agnostic core for multi-agent LLM workflows on
|
|
4
|
+
[LangGraph](https://github.com/langchain-ai/langgraph):
|
|
5
|
+
|
|
6
|
+
- **Specialists** produce artifacts in parallel.
|
|
7
|
+
- **Reviewers** judge each other's artifacts; rejections send the author back to revise,
|
|
8
|
+
with the feedback, for a bounded number of rounds.
|
|
9
|
+
- A **coordinator** assembles the result, including anything still unresolved.
|
|
10
|
+
|
|
11
|
+
It runs on local models by default ([Ollama](https://ollama.com)), with bring-your-own-key
|
|
12
|
+
OpenAI and Anthropic support, and gets reliable structured output from small models.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install agentpanel # Ollama only
|
|
16
|
+
pip install "agentpanel[cloud]" # plus OpenAI and Anthropic
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Example
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
from agentpanel import AgentContext, LLMReviewer, LLMSpecialist, Workflow
|
|
24
|
+
from agentpanel.config import LLMSettings
|
|
25
|
+
from agentpanel.llm import create_chat_model
|
|
26
|
+
|
|
27
|
+
model = create_chat_model(LLMSettings()) # qwen3:8b on local Ollama by default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Join:
|
|
31
|
+
async def assemble(self, ctx: AgentContext) -> str:
|
|
32
|
+
return ctx.artifacts["writer"].content
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
workflow = Workflow(
|
|
36
|
+
specialists=[
|
|
37
|
+
LLMSpecialist(
|
|
38
|
+
name="writer",
|
|
39
|
+
kind="product description",
|
|
40
|
+
model=model,
|
|
41
|
+
instructions="You write concise product descriptions.",
|
|
42
|
+
render=lambda ctx: f"Describe {ctx.inputs['product']} in two sentences.",
|
|
43
|
+
)
|
|
44
|
+
],
|
|
45
|
+
reviewers=[LLMReviewer("editor", model, "You edit marketing copy for accuracy.")],
|
|
46
|
+
coordinator=Join(),
|
|
47
|
+
max_revisions=1,
|
|
48
|
+
)
|
|
49
|
+
result = asyncio.run(workflow.run({"product": "a solar-powered kettle"}))
|
|
50
|
+
print(result.result, result.unresolved)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Pieces
|
|
54
|
+
|
|
55
|
+
| Module | What it gives you |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `agentpanel.graph` | `Workflow`: parallel specialists, cross-review, bounded revision, progress callbacks |
|
|
58
|
+
| `agentpanel.agents` | `Specialist`, `Reviewer`, `Coordinator` protocols: bring any implementation |
|
|
59
|
+
| `agentpanel.llm_agents` | `LLMSpecialist`, `LLMReviewer`: prompt-driven agents with revision handling |
|
|
60
|
+
| `agentpanel.structured` | `ainvoke_structured`: pydantic-validated output with error feedback and domain `check`s; constrained decoding on Ollama |
|
|
61
|
+
| `agentpanel.llm` | `create_chat_model`, `check_provider`: provider factory and health checks |
|
|
62
|
+
| `agentpanel.config` | `LLMSettings`: env/.env settings, per-agent model and reasoning overrides |
|
|
63
|
+
| `agentpanel.testing` | `ScriptedChatModel`: a deterministic fake chat model for tests |
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
`LLMSettings` reads `AGENTPANEL_*` environment variables (subclass it to use your own
|
|
68
|
+
prefix): `PROVIDER` (`ollama`, `openai`, `anthropic`), `MODEL`, `BASE_URL`, `TIMEOUT`,
|
|
69
|
+
`REASONING`, `CONTEXT_WINDOW`, `AGENT_MODELS`, `AGENT_REASONING`. Cloud keys use the
|
|
70
|
+
standard `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`.
|
|
71
|
+
|
|
72
|
+
Built for and used by [API Shakedown](https://github.com/anzar-ahsan-commits/api-shakedown), where the
|
|
73
|
+
source lives. Issues and contributions: see that repository.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentpanel"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Domain-agnostic multi-agent orchestration core (LangGraph) with pluggable local/cloud LLM providers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Anzar Ahsan" }]
|
|
14
|
+
keywords = ["llm", "agents", "multi-agent", "langgraph", "ollama", "structured-output"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"langgraph>=1.0",
|
|
28
|
+
"langchain-core>=1.0",
|
|
29
|
+
"langchain-ollama>=1.0", # Ollama is the default provider, so it is not optional
|
|
30
|
+
"pydantic>=2.7",
|
|
31
|
+
"pydantic-settings>=2.4",
|
|
32
|
+
"httpx>=0.27",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/anzar-ahsan-commits/api-shakedown/tree/main/packages/agentpanel"
|
|
37
|
+
Source = "https://github.com/anzar-ahsan-commits/api-shakedown"
|
|
38
|
+
Issues = "https://github.com/anzar-ahsan-commits/api-shakedown/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
openai = ["langchain-openai>=1.0"]
|
|
42
|
+
anthropic = ["langchain-anthropic>=1.0"]
|
|
43
|
+
cloud = ["agentpanel[openai,anthropic]"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.version]
|
|
46
|
+
path = "src/agentpanel/__init__.py"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/agentpanel"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Domain-agnostic multi-agent orchestration core."""
|
|
2
|
+
|
|
3
|
+
from agentpanel.agents import Coordinator, Reviewer, Specialist
|
|
4
|
+
from agentpanel.graph import Workflow, WorkflowResult
|
|
5
|
+
from agentpanel.llm_agents import LLMReviewer, LLMSpecialist
|
|
6
|
+
from agentpanel.state import AgentContext, Artifact, Review, Verdict
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AgentContext",
|
|
12
|
+
"Artifact",
|
|
13
|
+
"Coordinator",
|
|
14
|
+
"LLMReviewer",
|
|
15
|
+
"LLMSpecialist",
|
|
16
|
+
"Review",
|
|
17
|
+
"Reviewer",
|
|
18
|
+
"Specialist",
|
|
19
|
+
"Verdict",
|
|
20
|
+
"Workflow",
|
|
21
|
+
"WorkflowResult",
|
|
22
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Agent contracts: specialists, reviewers, and coordinators.
|
|
2
|
+
|
|
3
|
+
Agents return plain values; the workflow stamps authorship and round numbers.
|
|
4
|
+
One object may implement several roles, e.g. a specialist that also peer-reviews.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from agentpanel.state import AgentContext, Artifact, Verdict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Specialist(Protocol):
|
|
13
|
+
"""Produces one artifact per round. On revision rounds, `ctx.feedback_for(self.name)`
|
|
14
|
+
holds the rejections to address and `ctx.artifacts[self.name]` the previous attempt."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str: ...
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def kind(self) -> str: ...
|
|
21
|
+
|
|
22
|
+
async def produce(self, ctx: AgentContext) -> Any: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Reviewer(Protocol):
|
|
26
|
+
"""Judges another agent's artifact. Never asked to review its own."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
async def review(self, artifact: Artifact, ctx: AgentContext) -> Verdict: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Coordinator(Protocol):
|
|
35
|
+
"""Assembles the final result once reviews are settled. `ctx.unresolved` lists
|
|
36
|
+
artifacts that still had rejections when the revision budget ran out."""
|
|
37
|
+
|
|
38
|
+
async def assemble(self, ctx: AgentContext) -> Any: ...
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Settings loaded from environment variables and an optional `.env` file.
|
|
2
|
+
|
|
3
|
+
Applications subclass `LLMSettings` to use their own env prefix, e.g.::
|
|
4
|
+
|
|
5
|
+
class MyAppSettings(LLMSettings):
|
|
6
|
+
model_config = SettingsConfigDict(env_prefix="MY_APP_")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal, Self, get_args
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, SecretStr
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
Provider = Literal["ollama", "openai", "anthropic"]
|
|
15
|
+
|
|
16
|
+
DEFAULT_MODELS: dict[Provider, str] = {
|
|
17
|
+
"ollama": "qwen3:8b",
|
|
18
|
+
"openai": "gpt-5-mini",
|
|
19
|
+
"anthropic": "claude-sonnet-5-5",
|
|
20
|
+
}
|
|
21
|
+
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
|
22
|
+
|
|
23
|
+
# Cloud keys use each vendor's standard variable name, not the app prefix,
|
|
24
|
+
# so keys users already have in their environment just work.
|
|
25
|
+
API_KEY_ENV_VARS: dict[Provider, str] = {
|
|
26
|
+
"openai": "OPENAI_API_KEY",
|
|
27
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LLMSettings(BaseSettings):
|
|
32
|
+
"""Which LLM to talk to and how."""
|
|
33
|
+
|
|
34
|
+
model_config = SettingsConfigDict(
|
|
35
|
+
env_prefix="AGENTPANEL_",
|
|
36
|
+
env_file=".env",
|
|
37
|
+
extra="ignore",
|
|
38
|
+
populate_by_name=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
provider: Provider = "ollama"
|
|
42
|
+
model: str | None = Field(default=None, description="Defaults per provider if unset.")
|
|
43
|
+
base_url: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Endpoint override for the selected provider, e.g. an "
|
|
46
|
+
"OpenAI-compatible server. Wins over ollama_url.",
|
|
47
|
+
)
|
|
48
|
+
ollama_url: str = Field(
|
|
49
|
+
default=DEFAULT_OLLAMA_URL,
|
|
50
|
+
description="Where Ollama runs. Only used when the provider is ollama, so it is "
|
|
51
|
+
"safe to set globally (e.g. in a container).",
|
|
52
|
+
)
|
|
53
|
+
temperature: float | None = Field(
|
|
54
|
+
default=None, description="Unset means the provider's default."
|
|
55
|
+
)
|
|
56
|
+
timeout: float | None = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Per-request timeout in seconds. Unset: 600 for Ollama (local models are "
|
|
59
|
+
"slow and serve one request at a time), 120 for cloud providers.",
|
|
60
|
+
)
|
|
61
|
+
context_window: int = Field(
|
|
62
|
+
default=16_384,
|
|
63
|
+
description="Ollama only: tokens of context. Ollama's own default is small and "
|
|
64
|
+
"silently truncates long prompts.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
reasoning: bool | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Ollama only: True/False turns a reasoning model's thinking on/off; "
|
|
70
|
+
"unset uses the model's default. Off is much faster for structured generation.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
agent_models: dict[str, str] = Field(
|
|
74
|
+
default_factory=dict,
|
|
75
|
+
description='Per-agent overrides as JSON, e.g. {"reviewer": "anthropic:claude-sonnet-5-5"} '
|
|
76
|
+
'or {"stubs": "qwen3:14b"}. A known provider prefix switches provider.',
|
|
77
|
+
)
|
|
78
|
+
agent_reasoning: dict[str, bool] = Field(
|
|
79
|
+
default_factory=dict,
|
|
80
|
+
description='Per-agent `reasoning` overrides as JSON, e.g. {"reviewer": true}.',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
openai_api_key: SecretStr | None = Field(default=None, validation_alias="OPENAI_API_KEY")
|
|
84
|
+
anthropic_api_key: SecretStr | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
|
|
85
|
+
|
|
86
|
+
def for_agent(self, name: str) -> Self:
|
|
87
|
+
"""Settings for one agent, applying any `agent_models` / `agent_reasoning` override."""
|
|
88
|
+
update: dict[str, object] = {}
|
|
89
|
+
if name in self.agent_reasoning:
|
|
90
|
+
update["reasoning"] = self.agent_reasoning[name]
|
|
91
|
+
spec = self.agent_models.get(name)
|
|
92
|
+
if spec is not None:
|
|
93
|
+
provider, sep, model = spec.partition(":")
|
|
94
|
+
if sep and provider in get_args(Provider):
|
|
95
|
+
update["model"] = model
|
|
96
|
+
if provider != self.provider:
|
|
97
|
+
# base_url belongs to the original provider; don't leak it to another one
|
|
98
|
+
update |= {"provider": provider, "base_url": None}
|
|
99
|
+
else:
|
|
100
|
+
update["model"] = spec
|
|
101
|
+
return self.model_copy(update=update) if update else self
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def resolved_model(self) -> str:
|
|
105
|
+
return self.model or DEFAULT_MODELS[self.provider]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def resolved_timeout(self) -> float:
|
|
109
|
+
if self.timeout is not None:
|
|
110
|
+
return self.timeout
|
|
111
|
+
return 600.0 if self.provider == "ollama" else 120.0
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def resolved_base_url(self) -> str | None:
|
|
115
|
+
if self.base_url:
|
|
116
|
+
return self.base_url.rstrip("/")
|
|
117
|
+
return self.ollama_url.rstrip("/") if self.provider == "ollama" else None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def api_key(self) -> SecretStr | None:
|
|
121
|
+
"""The key for the selected provider (None for Ollama)."""
|
|
122
|
+
if self.provider == "openai":
|
|
123
|
+
return self.openai_api_key
|
|
124
|
+
if self.provider == "anthropic":
|
|
125
|
+
return self.anthropic_api_key
|
|
126
|
+
return None
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Builds the LangGraph workflow: parallel specialists, cross-review, bounded revision
|
|
2
|
+
rounds, then coordinator assembly.
|
|
3
|
+
|
|
4
|
+
START -> specialists (parallel) -> gather -> review (one task per reviewer x artifact)
|
|
5
|
+
-> decide -> [rejected specialists revise | coordinator] -> END
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Protocol, TypedDict, cast
|
|
12
|
+
|
|
13
|
+
from langchain_core.runnables import RunnableConfig
|
|
14
|
+
from langgraph.graph import END, START, StateGraph
|
|
15
|
+
from langgraph.types import Send
|
|
16
|
+
|
|
17
|
+
from agentpanel.agents import Coordinator, Reviewer, Specialist
|
|
18
|
+
from agentpanel.state import AgentContext, Artifact, Review, WorkflowState
|
|
19
|
+
|
|
20
|
+
RESERVED_NAMES = frozenset({"gather", "review", "decide", "coordinator"})
|
|
21
|
+
_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
22
|
+
_STEPS_PER_ROUND = 4 # specialists, gather, review, decide
|
|
23
|
+
|
|
24
|
+
UpdateCallback = Callable[[str, dict[str, Any]], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Node(Protocol):
|
|
28
|
+
async def __call__(self, state: WorkflowState) -> dict[str, Any]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _ReviewTask(TypedDict):
|
|
32
|
+
reviewer: str
|
|
33
|
+
target: str
|
|
34
|
+
context: AgentContext
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class WorkflowResult:
|
|
39
|
+
result: Any
|
|
40
|
+
artifacts: dict[str, Artifact]
|
|
41
|
+
reviews: list[Review]
|
|
42
|
+
rounds: int # revision rounds used
|
|
43
|
+
unresolved: list[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Workflow:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
specialists: Sequence[Specialist],
|
|
51
|
+
coordinator: Coordinator,
|
|
52
|
+
reviewers: Sequence[Reviewer] = (),
|
|
53
|
+
max_revisions: int = 1,
|
|
54
|
+
) -> None:
|
|
55
|
+
if not specialists:
|
|
56
|
+
raise ValueError("A workflow needs at least one specialist.")
|
|
57
|
+
if max_revisions < 0:
|
|
58
|
+
raise ValueError("max_revisions must be >= 0.")
|
|
59
|
+
_validate_names([s.name for s in specialists], "specialist")
|
|
60
|
+
_validate_names([r.name for r in reviewers], "reviewer")
|
|
61
|
+
|
|
62
|
+
self._specialists = {s.name: s for s in specialists}
|
|
63
|
+
self._reviewers = {r.name: r for r in reviewers}
|
|
64
|
+
self._coordinator = coordinator
|
|
65
|
+
self.max_revisions = max_revisions
|
|
66
|
+
self.graph = self._build().compile()
|
|
67
|
+
|
|
68
|
+
async def run(
|
|
69
|
+
self, inputs: Mapping[str, Any], on_update: UpdateCallback | None = None
|
|
70
|
+
) -> WorkflowResult:
|
|
71
|
+
"""Run to completion. `on_update(node, update)` is called as each node finishes,
|
|
72
|
+
e.g. ("stubs", {"artifacts": ...}) or ("review", {"reviews": [Review]})."""
|
|
73
|
+
initial: WorkflowState = {
|
|
74
|
+
"inputs": dict(inputs),
|
|
75
|
+
"artifacts": {},
|
|
76
|
+
"reviews": [],
|
|
77
|
+
"round": 0,
|
|
78
|
+
"revise": [],
|
|
79
|
+
"result": None,
|
|
80
|
+
}
|
|
81
|
+
config: RunnableConfig = {
|
|
82
|
+
"recursion_limit": _STEPS_PER_ROUND * (self.max_revisions + 1) + 5
|
|
83
|
+
}
|
|
84
|
+
final: Any = None
|
|
85
|
+
async for mode, chunk in self.graph.astream(
|
|
86
|
+
initial, config, stream_mode=["updates", "values"]
|
|
87
|
+
):
|
|
88
|
+
if mode == "values":
|
|
89
|
+
final = chunk
|
|
90
|
+
elif on_update is not None:
|
|
91
|
+
for node, update in cast(dict[str, Any], chunk).items():
|
|
92
|
+
on_update(node, update or {})
|
|
93
|
+
final = cast(WorkflowState, final)
|
|
94
|
+
return WorkflowResult(
|
|
95
|
+
result=final["result"],
|
|
96
|
+
artifacts=final["artifacts"],
|
|
97
|
+
reviews=final["reviews"],
|
|
98
|
+
rounds=final["round"],
|
|
99
|
+
unresolved=AgentContext.from_state(final).unresolved,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _build(self) -> StateGraph[WorkflowState]:
|
|
103
|
+
graph = StateGraph(WorkflowState)
|
|
104
|
+
for name, specialist in self._specialists.items():
|
|
105
|
+
graph.add_node(name, self._specialist_node(specialist))
|
|
106
|
+
graph.add_edge(START, name)
|
|
107
|
+
graph.add_edge(name, "gather")
|
|
108
|
+
graph.add_node("gather", _noop)
|
|
109
|
+
graph.add_node("review", self._review, input_schema=_ReviewTask)
|
|
110
|
+
graph.add_node("decide", self._decide)
|
|
111
|
+
graph.add_node("coordinator", self._assemble)
|
|
112
|
+
|
|
113
|
+
graph.add_conditional_edges("gather", self._route_reviews, ["review", "decide"])
|
|
114
|
+
graph.add_edge("review", "decide")
|
|
115
|
+
graph.add_conditional_edges(
|
|
116
|
+
"decide", _route_after_decide, [*self._specialists, "coordinator"]
|
|
117
|
+
)
|
|
118
|
+
graph.add_edge("coordinator", END)
|
|
119
|
+
return graph
|
|
120
|
+
|
|
121
|
+
def _specialist_node(self, specialist: Specialist) -> _Node:
|
|
122
|
+
async def node(state: WorkflowState) -> dict[str, Any]:
|
|
123
|
+
content = await specialist.produce(AgentContext.from_state(state))
|
|
124
|
+
artifact = Artifact(
|
|
125
|
+
author=specialist.name, kind=specialist.kind, content=content, round=state["round"]
|
|
126
|
+
)
|
|
127
|
+
return {"artifacts": {specialist.name: artifact}}
|
|
128
|
+
|
|
129
|
+
return node
|
|
130
|
+
|
|
131
|
+
def _route_reviews(self, state: WorkflowState) -> list[Send] | str:
|
|
132
|
+
context = AgentContext.from_state(state)
|
|
133
|
+
fresh = [a for a in state["artifacts"].values() if a.round == state["round"]]
|
|
134
|
+
tasks = [
|
|
135
|
+
Send("review", _ReviewTask(reviewer=name, target=artifact.author, context=context))
|
|
136
|
+
for artifact in fresh
|
|
137
|
+
for name in self._reviewers
|
|
138
|
+
if name != artifact.author
|
|
139
|
+
]
|
|
140
|
+
return tasks or "decide"
|
|
141
|
+
|
|
142
|
+
async def _review(self, state: _ReviewTask) -> dict[str, Any]:
|
|
143
|
+
artifact = state["context"].artifacts[state["target"]]
|
|
144
|
+
verdict = await self._reviewers[state["reviewer"]].review(artifact, state["context"])
|
|
145
|
+
review = Review(
|
|
146
|
+
reviewer=state["reviewer"],
|
|
147
|
+
target=artifact.author,
|
|
148
|
+
round=artifact.round,
|
|
149
|
+
approved=verdict.approved,
|
|
150
|
+
comments=verdict.comments,
|
|
151
|
+
)
|
|
152
|
+
return {"reviews": [review]}
|
|
153
|
+
|
|
154
|
+
async def _decide(self, state: WorkflowState) -> dict[str, Any]:
|
|
155
|
+
rejected = AgentContext.from_state(state).unresolved
|
|
156
|
+
if rejected and state["round"] < self.max_revisions:
|
|
157
|
+
return {"round": state["round"] + 1, "revise": rejected}
|
|
158
|
+
return {"revise": []}
|
|
159
|
+
|
|
160
|
+
async def _assemble(self, state: WorkflowState) -> dict[str, Any]:
|
|
161
|
+
return {"result": await self._coordinator.assemble(AgentContext.from_state(state))}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _noop(state: WorkflowState) -> dict[str, Any]:
|
|
165
|
+
return {}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _route_after_decide(state: WorkflowState) -> list[str] | str:
|
|
169
|
+
return state["revise"] or "coordinator"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_names(names: list[str], role: str) -> None:
|
|
173
|
+
for name in names:
|
|
174
|
+
if not _NAME_PATTERN.match(name):
|
|
175
|
+
raise ValueError(f"Invalid {role} name {name!r}: use lowercase letters, digits, _.")
|
|
176
|
+
if name in RESERVED_NAMES:
|
|
177
|
+
raise ValueError(f"{role.capitalize()} name {name!r} is reserved.")
|
|
178
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
179
|
+
if duplicates:
|
|
180
|
+
raise ValueError(f"Duplicate {role} names: {duplicates}")
|