agentpanel 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- agentpanel/agents.py +38 -0
- agentpanel/config.py +126 -0
- agentpanel/graph.py +180 -0
- agentpanel/llm.py +139 -0
- agentpanel/llm_agents.py +98 -0
- agentpanel/py.typed +0 -0
- agentpanel/state.py +84 -0
- agentpanel/structured.py +146 -0
- agentpanel/testing.py +44 -0
- agentpanel-0.1.0.dist-info/METADATA +113 -0
- agentpanel-0.1.0.dist-info/RECORD +14 -0
- agentpanel-0.1.0.dist-info/WHEEL +4 -0
- agentpanel-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpanel/__init__.py
ADDED
|
@@ -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
|
+
]
|
agentpanel/agents.py
ADDED
|
@@ -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: ...
|
agentpanel/config.py
ADDED
|
@@ -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
|
agentpanel/graph.py
ADDED
|
@@ -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}")
|
agentpanel/llm.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""LLM provider abstraction: Ollama by default, bring-your-own-key for cloud providers.
|
|
2
|
+
|
|
3
|
+
Provider packages are imported lazily so cloud SDKs stay optional extras.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from langchain_core.language_models import BaseChatModel
|
|
13
|
+
|
|
14
|
+
from agentpanel.config import API_KEY_ENV_VARS, LLMSettings, Provider
|
|
15
|
+
|
|
16
|
+
# provider -> (module, chat class, pip extra)
|
|
17
|
+
_PROVIDERS: dict[Provider, tuple[str, str, str | None]] = {
|
|
18
|
+
"ollama": ("langchain_ollama", "ChatOllama", None),
|
|
19
|
+
"openai": ("langchain_openai", "ChatOpenAI", "openai"),
|
|
20
|
+
"anthropic": ("langchain_anthropic", "ChatAnthropic", "anthropic"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LLMConfigError(Exception):
|
|
25
|
+
"""The configured provider cannot be used as-is. The message says how to fix it."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderNotInstalledError(LLMConfigError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MissingAPIKeyError(LLMConfigError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_chat_model(settings: LLMSettings | None = None) -> BaseChatModel:
|
|
37
|
+
"""Build a LangChain chat model for the configured provider. Makes no network calls."""
|
|
38
|
+
settings = settings or LLMSettings()
|
|
39
|
+
if settings.provider != "ollama" and settings.api_key is None:
|
|
40
|
+
env_var = API_KEY_ENV_VARS[settings.provider]
|
|
41
|
+
raise MissingAPIKeyError(
|
|
42
|
+
f"Provider '{settings.provider}' needs an API key. Set {env_var} "
|
|
43
|
+
"in your environment or .env file."
|
|
44
|
+
)
|
|
45
|
+
_, class_name, _ = _PROVIDERS[settings.provider]
|
|
46
|
+
chat_class = getattr(_import_provider(settings.provider), class_name)
|
|
47
|
+
|
|
48
|
+
kwargs: dict[str, Any] = {"model": settings.resolved_model}
|
|
49
|
+
if settings.temperature is not None:
|
|
50
|
+
kwargs["temperature"] = settings.temperature
|
|
51
|
+
if settings.resolved_base_url:
|
|
52
|
+
kwargs["base_url"] = settings.resolved_base_url
|
|
53
|
+
if settings.provider == "ollama":
|
|
54
|
+
kwargs["client_kwargs"] = {"timeout": settings.resolved_timeout}
|
|
55
|
+
kwargs["num_ctx"] = settings.context_window
|
|
56
|
+
if settings.reasoning is not None:
|
|
57
|
+
kwargs["reasoning"] = settings.reasoning
|
|
58
|
+
else:
|
|
59
|
+
kwargs["api_key"] = settings.api_key
|
|
60
|
+
kwargs["timeout"] = settings.resolved_timeout
|
|
61
|
+
|
|
62
|
+
return cast(BaseChatModel, chat_class(**kwargs))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _import_provider(provider: Provider) -> Any:
|
|
66
|
+
module_name, _, extra = _PROVIDERS[provider]
|
|
67
|
+
try:
|
|
68
|
+
return importlib.import_module(module_name)
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
package = module_name.replace("_", "-")
|
|
71
|
+
hint = f"Install `{package}`" + (f" (the `{extra}` extra)." if extra else ".")
|
|
72
|
+
raise ProviderNotInstalledError(f"Provider '{provider}' is not installed. {hint}") from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ProviderStatus:
|
|
77
|
+
ok: bool
|
|
78
|
+
provider: Provider
|
|
79
|
+
model: str
|
|
80
|
+
detail: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_provider(
|
|
84
|
+
settings: LLMSettings | None = None, *, client: httpx.Client | None = None
|
|
85
|
+
) -> ProviderStatus:
|
|
86
|
+
"""Check the configured provider is usable, with an actionable message if not.
|
|
87
|
+
|
|
88
|
+
For Ollama this contacts the server and checks the model is pulled. For cloud
|
|
89
|
+
providers it only checks the package and key are present; it does not spend
|
|
90
|
+
tokens validating the key.
|
|
91
|
+
"""
|
|
92
|
+
settings = settings or LLMSettings()
|
|
93
|
+
model = settings.resolved_model
|
|
94
|
+
|
|
95
|
+
def status(ok: bool, detail: str) -> ProviderStatus:
|
|
96
|
+
return ProviderStatus(ok=ok, provider=settings.provider, model=model, detail=detail)
|
|
97
|
+
|
|
98
|
+
module_name, _, extra = _PROVIDERS[settings.provider]
|
|
99
|
+
if importlib.util.find_spec(module_name) is None:
|
|
100
|
+
return status(
|
|
101
|
+
False,
|
|
102
|
+
f"Package `{module_name.replace('_', '-')}` is not installed (the `{extra}` extra).",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if settings.provider != "ollama":
|
|
106
|
+
env_var = API_KEY_ENV_VARS[settings.provider]
|
|
107
|
+
if settings.api_key is None:
|
|
108
|
+
return status(False, f"No API key. Set {env_var} in your environment or .env file.")
|
|
109
|
+
return status(True, f"{env_var} is set (not validated against the API).")
|
|
110
|
+
|
|
111
|
+
return status(*_check_ollama(settings, model, client))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_ollama(
|
|
115
|
+
settings: LLMSettings, model: str, client: httpx.Client | None
|
|
116
|
+
) -> tuple[bool, str]:
|
|
117
|
+
url = settings.resolved_base_url
|
|
118
|
+
http = client or httpx.Client(timeout=5.0)
|
|
119
|
+
try:
|
|
120
|
+
response = http.get(f"{url}/api/tags")
|
|
121
|
+
response.raise_for_status()
|
|
122
|
+
pulled = {m["name"] for m in response.json().get("models", [])}
|
|
123
|
+
except httpx.HTTPError as exc:
|
|
124
|
+
return False, (
|
|
125
|
+
f"Cannot reach Ollama at {url} ({exc.__class__.__name__}). "
|
|
126
|
+
"Is it running? Start it with `ollama serve`."
|
|
127
|
+
)
|
|
128
|
+
finally:
|
|
129
|
+
if client is None:
|
|
130
|
+
http.close()
|
|
131
|
+
|
|
132
|
+
if _ollama_tag(model) not in {_ollama_tag(name) for name in pulled}:
|
|
133
|
+
return False, f"Model '{model}' is not pulled. Run `ollama pull {model}`."
|
|
134
|
+
return True, f"Ollama is running at {url} and '{model}' is available."
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ollama_tag(name: str) -> str:
|
|
138
|
+
"""`qwen3` and `qwen3:latest` refer to the same Ollama model."""
|
|
139
|
+
return name if ":" in name else f"{name}:latest"
|
agentpanel/llm_agents.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Reusable LLM-backed agents. Applications supply instructions and prompt renderers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.language_models import BaseChatModel
|
|
8
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from agentpanel.state import AgentContext, Artifact, Review, Verdict
|
|
12
|
+
from agentpanel.structured import ainvoke_structured, format_content, message_text
|
|
13
|
+
|
|
14
|
+
REVIEW_GUIDANCE = (
|
|
15
|
+
"Approve only if the artifact is correct and fit for its purpose. When rejecting, give "
|
|
16
|
+
"specific, actionable comments: the author revises using only your comments."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_artifact(artifact: Artifact, ctx: AgentContext) -> str:
|
|
21
|
+
"""Default reviewer prompt: just the artifact."""
|
|
22
|
+
return (
|
|
23
|
+
f"Review this {artifact.kind} produced by '{artifact.author}':\n\n"
|
|
24
|
+
f"<artifact>\n{format_content(artifact.content)}\n</artifact>"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_feedback(feedback: list[Review]) -> str:
|
|
29
|
+
"""Reviewer comments as a bullet list, attributed to each reviewer."""
|
|
30
|
+
return "\n".join(
|
|
31
|
+
f"- ({review.reviewer}) {comment}"
|
|
32
|
+
for review in feedback
|
|
33
|
+
for comment in (review.comments or ["Rejected without comments."])
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_revision(previous: Artifact, feedback: list[Review]) -> str:
|
|
38
|
+
return (
|
|
39
|
+
"Your previous attempt was rejected.\n\n"
|
|
40
|
+
f"<previous_attempt>\n{format_content(previous.content)}\n</previous_attempt>\n\n"
|
|
41
|
+
f"Reviewer feedback to address:\n{render_feedback(feedback)}\n\n"
|
|
42
|
+
"Produce a complete, corrected replacement, not a diff."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LLMSpecialist:
|
|
48
|
+
"""Produces text, or an `output_schema` instance when one is given.
|
|
49
|
+
|
|
50
|
+
On revision rounds the previous attempt and reviewer feedback are appended to the
|
|
51
|
+
prompt automatically, so `render` only needs to describe the task.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
kind: str
|
|
56
|
+
model: BaseChatModel
|
|
57
|
+
instructions: str
|
|
58
|
+
render: Callable[[AgentContext], str]
|
|
59
|
+
output_schema: type[BaseModel] | None = None
|
|
60
|
+
check: Callable[[Any], list[str]] | None = None # domain checks on structured output
|
|
61
|
+
max_attempts: int = 3
|
|
62
|
+
|
|
63
|
+
async def produce(self, ctx: AgentContext) -> Any:
|
|
64
|
+
messages = [SystemMessage(self.instructions), HumanMessage(self._prompt(ctx))]
|
|
65
|
+
if self.output_schema is None:
|
|
66
|
+
return message_text(await self.model.ainvoke(messages))
|
|
67
|
+
return await ainvoke_structured(
|
|
68
|
+
self.model,
|
|
69
|
+
messages,
|
|
70
|
+
self.output_schema,
|
|
71
|
+
max_attempts=self.max_attempts,
|
|
72
|
+
check=self.check,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _prompt(self, ctx: AgentContext) -> str:
|
|
76
|
+
prompt = self.render(ctx)
|
|
77
|
+
feedback = ctx.feedback_for(self.name)
|
|
78
|
+
if not feedback:
|
|
79
|
+
return prompt
|
|
80
|
+
return f"{prompt}\n\n{render_revision(ctx.artifacts[self.name], feedback)}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class LLMReviewer:
|
|
85
|
+
name: str
|
|
86
|
+
model: BaseChatModel
|
|
87
|
+
instructions: str
|
|
88
|
+
render: Callable[[Artifact, AgentContext], str] = render_artifact
|
|
89
|
+
max_attempts: int = 3
|
|
90
|
+
|
|
91
|
+
async def review(self, artifact: Artifact, ctx: AgentContext) -> Verdict:
|
|
92
|
+
messages = [
|
|
93
|
+
SystemMessage(f"{self.instructions}\n\n{REVIEW_GUIDANCE}"),
|
|
94
|
+
HumanMessage(self.render(artifact, ctx)),
|
|
95
|
+
]
|
|
96
|
+
return await ainvoke_structured(
|
|
97
|
+
self.model, messages, Verdict, max_attempts=self.max_attempts
|
|
98
|
+
)
|
agentpanel/py.typed
ADDED
|
File without changes
|
agentpanel/state.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared graph state: artifacts, reviews, and the read-only context agents receive."""
|
|
2
|
+
|
|
3
|
+
import operator
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, Any, TypedDict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Artifact(BaseModel):
|
|
12
|
+
"""One specialist's output for one round."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(frozen=True)
|
|
15
|
+
|
|
16
|
+
author: str
|
|
17
|
+
kind: str
|
|
18
|
+
content: Any
|
|
19
|
+
round: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Verdict(BaseModel):
|
|
23
|
+
"""A reviewer's decision about one artifact. Rejections trigger a revision."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True)
|
|
26
|
+
|
|
27
|
+
approved: bool = Field(description="True if the artifact is acceptable as-is.")
|
|
28
|
+
comments: list[str] = Field(
|
|
29
|
+
default_factory=list, description="Specific, actionable problems or suggestions."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Review(Verdict):
|
|
34
|
+
reviewer: str
|
|
35
|
+
target: str # author of the reviewed artifact
|
|
36
|
+
round: int # round of the reviewed artifact
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge(left: dict[str, Artifact], right: dict[str, Artifact]) -> dict[str, Artifact]:
|
|
40
|
+
return {**left, **right}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WorkflowState(TypedDict):
|
|
44
|
+
inputs: dict[str, Any]
|
|
45
|
+
artifacts: Annotated[dict[str, Artifact], _merge] # latest artifact per author
|
|
46
|
+
reviews: Annotated[list[Review], operator.add] # every review from every round
|
|
47
|
+
round: int
|
|
48
|
+
revise: list[str] # authors scheduled to revise in the current round
|
|
49
|
+
result: Any
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class AgentContext:
|
|
54
|
+
"""What an agent can see when it runs."""
|
|
55
|
+
|
|
56
|
+
inputs: Mapping[str, Any]
|
|
57
|
+
artifacts: Mapping[str, Artifact]
|
|
58
|
+
reviews: Sequence[Review]
|
|
59
|
+
round: int
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_state(cls, state: WorkflowState) -> "AgentContext":
|
|
63
|
+
return cls(
|
|
64
|
+
inputs=state["inputs"],
|
|
65
|
+
artifacts=state["artifacts"],
|
|
66
|
+
reviews=state["reviews"],
|
|
67
|
+
round=state["round"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def reviews_of(self, author: str) -> list[Review]:
|
|
71
|
+
"""Reviews of `author`'s latest artifact."""
|
|
72
|
+
artifact = self.artifacts.get(author)
|
|
73
|
+
if artifact is None:
|
|
74
|
+
return []
|
|
75
|
+
return [r for r in self.reviews if r.target == author and r.round == artifact.round]
|
|
76
|
+
|
|
77
|
+
def feedback_for(self, author: str) -> list[Review]:
|
|
78
|
+
"""Rejections of `author`'s latest artifact, i.e. what a revision must address."""
|
|
79
|
+
return [r for r in self.reviews_of(author) if not r.approved]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def unresolved(self) -> list[str]:
|
|
83
|
+
"""Authors whose latest artifact still has at least one rejection."""
|
|
84
|
+
return sorted(author for author in self.artifacts if self.feedback_for(author))
|
agentpanel/structured.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Structured output that works across providers, including small local models.
|
|
2
|
+
|
|
3
|
+
The JSON Schema goes in the prompt, the reply is parsed and validated with pydantic,
|
|
4
|
+
and validation errors are fed back to the model for another attempt. On Ollama,
|
|
5
|
+
decoding is additionally constrained to the schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from langchain_core.language_models import BaseChatModel
|
|
14
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
|
15
|
+
from langchain_core.runnables import Runnable
|
|
16
|
+
from langchain_ollama import ChatOllama
|
|
17
|
+
from pydantic import BaseModel, ValidationError
|
|
18
|
+
|
|
19
|
+
_THINK = re.compile(r"<think>.*?</think>", re.DOTALL)
|
|
20
|
+
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|
21
|
+
_MAX_ERROR_CHARS = 1500
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T", bound=BaseModel)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StructuredOutputError(Exception):
|
|
27
|
+
"""The model never produced output that both parsed and passed the checks.
|
|
28
|
+
|
|
29
|
+
`value` holds the last reply that parsed but failed `check`, if any, so callers
|
|
30
|
+
can salvage the valid parts.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
schema: type[BaseModel],
|
|
36
|
+
attempts: int,
|
|
37
|
+
problem: str,
|
|
38
|
+
reply: str,
|
|
39
|
+
value: BaseModel | None = None,
|
|
40
|
+
):
|
|
41
|
+
super().__init__(
|
|
42
|
+
f"No valid {schema.__name__} after {attempts} attempt(s). Last problem: {problem}"
|
|
43
|
+
)
|
|
44
|
+
self.reply = reply
|
|
45
|
+
self.value = value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strip_reasoning(text: str) -> str:
|
|
49
|
+
"""Drop `<think>...</think>` blocks that reasoning models put in their content."""
|
|
50
|
+
return _THINK.sub("", text).strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def message_text(message: BaseMessage) -> str:
|
|
54
|
+
return strip_reasoning(message.text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_content(content: Any) -> str:
|
|
58
|
+
"""Render artifact content for a prompt."""
|
|
59
|
+
if isinstance(content, str):
|
|
60
|
+
return content
|
|
61
|
+
if isinstance(content, BaseModel):
|
|
62
|
+
return content.model_dump_json(indent=2)
|
|
63
|
+
return json.dumps(content, indent=2, default=str)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def schema_instructions(schema: type[BaseModel]) -> str:
|
|
67
|
+
return (
|
|
68
|
+
"Respond with only a JSON object (no prose) that matches this JSON Schema:\n"
|
|
69
|
+
+ json.dumps(schema.model_json_schema(), indent=2)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_reply(text: str, schema: type[T]) -> T:
|
|
74
|
+
"""Validate a reply, tolerating reasoning tags, code fences, and surrounding prose."""
|
|
75
|
+
text = strip_reasoning(text)
|
|
76
|
+
candidates = [text]
|
|
77
|
+
if fenced := _FENCE.search(text):
|
|
78
|
+
candidates.append(fenced.group(1))
|
|
79
|
+
start, end = text.find("{"), text.rfind("}")
|
|
80
|
+
if start != -1 and end > start:
|
|
81
|
+
candidates.append(text[start : end + 1])
|
|
82
|
+
error: ValidationError | None = None
|
|
83
|
+
for candidate in candidates:
|
|
84
|
+
try:
|
|
85
|
+
return schema.model_validate_json(candidate)
|
|
86
|
+
except ValidationError as exc:
|
|
87
|
+
error = error or exc # the error for the full reply is the most informative
|
|
88
|
+
assert error is not None
|
|
89
|
+
raise error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def ainvoke_structured(
|
|
93
|
+
model: BaseChatModel,
|
|
94
|
+
messages: Sequence[BaseMessage],
|
|
95
|
+
schema: type[T],
|
|
96
|
+
*,
|
|
97
|
+
max_attempts: int = 3,
|
|
98
|
+
check: Callable[[T], list[str]] | None = None,
|
|
99
|
+
) -> T:
|
|
100
|
+
"""Call `model` until its reply validates against `schema` and passes `check`.
|
|
101
|
+
|
|
102
|
+
`check` returns domain-level problems (empty list = fine). Schema errors and check
|
|
103
|
+
problems are both fed back to the model for the next attempt.
|
|
104
|
+
"""
|
|
105
|
+
if max_attempts < 1:
|
|
106
|
+
raise ValueError("max_attempts must be >= 1.")
|
|
107
|
+
runnable = _constrained(model, schema)
|
|
108
|
+
conversation = _with_instructions(list(messages), schema_instructions(schema))
|
|
109
|
+
problem = ""
|
|
110
|
+
text = ""
|
|
111
|
+
last_value: T | None = None
|
|
112
|
+
for _ in range(max_attempts):
|
|
113
|
+
reply = await runnable.ainvoke(conversation)
|
|
114
|
+
text = message_text(reply)
|
|
115
|
+
try:
|
|
116
|
+
value = parse_reply(text, schema)
|
|
117
|
+
except ValidationError as exc:
|
|
118
|
+
problem = str(exc)
|
|
119
|
+
else:
|
|
120
|
+
problems = check(value) if check else []
|
|
121
|
+
if not problems:
|
|
122
|
+
return value
|
|
123
|
+
last_value = value
|
|
124
|
+
problem = "\n".join(f"- {p}" for p in problems)
|
|
125
|
+
conversation += [
|
|
126
|
+
AIMessage(text),
|
|
127
|
+
HumanMessage(
|
|
128
|
+
f"That reply was invalid:\n{problem[:_MAX_ERROR_CHARS]}\n\n"
|
|
129
|
+
"Respond again with the complete, corrected JSON object."
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
raise StructuredOutputError(schema, max_attempts, problem, text, last_value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _constrained(model: BaseChatModel, schema: type[BaseModel]) -> Runnable[Any, BaseMessage]:
|
|
136
|
+
if isinstance(model, ChatOllama):
|
|
137
|
+
return model.bind(format=schema.model_json_schema())
|
|
138
|
+
return model
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _with_instructions(messages: list[BaseMessage], instructions: str) -> list[BaseMessage]:
|
|
142
|
+
"""Append to the final user message rather than adding a second one in a row."""
|
|
143
|
+
last = messages[-1] if messages else None
|
|
144
|
+
if isinstance(last, HumanMessage) and isinstance(last.content, str):
|
|
145
|
+
return [*messages[:-1], HumanMessage(f"{last.content}\n\n{instructions}")]
|
|
146
|
+
return [*messages, HumanMessage(instructions)]
|
agentpanel/testing.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Test helpers for code built on agentpanel."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from langchain_core.callbacks import CallbackManagerForLLMRun
|
|
7
|
+
from langchain_core.language_models import BaseChatModel
|
|
8
|
+
from langchain_core.messages import AIMessage, BaseMessage
|
|
9
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScriptedChatModel(BaseChatModel):
|
|
14
|
+
"""Returns canned replies in order (or from `respond`) and records every call.
|
|
15
|
+
|
|
16
|
+
`respond(messages)` computes the reply from the prompt instead, which suits models
|
|
17
|
+
shared by several agents, e.g. a reviewer that sees different artifacts.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
replies: list[str] = Field(default_factory=list)
|
|
21
|
+
respond: Callable[[list[BaseMessage]], str] | None = None
|
|
22
|
+
calls: list[list[BaseMessage]] = Field(default_factory=list)
|
|
23
|
+
call_kwargs: list[dict[str, Any]] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def _llm_type(self) -> str:
|
|
27
|
+
return "scripted"
|
|
28
|
+
|
|
29
|
+
def _generate(
|
|
30
|
+
self,
|
|
31
|
+
messages: list[BaseMessage],
|
|
32
|
+
stop: list[str] | None = None,
|
|
33
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> ChatResult:
|
|
36
|
+
self.calls.append(list(messages))
|
|
37
|
+
self.call_kwargs.append(kwargs)
|
|
38
|
+
if self.respond is not None:
|
|
39
|
+
text = self.respond(list(messages))
|
|
40
|
+
elif self.replies:
|
|
41
|
+
text = self.replies.pop(0)
|
|
42
|
+
else:
|
|
43
|
+
raise AssertionError("ScriptedChatModel ran out of replies.")
|
|
44
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(text))])
|
|
@@ -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,14 @@
|
|
|
1
|
+
agentpanel/__init__.py,sha256=84T1ixNSukwAm94fCYKmf_u8JbeRf2a_c2U3hbfYwu8,537
|
|
2
|
+
agentpanel/agents.py,sha256=SKS9RP37QujnrwQ_LSalcWC63Z7465vMDh9gb6y1PKI,1170
|
|
3
|
+
agentpanel/config.py,sha256=hmW7D_EbGGDdECjhXML0Erv0odIV7EXgDCVuZuE6bjE,4732
|
|
4
|
+
agentpanel/graph.py,sha256=lk-FqhEdEiUtQT3imnDgP-tOsSZoqKV_Ma74eJncxw8,6833
|
|
5
|
+
agentpanel/llm.py,sha256=0iJ44lFmCBNbR5GEUPEzDhyzv-w-oXxuFu5GiUQkg0Q,5073
|
|
6
|
+
agentpanel/llm_agents.py,sha256=Tco213j7lBCj1wDlOeitgaJsUV655perWbxO0C2sQL4,3480
|
|
7
|
+
agentpanel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agentpanel/state.py,sha256=OOpjET9zElbqUGgPD6Wg3544am6TQMwNIJvxNt6wZEo,2644
|
|
9
|
+
agentpanel/structured.py,sha256=qFlBuXLtsKun3pGojjBgW41PJPdQdxdErsFYUpmQdUs,5128
|
|
10
|
+
agentpanel/testing.py,sha256=WTGopioc-vUemU4gPb2CkGSH_2nMRncHUTn5y1qLXFk,1615
|
|
11
|
+
agentpanel-0.1.0.dist-info/METADATA,sha256=E86776RvEOvDy1Xp2xU_elc3MubyFr9iXr_hwqoGXdc,4553
|
|
12
|
+
agentpanel-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
agentpanel-0.1.0.dist-info/licenses/LICENSE,sha256=P1Wn2gFhqAYbpMeFoXqSNkoj3-k3Ce2Usfr2dW4PRec,1068
|
|
14
|
+
agentpanel-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|