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.
Files changed (104) hide show
  1. windlass/__init__.py +243 -0
  2. windlass/_version.py +7 -0
  3. windlass/agent/__init__.py +44 -0
  4. windlass/agent/builder.py +709 -0
  5. windlass/agent/checkpoint.py +330 -0
  6. windlass/agent/graph.py +410 -0
  7. windlass/agent/runtime.py +633 -0
  8. windlass/agent/supervisor.py +373 -0
  9. windlass/api.py +469 -0
  10. windlass/cli.py +389 -0
  11. windlass/core/__init__.py +145 -0
  12. windlass/core/cache.py +336 -0
  13. windlass/core/concurrency.py +341 -0
  14. windlass/core/config.py +461 -0
  15. windlass/core/container.py +383 -0
  16. windlass/core/exceptions.py +465 -0
  17. windlass/core/lazy.py +208 -0
  18. windlass/core/logging.py +204 -0
  19. windlass/core/registry.py +626 -0
  20. windlass/core/retry.py +288 -0
  21. windlass/core/text.py +505 -0
  22. windlass/core/types.py +671 -0
  23. windlass/core/vectors.py +306 -0
  24. windlass/interfaces/__init__.py +81 -0
  25. windlass/interfaces/base.py +191 -0
  26. windlass/interfaces/chunker.py +279 -0
  27. windlass/interfaces/embedding.py +259 -0
  28. windlass/interfaces/evaluator.py +264 -0
  29. windlass/interfaces/guardrail.py +289 -0
  30. windlass/interfaces/llm.py +410 -0
  31. windlass/interfaces/loader.py +349 -0
  32. windlass/interfaces/mcp.py +312 -0
  33. windlass/interfaces/memory.py +178 -0
  34. windlass/interfaces/preprocessor.py +195 -0
  35. windlass/interfaces/retriever.py +245 -0
  36. windlass/interfaces/tool.py +339 -0
  37. windlass/interfaces/tracer.py +366 -0
  38. windlass/interfaces/vectordb.py +340 -0
  39. windlass/providers/__init__.py +642 -0
  40. windlass/providers/chunkers/__init__.py +7 -0
  41. windlass/providers/chunkers/hierarchical.py +254 -0
  42. windlass/providers/chunkers/recursive.py +260 -0
  43. windlass/providers/chunkers/semantic.py +204 -0
  44. windlass/providers/chunkers/structural.py +323 -0
  45. windlass/providers/embeddings/__init__.py +7 -0
  46. windlass/providers/embeddings/hash.py +127 -0
  47. windlass/providers/embeddings/hf_inference.py +284 -0
  48. windlass/providers/embeddings/huggingface.py +187 -0
  49. windlass/providers/embeddings/openai.py +126 -0
  50. windlass/providers/evaluation/__init__.py +7 -0
  51. windlass/providers/evaluation/builtin.py +428 -0
  52. windlass/providers/evaluation/external.py +356 -0
  53. windlass/providers/guardrails/__init__.py +7 -0
  54. windlass/providers/guardrails/nemo.py +241 -0
  55. windlass/providers/guardrails/rules.py +241 -0
  56. windlass/providers/llm/__init__.py +9 -0
  57. windlass/providers/llm/anthropic.py +355 -0
  58. windlass/providers/llm/fake.py +271 -0
  59. windlass/providers/llm/gemini.py +291 -0
  60. windlass/providers/llm/groq.py +361 -0
  61. windlass/providers/llm/ollama.py +313 -0
  62. windlass/providers/llm/openai.py +424 -0
  63. windlass/providers/loaders/__init__.py +7 -0
  64. windlass/providers/loaders/media.py +296 -0
  65. windlass/providers/loaders/office.py +511 -0
  66. windlass/providers/loaders/text.py +507 -0
  67. windlass/providers/loaders/web.py +452 -0
  68. windlass/providers/mcp/__init__.py +7 -0
  69. windlass/providers/mcp/fastmcp.py +635 -0
  70. windlass/providers/memory/__init__.py +7 -0
  71. windlass/providers/memory/conversation.py +322 -0
  72. windlass/providers/memory/longterm.py +304 -0
  73. windlass/providers/observability/__init__.py +7 -0
  74. windlass/providers/observability/console.py +259 -0
  75. windlass/providers/observability/multi.py +160 -0
  76. windlass/providers/observability/platforms.py +424 -0
  77. windlass/providers/preprocessors/__init__.py +7 -0
  78. windlass/providers/preprocessors/clean.py +306 -0
  79. windlass/providers/preprocessors/dedup.py +321 -0
  80. windlass/providers/preprocessors/enrich.py +303 -0
  81. windlass/providers/preprocessors/privacy.py +307 -0
  82. windlass/providers/retrievers/__init__.py +7 -0
  83. windlass/providers/retrievers/bm25.py +365 -0
  84. windlass/providers/retrievers/contextual.py +326 -0
  85. windlass/providers/retrievers/hybrid.py +255 -0
  86. windlass/providers/retrievers/vector.py +247 -0
  87. windlass/providers/vectordb/__init__.py +7 -0
  88. windlass/providers/vectordb/chroma.py +382 -0
  89. windlass/providers/vectordb/faiss.py +445 -0
  90. windlass/providers/vectordb/memory.py +361 -0
  91. windlass/providers/vectordb/pinecone.py +442 -0
  92. windlass/py.typed +1 -0
  93. windlass/rag/__init__.py +34 -0
  94. windlass/rag/builder.py +739 -0
  95. windlass/rag/loading.py +250 -0
  96. windlass/rag/pipeline.py +854 -0
  97. windlass/testing.py +376 -0
  98. windlass/tools/__init__.py +458 -0
  99. windlass/tools/schema.py +417 -0
  100. windlass-0.1.0.dist-info/METADATA +679 -0
  101. windlass-0.1.0.dist-info/RECORD +104 -0
  102. windlass-0.1.0.dist-info/WHEEL +4 -0
  103. windlass-0.1.0.dist-info/entry_points.txt +2 -0
  104. 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