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 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"
@@ -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))
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.