windlass 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.
- windlass/__init__.py +243 -0
- windlass/_version.py +7 -0
- windlass/agent/__init__.py +44 -0
- windlass/agent/builder.py +709 -0
- windlass/agent/checkpoint.py +330 -0
- windlass/agent/graph.py +410 -0
- windlass/agent/runtime.py +633 -0
- windlass/agent/supervisor.py +373 -0
- windlass/api.py +469 -0
- windlass/cli.py +389 -0
- windlass/core/__init__.py +145 -0
- windlass/core/cache.py +336 -0
- windlass/core/concurrency.py +341 -0
- windlass/core/config.py +461 -0
- windlass/core/container.py +383 -0
- windlass/core/exceptions.py +465 -0
- windlass/core/lazy.py +208 -0
- windlass/core/logging.py +204 -0
- windlass/core/registry.py +626 -0
- windlass/core/retry.py +288 -0
- windlass/core/text.py +505 -0
- windlass/core/types.py +671 -0
- windlass/core/vectors.py +306 -0
- windlass/interfaces/__init__.py +81 -0
- windlass/interfaces/base.py +191 -0
- windlass/interfaces/chunker.py +279 -0
- windlass/interfaces/embedding.py +259 -0
- windlass/interfaces/evaluator.py +264 -0
- windlass/interfaces/guardrail.py +289 -0
- windlass/interfaces/llm.py +410 -0
- windlass/interfaces/loader.py +349 -0
- windlass/interfaces/mcp.py +312 -0
- windlass/interfaces/memory.py +178 -0
- windlass/interfaces/preprocessor.py +195 -0
- windlass/interfaces/retriever.py +245 -0
- windlass/interfaces/tool.py +339 -0
- windlass/interfaces/tracer.py +366 -0
- windlass/interfaces/vectordb.py +340 -0
- windlass/providers/__init__.py +642 -0
- windlass/providers/chunkers/__init__.py +7 -0
- windlass/providers/chunkers/hierarchical.py +254 -0
- windlass/providers/chunkers/recursive.py +260 -0
- windlass/providers/chunkers/semantic.py +204 -0
- windlass/providers/chunkers/structural.py +323 -0
- windlass/providers/embeddings/__init__.py +7 -0
- windlass/providers/embeddings/hash.py +127 -0
- windlass/providers/embeddings/hf_inference.py +284 -0
- windlass/providers/embeddings/huggingface.py +187 -0
- windlass/providers/embeddings/openai.py +126 -0
- windlass/providers/evaluation/__init__.py +7 -0
- windlass/providers/evaluation/builtin.py +428 -0
- windlass/providers/evaluation/external.py +356 -0
- windlass/providers/guardrails/__init__.py +7 -0
- windlass/providers/guardrails/nemo.py +241 -0
- windlass/providers/guardrails/rules.py +241 -0
- windlass/providers/llm/__init__.py +9 -0
- windlass/providers/llm/anthropic.py +355 -0
- windlass/providers/llm/fake.py +271 -0
- windlass/providers/llm/gemini.py +291 -0
- windlass/providers/llm/groq.py +361 -0
- windlass/providers/llm/ollama.py +313 -0
- windlass/providers/llm/openai.py +424 -0
- windlass/providers/loaders/__init__.py +7 -0
- windlass/providers/loaders/media.py +296 -0
- windlass/providers/loaders/office.py +511 -0
- windlass/providers/loaders/text.py +507 -0
- windlass/providers/loaders/web.py +452 -0
- windlass/providers/mcp/__init__.py +7 -0
- windlass/providers/mcp/fastmcp.py +635 -0
- windlass/providers/memory/__init__.py +7 -0
- windlass/providers/memory/conversation.py +322 -0
- windlass/providers/memory/longterm.py +304 -0
- windlass/providers/observability/__init__.py +7 -0
- windlass/providers/observability/console.py +259 -0
- windlass/providers/observability/multi.py +160 -0
- windlass/providers/observability/platforms.py +424 -0
- windlass/providers/preprocessors/__init__.py +7 -0
- windlass/providers/preprocessors/clean.py +306 -0
- windlass/providers/preprocessors/dedup.py +321 -0
- windlass/providers/preprocessors/enrich.py +303 -0
- windlass/providers/preprocessors/privacy.py +307 -0
- windlass/providers/retrievers/__init__.py +7 -0
- windlass/providers/retrievers/bm25.py +365 -0
- windlass/providers/retrievers/contextual.py +326 -0
- windlass/providers/retrievers/hybrid.py +255 -0
- windlass/providers/retrievers/vector.py +247 -0
- windlass/providers/vectordb/__init__.py +7 -0
- windlass/providers/vectordb/chroma.py +382 -0
- windlass/providers/vectordb/faiss.py +445 -0
- windlass/providers/vectordb/memory.py +361 -0
- windlass/providers/vectordb/pinecone.py +442 -0
- windlass/py.typed +1 -0
- windlass/rag/__init__.py +34 -0
- windlass/rag/builder.py +739 -0
- windlass/rag/loading.py +250 -0
- windlass/rag/pipeline.py +854 -0
- windlass/testing.py +376 -0
- windlass/tools/__init__.py +458 -0
- windlass/tools/schema.py +417 -0
- windlass-0.1.0.dist-info/METADATA +679 -0
- windlass-0.1.0.dist-info/RECORD +104 -0
- windlass-0.1.0.dist-info/WHEEL +4 -0
- windlass-0.1.0.dist-info/entry_points.txt +2 -0
- windlass-0.1.0.dist-info/licenses/LICENSE +205 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""The fluent agent builder.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
agent = (
|
|
6
|
+
Windlass.agent()
|
|
7
|
+
.llm("gpt-4o")
|
|
8
|
+
.tool(weather_tool)
|
|
9
|
+
.tool(search_tool)
|
|
10
|
+
.mcp(command="npx",
|
|
11
|
+
args=["-y", "@modelcontextprotocol/server-filesystem", "."])
|
|
12
|
+
.memory()
|
|
13
|
+
.guardrails()
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
response = agent.run("Summarise my PDF documents")
|
|
17
|
+
|
|
18
|
+
Like the RAG builder, nothing is constructed until first use, every argument
|
|
19
|
+
accepts a name, an instance or a factory, and the builder forwards the runtime's
|
|
20
|
+
methods so there is no mandatory ``.build()`` step.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
26
|
+
from typing import Any, Self
|
|
27
|
+
|
|
28
|
+
from windlass.agent.runtime import AgentRuntime
|
|
29
|
+
from windlass.core.container import Container, root_container
|
|
30
|
+
from windlass.core.exceptions import ConfigurationError
|
|
31
|
+
from windlass.core.logging import get_logger
|
|
32
|
+
from windlass.core.types import AgentResponse, StreamEvent
|
|
33
|
+
from windlass.tools import ToolRegistry
|
|
34
|
+
|
|
35
|
+
__all__ = ["AgentBuilder"]
|
|
36
|
+
|
|
37
|
+
_log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentBuilder:
|
|
41
|
+
"""Fluent builder for an agent.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
container: Dependency container. Defaults to a child of the process root.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
Level 1 — a working agent in two lines, no dependencies::
|
|
48
|
+
|
|
49
|
+
agent = Windlass.agent().llm("fake", responses=["Hello!"])
|
|
50
|
+
print(agent.run("Say hello"))
|
|
51
|
+
|
|
52
|
+
Level 2 — a real agent::
|
|
53
|
+
|
|
54
|
+
agent = (Windlass.agent()
|
|
55
|
+
.llm("anthropic", model="claude-sonnet-4-5")
|
|
56
|
+
.tool(search).tool(calculator)
|
|
57
|
+
.system("You are a research assistant. Cite your sources.")
|
|
58
|
+
.memory("summary")
|
|
59
|
+
.guardrails(pii=True, on_violation="redact")
|
|
60
|
+
.checkpoint("sqlite", path="./state.db")
|
|
61
|
+
.max_iterations(15)
|
|
62
|
+
.observe("langfuse"))
|
|
63
|
+
|
|
64
|
+
Level 3 — a graph you control::
|
|
65
|
+
|
|
66
|
+
agent = Windlass.agent().llm("openai").tool(search).graph()
|
|
67
|
+
graph = agent.native_graph()
|
|
68
|
+
graph.add_node("critic", critic_fn)
|
|
69
|
+
graph.add_edge("tools", "critic")
|
|
70
|
+
agent.recompile()
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, container: Container | None = None) -> None:
|
|
74
|
+
self.container = container or root_container().scope()
|
|
75
|
+
self._specs: dict[str, tuple[Any, dict[str, Any]]] = {}
|
|
76
|
+
self._tools: list[Any] = []
|
|
77
|
+
self._mcp_specs: list[tuple[Any, dict[str, Any]]] = []
|
|
78
|
+
self._sub_agents: dict[str, Any] = {}
|
|
79
|
+
self._descriptions: dict[str, str] = {}
|
|
80
|
+
self._options: dict[str, Any] = {
|
|
81
|
+
"system_prompt": None,
|
|
82
|
+
"max_iterations": 10,
|
|
83
|
+
"parallel_tools": True,
|
|
84
|
+
"require_approval": False,
|
|
85
|
+
"max_tool_call_retries": 2,
|
|
86
|
+
"name": "agent",
|
|
87
|
+
}
|
|
88
|
+
self._use_graph = False
|
|
89
|
+
self._runtime: AgentRuntime | None = None
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Components
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
def llm(self, spec: Any = None, /, **config: Any) -> Self:
|
|
95
|
+
"""Choose the reasoning model.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
spec: Registry name (``"openai"``, ``"anthropic"``, ``"gemini"``,
|
|
99
|
+
``"groq"``, ``"ollama"``, ``"fake"``), an
|
|
100
|
+
:class:`~windlass.interfaces.llm.LLM`, or a factory. A bare model
|
|
101
|
+
id like ``"gpt-4o"`` is also accepted and mapped to its provider.
|
|
102
|
+
**config: Provider options — ``model``, ``temperature``, ``api_key``, ...
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
``self``.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> from windlass import Windlass
|
|
109
|
+
>>> _ = Windlass.agent().llm("gpt-4o-mini", temperature=0)
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(spec, str):
|
|
112
|
+
provider, model = _split_model(spec)
|
|
113
|
+
if model and "model" not in config:
|
|
114
|
+
config = {**config, "model": model}
|
|
115
|
+
spec = provider
|
|
116
|
+
return self._set("llm", spec, config)
|
|
117
|
+
|
|
118
|
+
def tool(self, *tools: Any) -> Self:
|
|
119
|
+
"""Bind one or more tools.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
*tools: :class:`~windlass.interfaces.tool.Tool` instances, functions
|
|
123
|
+
decorated with :func:`~windlass.tools.tool`, plain functions
|
|
124
|
+
(wrapped automatically), or registry names.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
``self``.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ConfigurationError: When an argument cannot be interpreted as a tool.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> from windlass import Windlass, tool
|
|
134
|
+
>>> @tool
|
|
135
|
+
... def now() -> str:
|
|
136
|
+
... '''Return the current time.'''
|
|
137
|
+
... return "12:00"
|
|
138
|
+
>>> _ = Windlass.agent().tool(now)
|
|
139
|
+
"""
|
|
140
|
+
from windlass.interfaces.tool import Tool
|
|
141
|
+
|
|
142
|
+
for item in tools:
|
|
143
|
+
if item is None:
|
|
144
|
+
continue
|
|
145
|
+
# A Tool subclass is not necessarily callable — only FunctionTool
|
|
146
|
+
# forwards __call__ — so the isinstance check has to come first.
|
|
147
|
+
if not (isinstance(item, Tool | str) or callable(item)):
|
|
148
|
+
raise ConfigurationError(
|
|
149
|
+
f"Cannot use {type(item).__name__} as a tool.",
|
|
150
|
+
hint="Pass a @tool-decorated function, a Tool instance, a plain "
|
|
151
|
+
"callable, or a registered tool name.",
|
|
152
|
+
)
|
|
153
|
+
self._tools.append(item)
|
|
154
|
+
return self._invalidate()
|
|
155
|
+
|
|
156
|
+
def tools(self, tools: Sequence[Any]) -> Self:
|
|
157
|
+
"""Bind a sequence of tools. See :meth:`tool`."""
|
|
158
|
+
return self.tool(*tools)
|
|
159
|
+
|
|
160
|
+
def mcp(self, spec: Any = None, /, **config: Any) -> Self:
|
|
161
|
+
"""Connect an MCP server and bind its tools.
|
|
162
|
+
|
|
163
|
+
Call it repeatedly to connect several servers; their tools are namespaced
|
|
164
|
+
by server name so identically-named tools do not collide.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
spec: Registry name (``"fastmcp"``, ``"static"``), an
|
|
168
|
+
:class:`~windlass.interfaces.mcp.MCPClient`, or a factory. Omit it
|
|
169
|
+
for the FastMCP client.
|
|
170
|
+
**config: Transport options — ``command``, ``args``, ``url``, ``env``,
|
|
171
|
+
``server``.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
``self``.
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
>>> from windlass import Windlass
|
|
178
|
+
>>> _ = Windlass.agent().mcp(server="fs", command="npx",
|
|
179
|
+
... args=["-y", "@modelcontextprotocol/server-filesystem", "."])
|
|
180
|
+
"""
|
|
181
|
+
self._mcp_specs.append((spec if spec is not None else "fastmcp", config))
|
|
182
|
+
return self._invalidate()
|
|
183
|
+
|
|
184
|
+
def memory(self, spec: Any = None, /, **config: Any) -> Self:
|
|
185
|
+
"""Attach conversation memory.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
spec: Registry name (``"window"``, ``"buffer"``, ``"summary"``,
|
|
189
|
+
``"vector"``, ``"composite"``), a
|
|
190
|
+
:class:`~windlass.interfaces.memory.Memory`, or a factory. Omit it
|
|
191
|
+
for a sliding window.
|
|
192
|
+
**config: Memory options.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
``self``.
|
|
196
|
+
"""
|
|
197
|
+
return self._set("memory", spec if spec is not None else "window", config)
|
|
198
|
+
|
|
199
|
+
def guardrails(self, spec: Any = None, /, **config: Any) -> Self:
|
|
200
|
+
"""Enable input and output guardrails.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
spec: Registry name (``"rules"``, ``"nemo"``), a
|
|
204
|
+
:class:`~windlass.interfaces.guardrail.Guardrail`, or a factory.
|
|
205
|
+
Omit it for the dependency-free rule guardrail.
|
|
206
|
+
**config: Policy options.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
``self``.
|
|
210
|
+
"""
|
|
211
|
+
return self._set("guardrail", spec if spec is not None else "rules", config)
|
|
212
|
+
|
|
213
|
+
def observe(self, spec: Any = None, /, **config: Any) -> Self:
|
|
214
|
+
"""Enable tracing.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
spec: Registry name (``"console"``, ``"langfuse"``, ``"langsmith"``,
|
|
218
|
+
``"memory"``), a :class:`~windlass.interfaces.tracer.Tracer`, or a
|
|
219
|
+
factory.
|
|
220
|
+
**config: Tracer options.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
``self``.
|
|
224
|
+
"""
|
|
225
|
+
return self._set("tracer", spec if spec is not None else "console", config)
|
|
226
|
+
|
|
227
|
+
def checkpoint(self, spec: Any = None, /, **config: Any) -> Self:
|
|
228
|
+
"""Enable durable state, resume and human-in-the-loop approval.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
spec: Registry name (``"memory"``, ``"sqlite"``), a
|
|
232
|
+
:class:`~windlass.agent.checkpoint.Checkpointer`, or a factory.
|
|
233
|
+
Omit it for the in-process store.
|
|
234
|
+
**config: Checkpointer options — ``path``, ``max_history``.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
``self``.
|
|
238
|
+
|
|
239
|
+
Note:
|
|
240
|
+
Approval interrupts require a checkpointer, since a paused run has to
|
|
241
|
+
be stored somewhere before it can be resumed.
|
|
242
|
+
"""
|
|
243
|
+
return self._set("checkpointer", spec if spec is not None else "memory", config)
|
|
244
|
+
|
|
245
|
+
def agent(self, name: str, worker: Any, description: str = "") -> Self:
|
|
246
|
+
"""Add a specialist, turning this into a supervisor.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
name: The name the supervisor uses to delegate.
|
|
250
|
+
worker: An agent, builder, or anything with ``arun``.
|
|
251
|
+
description: What the specialist is good at. The supervisor routes on
|
|
252
|
+
this text.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
``self``.
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
>>> from windlass import Windlass
|
|
259
|
+
>>> _ = (Windlass.agent().llm("fake")
|
|
260
|
+
... .agent("researcher", Windlass.agent().llm("fake"),
|
|
261
|
+
... "Finds and summarises source material."))
|
|
262
|
+
"""
|
|
263
|
+
self._sub_agents[name] = worker
|
|
264
|
+
if description:
|
|
265
|
+
self._descriptions[name] = description
|
|
266
|
+
return self._invalidate()
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Behaviour
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
def system(self, prompt: str) -> Self:
|
|
272
|
+
"""Set the system prompt.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
prompt: Instructions prepended to every run.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
``self``.
|
|
279
|
+
"""
|
|
280
|
+
self._options["system_prompt"] = prompt
|
|
281
|
+
return self._invalidate()
|
|
282
|
+
|
|
283
|
+
def name(self, value: str) -> Self:
|
|
284
|
+
"""Name the agent, for traces and multi-agent routing."""
|
|
285
|
+
self._options["name"] = value
|
|
286
|
+
return self._invalidate()
|
|
287
|
+
|
|
288
|
+
def max_iterations(self, limit: int) -> Self:
|
|
289
|
+
"""Set the reason/act step budget.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
limit: Maximum cycles before
|
|
293
|
+
:class:`~windlass.core.exceptions.MaxIterationsExceeded`.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
``self``.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
ValueError: If ``limit`` is not positive.
|
|
300
|
+
"""
|
|
301
|
+
if limit <= 0:
|
|
302
|
+
raise ValueError("max_iterations must be positive")
|
|
303
|
+
self._options["max_iterations"] = limit
|
|
304
|
+
return self._invalidate()
|
|
305
|
+
|
|
306
|
+
def tool_call_retries(self, limit: int) -> Self:
|
|
307
|
+
"""Set how often a run may recover from an unparseable tool call.
|
|
308
|
+
|
|
309
|
+
Models occasionally emit a tool call the provider cannot parse — most
|
|
310
|
+
often by nesting one call inside another's arguments, which the
|
|
311
|
+
tool-calling protocol has no way to express. Windlass shows the model the
|
|
312
|
+
problem and lets it retry, the same way it handles an invented tool name.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
limit: Maximum corrections per run. Each one costs an iteration from
|
|
316
|
+
:meth:`max_iterations`, so a model that never recovers still
|
|
317
|
+
terminates. ``0`` fails on the first malformed call.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
``self``.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError: If ``limit`` is negative.
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
>>> from windlass import Windlass
|
|
327
|
+
>>> _ = Windlass.agent().llm("fake").tool_call_retries(3)
|
|
328
|
+
"""
|
|
329
|
+
if limit < 0:
|
|
330
|
+
raise ValueError("tool_call_retries must not be negative")
|
|
331
|
+
self._options["max_tool_call_retries"] = limit
|
|
332
|
+
return self._invalidate()
|
|
333
|
+
|
|
334
|
+
def parallel_tools(self, enabled: bool = True) -> Self:
|
|
335
|
+
"""Control whether simultaneous tool calls run concurrently.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
enabled: False forces serial execution — occasionally necessary when
|
|
339
|
+
tools share mutable state.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
``self``.
|
|
343
|
+
"""
|
|
344
|
+
self._options["parallel_tools"] = enabled
|
|
345
|
+
return self._invalidate()
|
|
346
|
+
|
|
347
|
+
def human_in_the_loop(self, enabled: bool = True) -> Self:
|
|
348
|
+
"""Require human approval before *every* tool call.
|
|
349
|
+
|
|
350
|
+
Individual tools can require approval on their own with
|
|
351
|
+
``@tool(requires_approval=True)``; this turns it on globally.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
enabled: True to pause before every tool call.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
``self``.
|
|
358
|
+
|
|
359
|
+
Note:
|
|
360
|
+
Implies a checkpointer, since a paused run has to be stored. One is
|
|
361
|
+
enabled automatically if you have not chosen one.
|
|
362
|
+
"""
|
|
363
|
+
self._options["require_approval"] = enabled
|
|
364
|
+
if enabled and "checkpointer" not in self._specs:
|
|
365
|
+
self.checkpoint()
|
|
366
|
+
return self._invalidate()
|
|
367
|
+
|
|
368
|
+
def graph(self, enabled: bool = True) -> Self:
|
|
369
|
+
"""Use the LangGraph runtime instead of the built-in loop.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
enabled: True to compile the agent into a LangGraph state machine.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
``self``.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
MissingDependencyError: At build time, when ``langgraph`` is missing.
|
|
379
|
+
"""
|
|
380
|
+
self._use_graph = enabled
|
|
381
|
+
return self._invalidate()
|
|
382
|
+
|
|
383
|
+
def bind(self, key: str, factory: Any) -> Self:
|
|
384
|
+
"""Bind an arbitrary dependency into this builder's container."""
|
|
385
|
+
if callable(factory):
|
|
386
|
+
self.container.bind(key, factory)
|
|
387
|
+
else:
|
|
388
|
+
self.container.bind_instance(key, factory)
|
|
389
|
+
return self._invalidate()
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Build
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
def build(self) -> AgentRuntime:
|
|
395
|
+
"""Resolve every component and construct the runtime.
|
|
396
|
+
|
|
397
|
+
Called automatically on first use.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
An :class:`~windlass.agent.runtime.AgentRuntime`, a
|
|
401
|
+
:class:`~windlass.agent.graph.LangGraphRuntime`, or a
|
|
402
|
+
:class:`~windlass.agent.supervisor.Supervisor` — whichever the
|
|
403
|
+
configuration describes.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
ConfigurationError: When a component cannot be constructed.
|
|
407
|
+
MissingDependencyError: When a chosen provider's extra is missing.
|
|
408
|
+
"""
|
|
409
|
+
if self._runtime is not None:
|
|
410
|
+
return self._runtime
|
|
411
|
+
|
|
412
|
+
from windlass.core.config import settings
|
|
413
|
+
|
|
414
|
+
cfg = settings()
|
|
415
|
+
llm = self._resolve("llm", cfg.default_llm)
|
|
416
|
+
registry = ToolRegistry()
|
|
417
|
+
|
|
418
|
+
for item in self._tools:
|
|
419
|
+
registry.add(self.container.component("tool", item) if isinstance(item, str) else item)
|
|
420
|
+
|
|
421
|
+
for spec, config in self._mcp_specs:
|
|
422
|
+
for remote in self._connect_mcp(spec, config):
|
|
423
|
+
registry.add(remote)
|
|
424
|
+
|
|
425
|
+
common: dict[str, Any] = {
|
|
426
|
+
"llm": llm,
|
|
427
|
+
"memory": self._resolve_memory(),
|
|
428
|
+
"guardrail": self._resolve_optional("guardrail"),
|
|
429
|
+
"checkpointer": self._resolve_optional("checkpointer"),
|
|
430
|
+
"tracer": self._resolve_optional("tracer"),
|
|
431
|
+
**self._options,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if self._sub_agents:
|
|
435
|
+
from windlass.agent.supervisor import Supervisor
|
|
436
|
+
|
|
437
|
+
self._runtime = Supervisor(
|
|
438
|
+
agents={k: _built(v) for k, v in self._sub_agents.items()},
|
|
439
|
+
descriptions=self._descriptions,
|
|
440
|
+
tools=list(registry),
|
|
441
|
+
**common,
|
|
442
|
+
)
|
|
443
|
+
elif self._use_graph:
|
|
444
|
+
from windlass.agent.graph import LangGraphRuntime
|
|
445
|
+
|
|
446
|
+
self._runtime = LangGraphRuntime(tools=registry, **common)
|
|
447
|
+
else:
|
|
448
|
+
self._runtime = AgentRuntime(tools=registry, **common)
|
|
449
|
+
|
|
450
|
+
return self._runtime
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def runtime(self) -> AgentRuntime:
|
|
454
|
+
"""The built runtime, constructing it on first access."""
|
|
455
|
+
return self.build()
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Runtime delegation
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
def run(self, prompt: Any, **kwargs: Any) -> AgentResponse:
|
|
461
|
+
"""Run the agent. See :meth:`~windlass.agent.runtime.AgentRuntime.run`."""
|
|
462
|
+
return self.build().run(prompt, **kwargs)
|
|
463
|
+
|
|
464
|
+
async def arun(self, prompt: Any, **kwargs: Any) -> AgentResponse:
|
|
465
|
+
"""Async :meth:`run`."""
|
|
466
|
+
return await self.build().arun(prompt, **kwargs)
|
|
467
|
+
|
|
468
|
+
def stream(self, prompt: Any, **kwargs: Any) -> Iterator[StreamEvent]:
|
|
469
|
+
"""Stream the run. See :meth:`~windlass.agent.runtime.AgentRuntime.stream`."""
|
|
470
|
+
return self.build().stream(prompt, **kwargs)
|
|
471
|
+
|
|
472
|
+
def astream(self, prompt: Any, **kwargs: Any) -> AsyncIterator[StreamEvent]:
|
|
473
|
+
"""Async :meth:`stream`."""
|
|
474
|
+
return self.build().astream(prompt, **kwargs)
|
|
475
|
+
|
|
476
|
+
def astream_text(self, prompt: Any, **kwargs: Any) -> AsyncIterator[str]:
|
|
477
|
+
"""Stream only the assistant's text."""
|
|
478
|
+
return self.build().astream_text(prompt, **kwargs)
|
|
479
|
+
|
|
480
|
+
def resume(self, thread_id: str, **kwargs: Any) -> AgentResponse:
|
|
481
|
+
"""Resume an interrupted run."""
|
|
482
|
+
return self.build().resume(thread_id, **kwargs)
|
|
483
|
+
|
|
484
|
+
async def aresume(self, thread_id: str, **kwargs: Any) -> AgentResponse:
|
|
485
|
+
"""Async :meth:`resume`."""
|
|
486
|
+
return await self.build().aresume(thread_id, **kwargs)
|
|
487
|
+
|
|
488
|
+
def pending_approvals(self, thread_id: str) -> list[Any]:
|
|
489
|
+
"""Return the tool calls awaiting approval on a thread."""
|
|
490
|
+
return self.build().pending_approvals(thread_id)
|
|
491
|
+
|
|
492
|
+
def state(self, thread_id: str) -> dict[str, Any] | None:
|
|
493
|
+
"""Return the latest checkpoint for a thread."""
|
|
494
|
+
return self.build().state(thread_id)
|
|
495
|
+
|
|
496
|
+
def history(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
497
|
+
"""Return recent checkpoints for a thread."""
|
|
498
|
+
return self.build().history(thread_id, limit)
|
|
499
|
+
|
|
500
|
+
def broadcast(self, task: str) -> dict[str, AgentResponse]:
|
|
501
|
+
"""Run every specialist on the same task (supervisor only).
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
ConfigurationError: When no specialists are configured.
|
|
505
|
+
"""
|
|
506
|
+
runtime = self.build()
|
|
507
|
+
caller = getattr(runtime, "broadcast", None)
|
|
508
|
+
if caller is None:
|
|
509
|
+
raise ConfigurationError(
|
|
510
|
+
"broadcast() needs specialist agents.",
|
|
511
|
+
hint="Add them with .agent('name', worker).",
|
|
512
|
+
)
|
|
513
|
+
return caller(task)
|
|
514
|
+
|
|
515
|
+
def pipeline(self, task: str, order: list[str]) -> AgentResponse:
|
|
516
|
+
"""Chain specialists in sequence (supervisor only).
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
ConfigurationError: When no specialists are configured.
|
|
520
|
+
"""
|
|
521
|
+
runtime = self.build()
|
|
522
|
+
caller = getattr(runtime, "pipeline", None)
|
|
523
|
+
if caller is None:
|
|
524
|
+
raise ConfigurationError(
|
|
525
|
+
"pipeline() needs specialist agents.",
|
|
526
|
+
hint="Add them with .agent('name', worker).",
|
|
527
|
+
)
|
|
528
|
+
return caller(task, order)
|
|
529
|
+
|
|
530
|
+
# -- Level 3 ----------------------------------------------------------
|
|
531
|
+
def native_llm(self) -> Any:
|
|
532
|
+
"""Return the model provider's own client."""
|
|
533
|
+
return self.build().native_llm()
|
|
534
|
+
|
|
535
|
+
def native_graph(self) -> Any:
|
|
536
|
+
"""Return the LangGraph ``StateGraph``.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
AgentError: When the agent was not built with :meth:`graph`.
|
|
540
|
+
"""
|
|
541
|
+
return self.build().native_graph()
|
|
542
|
+
|
|
543
|
+
def recompile(self) -> Any:
|
|
544
|
+
"""Recompile the graph after editing it.
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
ConfigurationError: When the agent is not graph-backed.
|
|
548
|
+
"""
|
|
549
|
+
runtime = self.build()
|
|
550
|
+
recompiler = getattr(runtime, "recompile", None)
|
|
551
|
+
if recompiler is None:
|
|
552
|
+
raise ConfigurationError(
|
|
553
|
+
"recompile() only applies to graph-backed agents.",
|
|
554
|
+
hint="Build the agent with .graph().",
|
|
555
|
+
)
|
|
556
|
+
return recompiler()
|
|
557
|
+
|
|
558
|
+
def describe(self) -> dict[str, Any]:
|
|
559
|
+
"""Return a JSON-safe description of the agent."""
|
|
560
|
+
return self.build().describe()
|
|
561
|
+
|
|
562
|
+
def close(self) -> None:
|
|
563
|
+
"""Release the model's and MCP clients' resources."""
|
|
564
|
+
if self._runtime is not None:
|
|
565
|
+
self._runtime.close()
|
|
566
|
+
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
# Internals
|
|
569
|
+
# ------------------------------------------------------------------
|
|
570
|
+
def _set(self, kind: str, spec: Any, config: dict[str, Any]) -> Self:
|
|
571
|
+
self._specs[kind] = (spec, config)
|
|
572
|
+
return self._invalidate()
|
|
573
|
+
|
|
574
|
+
def _invalidate(self) -> Self:
|
|
575
|
+
self._runtime = None
|
|
576
|
+
return self
|
|
577
|
+
|
|
578
|
+
def _resolve(self, kind: str, default: str) -> Any:
|
|
579
|
+
"""Resolve a required component.
|
|
580
|
+
|
|
581
|
+
Precedence is explicit builder call, then a container binding, then the
|
|
582
|
+
configured default — see the matching note in
|
|
583
|
+
:meth:`~windlass.rag.builder.RAGBuilder._resolve`.
|
|
584
|
+
"""
|
|
585
|
+
spec, config = self._specs.get(kind, (None, {}))
|
|
586
|
+
if spec is None and not config and self.container.has(kind):
|
|
587
|
+
return self.container.component(kind)
|
|
588
|
+
return self.container.component(kind, spec if spec is not None else default, **config)
|
|
589
|
+
|
|
590
|
+
def _resolve_optional(self, kind: str) -> Any:
|
|
591
|
+
"""Resolve a component the user asked for, or one the container supplies."""
|
|
592
|
+
if kind not in self._specs:
|
|
593
|
+
return self.container.component(kind) if self.container.has(kind) else None
|
|
594
|
+
spec, config = self._specs[kind]
|
|
595
|
+
return self.container.component(kind, spec, **config)
|
|
596
|
+
|
|
597
|
+
def _resolve_memory(self) -> Any:
|
|
598
|
+
"""Resolve memory, injecting an embedder for the semantic backends."""
|
|
599
|
+
if "memory" not in self._specs:
|
|
600
|
+
return None
|
|
601
|
+
spec, config = self._specs["memory"]
|
|
602
|
+
options = dict(config)
|
|
603
|
+
if spec in {"vector", "long-term", "longterm", "semantic"} and "embedder" not in options:
|
|
604
|
+
from windlass.core.config import settings
|
|
605
|
+
|
|
606
|
+
options["embedder"] = self.container.component(
|
|
607
|
+
"embedding", settings().default_embedding
|
|
608
|
+
)
|
|
609
|
+
return self.container.component("memory", spec, **options)
|
|
610
|
+
|
|
611
|
+
def _connect_mcp(self, spec: Any, config: dict[str, Any]) -> list[Any]:
|
|
612
|
+
"""Connect one MCP server and return its tools.
|
|
613
|
+
|
|
614
|
+
A server that is unreachable logs a warning and contributes no tools,
|
|
615
|
+
rather than preventing the agent from starting. An agent that works with
|
|
616
|
+
four of its five tool sources is more useful than one that refuses to run.
|
|
617
|
+
"""
|
|
618
|
+
from windlass.core.concurrency import run_sync
|
|
619
|
+
from windlass.interfaces.mcp import MCPClient
|
|
620
|
+
|
|
621
|
+
options = dict(config)
|
|
622
|
+
namespace = len(self._mcp_specs) > 1
|
|
623
|
+
|
|
624
|
+
if isinstance(spec, MCPClient):
|
|
625
|
+
# An already-constructed client cannot take construction config, so
|
|
626
|
+
# the namespacing decision is applied to the live object instead.
|
|
627
|
+
client = spec
|
|
628
|
+
if namespace:
|
|
629
|
+
client.namespace = True
|
|
630
|
+
else:
|
|
631
|
+
if namespace:
|
|
632
|
+
options.setdefault("namespace", True)
|
|
633
|
+
client = self.container.component("mcp", spec, **options)
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
return list(run_sync(client.alist_tools()))
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
_log.warning(
|
|
639
|
+
"MCP server %r is unavailable; its tools are not bound: %s",
|
|
640
|
+
getattr(client, "server", spec),
|
|
641
|
+
exc,
|
|
642
|
+
)
|
|
643
|
+
return []
|
|
644
|
+
|
|
645
|
+
def __repr__(self) -> str:
|
|
646
|
+
runtime = "graph" if self._use_graph else ("supervisor" if self._sub_agents else "builtin")
|
|
647
|
+
return (
|
|
648
|
+
f"AgentBuilder(runtime={runtime}, tools={len(self._tools)}, "
|
|
649
|
+
f"mcp={len(self._mcp_specs)})"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
#: Bare model ids mapped to the provider that serves them, so ``.llm("gpt-4o")``
|
|
654
|
+
#: works as well as ``.llm("openai", model="gpt-4o")``.
|
|
655
|
+
_MODEL_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
656
|
+
("gpt-", "openai"),
|
|
657
|
+
("o1", "openai"),
|
|
658
|
+
("o3", "openai"),
|
|
659
|
+
("o4", "openai"),
|
|
660
|
+
("claude", "anthropic"),
|
|
661
|
+
("gemini", "gemini"),
|
|
662
|
+
("llama", "groq"),
|
|
663
|
+
("mixtral", "groq"),
|
|
664
|
+
("gemma", "groq"),
|
|
665
|
+
("qwen", "ollama"),
|
|
666
|
+
("mistral", "ollama"),
|
|
667
|
+
("phi", "ollama"),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _split_model(spec: str) -> tuple[str, str]:
|
|
672
|
+
"""Split a model spec into ``(provider, model)``.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
spec: A provider name (``"openai"``), a bare model id (``"gpt-4o"``), or
|
|
676
|
+
a qualified ``provider/model`` string.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
A ``(provider, model)`` pair; ``model`` is ``""`` when only a provider
|
|
680
|
+
was given.
|
|
681
|
+
|
|
682
|
+
Example:
|
|
683
|
+
>>> _split_model("openai")
|
|
684
|
+
('openai', '')
|
|
685
|
+
>>> _split_model("gpt-4o")
|
|
686
|
+
('openai', 'gpt-4o')
|
|
687
|
+
>>> _split_model("ollama/llama3.2")
|
|
688
|
+
('ollama', 'llama3.2')
|
|
689
|
+
"""
|
|
690
|
+
if "/" in spec:
|
|
691
|
+
provider, _, model = spec.partition("/")
|
|
692
|
+
return provider, model
|
|
693
|
+
|
|
694
|
+
from windlass.core.registry import REGISTRY
|
|
695
|
+
|
|
696
|
+
if REGISTRY.has("llm", spec):
|
|
697
|
+
return spec, ""
|
|
698
|
+
|
|
699
|
+
lowered = spec.lower()
|
|
700
|
+
for prefix, provider in _MODEL_PREFIXES:
|
|
701
|
+
if lowered.startswith(prefix):
|
|
702
|
+
return provider, spec
|
|
703
|
+
return spec, ""
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _built(candidate: Any) -> Any:
|
|
707
|
+
"""Return a runnable agent from a builder or a runtime."""
|
|
708
|
+
builder = getattr(candidate, "build", None)
|
|
709
|
+
return builder() if callable(builder) else candidate
|