ellements 0.2.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 (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
ellements/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Curated root API and namespace bootstrap for the public Ellements repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from pathlib import Path
8
+ from pkgutil import extend_path
9
+ from typing import Any
10
+
11
+ __path__ = extend_path(__path__, __name__)
12
+
13
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
14
+ _SOURCE_ROOTS = [
15
+ "ellements-agents/src/ellements",
16
+ "ellements-benchmarking/src/ellements",
17
+ "ellements-cli/src/ellements",
18
+ "ellements-core/src/ellements",
19
+ "ellements-domain-specific/src/ellements",
20
+ "ellements-execution/src/ellements",
21
+ "ellements-fslm/src/ellements",
22
+ "ellements-reporting/src/ellements",
23
+ "ellements-standard-tools/src/ellements",
24
+ ]
25
+
26
+ for relative_root in _SOURCE_ROOTS:
27
+ candidate = _REPO_ROOT / relative_root
28
+ if candidate.is_dir():
29
+ candidate_str = str(candidate)
30
+ if candidate_str not in __path__:
31
+ __path__.append(candidate_str)
32
+
33
+ _ROOT_EXPORTS: dict[str, tuple[str, str]] = {
34
+ "Conversation": ("ellements.core", "Conversation"),
35
+ "ImageInput": ("ellements.core", "ImageInput"),
36
+ "LLMClient": ("ellements.core", "LLMClient"),
37
+ "SimpleTool": ("ellements.core", "SimpleTool"),
38
+ }
39
+
40
+ try:
41
+ __version__ = version("ellements")
42
+ except PackageNotFoundError:
43
+ __version__ = "0.2.0"
44
+
45
+ __all__ = [*sorted(_ROOT_EXPORTS), "__version__"]
46
+
47
+
48
+ def __getattr__(name: str) -> Any:
49
+ """Resolve curated public root exports lazily."""
50
+ if name not in _ROOT_EXPORTS:
51
+ raise AttributeError(f"module 'ellements' has no attribute {name!r}")
52
+ module_name, attr_name = _ROOT_EXPORTS[name]
53
+ return getattr(import_module(module_name), attr_name)
54
+
55
+
56
+ def __dir__() -> list[str]:
57
+ return sorted(set(globals()) | set(_ROOT_EXPORTS))
@@ -0,0 +1,45 @@
1
+ """Agent framework integration for ellements.
2
+
3
+ The package is built around the :class:`AgentBackend` Protocol so any
4
+ agent runtime (OpenAI Agents SDK, Anthropic Claude Agents, future
5
+ others) can be wired in without changing higher-level code. Two
6
+ concrete backends ship with the package:
7
+
8
+ - :class:`OpenAIAgentsBackend`
9
+ - :class:`ClaudeAgentsBackend`
10
+
11
+ Higher-level helpers (:class:`AgentBuilder`, :class:`AgentController`,
12
+ :class:`AgentRunStats`, :class:`AgentRunResult`,
13
+ :func:`run_agent_with_progress`) are backend-agnostic and operate
14
+ through the protocol.
15
+ """
16
+
17
+ from .backend import AgentBackend, AgentEvent
18
+ from .builder import AgentBuilder, check_openai_api_key, load_prompt_file
19
+ from .claude_backend import ClaudeAgentsBackend
20
+ from .controller import AgentController, ControllerConfig
21
+ from .openai_backend import OpenAIAgentsBackend
22
+ from .runner import (
23
+ DEFAULT_MAX_TURNS,
24
+ AgentRunResult,
25
+ AgentRunStats,
26
+ run_agent_with_progress,
27
+ )
28
+ from .tools import create_tool_from_method
29
+
30
+ __all__ = [
31
+ "AgentBackend",
32
+ "AgentBuilder",
33
+ "AgentController",
34
+ "AgentEvent",
35
+ "AgentRunResult",
36
+ "AgentRunStats",
37
+ "ClaudeAgentsBackend",
38
+ "ControllerConfig",
39
+ "DEFAULT_MAX_TURNS",
40
+ "OpenAIAgentsBackend",
41
+ "check_openai_api_key",
42
+ "create_tool_from_method",
43
+ "load_prompt_file",
44
+ "run_agent_with_progress",
45
+ ]
@@ -0,0 +1,100 @@
1
+ """The :class:`AgentBackend` Protocol — the single integration point.
2
+
3
+ Any agent framework that satisfies this Protocol can be plugged into
4
+ :class:`~ellements.agents.AgentBuilder`, :class:`AgentController`, and
5
+ :func:`run_agent_with_progress` without further changes.
6
+
7
+ :class:`AgentEvent` — the uniform vocabulary backends use to surface
8
+ streaming events — lives in :mod:`ellements.core.observability.events`
9
+ alongside the other observability primitives. It is re-exported from
10
+ this module for backend authors who only deal with the agent surface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncIterator
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+ from ellements.core import ToolRegistry
19
+ from ellements.core.observability import AgentEvent
20
+
21
+
22
+ @runtime_checkable
23
+ class AgentBackend(Protocol):
24
+ """Adapts an agent runtime to ellements' uniform agent surface."""
25
+
26
+ name: str
27
+ """Stable backend identifier, e.g. ``"openai_agents"``."""
28
+
29
+ def create_agent(
30
+ self,
31
+ *,
32
+ name: str,
33
+ instructions: str,
34
+ tools: ToolRegistry,
35
+ model: str,
36
+ ) -> Any:
37
+ """Construct a runtime-native agent instance.
38
+
39
+ *tools* is the canonical :class:`~ellements.core.ToolRegistry`;
40
+ each backend adapts the contained :class:`~ellements.core.ToolSpec`
41
+ instances to its native tool type.
42
+ """
43
+
44
+ def create_session(
45
+ self, session_id: str | None = None, **kwargs: Any
46
+ ) -> Any | None:
47
+ """Construct a runtime-native session object, or ``None``.
48
+
49
+ Backends that don't support sessions return ``None``. Backends
50
+ that support sessions but were given ``None`` for *session_id*
51
+ should also return ``None`` (caller wants no session).
52
+
53
+ Extra backend-specific options (e.g. ``storage_path`` for
54
+ ``OpenAIAgentsBackend``) are passed via ``**kwargs``; backends
55
+ ignore options they don't recognize.
56
+ """
57
+
58
+ async def run(
59
+ self,
60
+ agent: Any,
61
+ task: str,
62
+ *,
63
+ max_turns: int = 10,
64
+ session: Any | None = None,
65
+ **kwargs: Any,
66
+ ) -> Any:
67
+ """Run *agent* on *task* and return its native result object.
68
+
69
+ Implementations should propagate :class:`asyncio.CancelledError`
70
+ unchanged.
71
+ """
72
+
73
+ def stream_run(
74
+ self,
75
+ agent: Any,
76
+ task: str,
77
+ *,
78
+ max_turns: int = 10,
79
+ session: Any | None = None,
80
+ **kwargs: Any,
81
+ ) -> AgentStream:
82
+ """Run *agent* on *task* and return a streaming handle.
83
+
84
+ Use :class:`AgentStream` as both an async iterator (over
85
+ :class:`AgentEvent`) and a final-result holder accessible via
86
+ :attr:`AgentStream.result` after iteration completes.
87
+ """
88
+
89
+
90
+ class AgentStream(Protocol):
91
+ """Combined async-iterator + final-result handle for streaming runs."""
92
+
93
+ def __aiter__(self) -> AsyncIterator[AgentEvent]: ...
94
+
95
+ @property
96
+ def result(self) -> Any:
97
+ """The native runtime result, populated once streaming completes."""
98
+
99
+
100
+ __all__ = ["AgentBackend", "AgentEvent", "AgentStream"]
@@ -0,0 +1,303 @@
1
+ """Fluent builder for assembling agents on top of any :class:`AgentBackend`.
2
+
3
+ The builder unifies three concerns:
4
+
5
+ 1. **Instructions** — direct text, a file, or a rendered template.
6
+ 2. **Persona + Guideline** — composed through
7
+ :class:`ellements.core.PromptContext` + :class:`PersonaLibrary` /
8
+ :class:`GuidelineLibrary`.
9
+ 3. **Tools** — accumulated in a :class:`~ellements.core.ToolRegistry`
10
+ and forwarded to the backend, which adapts them to its native
11
+ tool format.
12
+
13
+ There are **no** alias methods, **no** ``run_sync`` flavours, **no**
14
+ ``.mustache.md`` legacy branch, and **no** lazy circular-import hacks.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from collections.abc import Callable, Mapping
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ellements.core import (
25
+ GuidelineLibrary,
26
+ PersonaLibrary,
27
+ ToolRegistry,
28
+ )
29
+ from ellements.core.prompting import PromptContext
30
+ from ellements.core.templating import render_prompt_template
31
+
32
+ from .backend import AgentBackend
33
+
34
+
35
+ class AgentBuilder:
36
+ """Compose an agent backend with tools, instructions, and prompt context.
37
+
38
+ Example:
39
+ >>> from ellements.agents import AgentBuilder, OpenAIAgentsBackend
40
+ >>> agent = (
41
+ ... AgentBuilder("YouTubeExpert", backend=OpenAIAgentsBackend())
42
+ ... .with_instructions("Find the best YouTube videos")
43
+ ... .with_model("gpt-4o-mini")
44
+ ... .with_tools(youtube_search_tools())
45
+ ... .build()
46
+ ... )
47
+ """
48
+
49
+ def __init__(self, name: str, *, backend: AgentBackend) -> None:
50
+ self.name = name
51
+ self.backend = backend
52
+ self.model: str | None = None
53
+ self.tools = ToolRegistry()
54
+ self.instructions: str | None = None
55
+ self.instruction_template: str | None = None
56
+ self.template_renderer: Callable[..., str] | None = None
57
+ self.prompt_context = PromptContext()
58
+
59
+ # ── Identity / model ───────────────────────────────────────────
60
+
61
+ def with_model(self, model: str) -> AgentBuilder:
62
+ self.model = model
63
+ return self
64
+
65
+ # ── Tools ──────────────────────────────────────────────────────
66
+
67
+ def with_tools(self, tools: ToolRegistry | Mapping[str, Any]) -> AgentBuilder:
68
+ """Bulk-add tools from a :class:`ToolRegistry` or ``{name: callable}`` mapping."""
69
+ for name, tool in tools.items():
70
+ self.tools.register(tool, name=name)
71
+ return self
72
+
73
+ def with_tool(self, name: str, tool: Any) -> AgentBuilder:
74
+ """Add a single tool under *name*."""
75
+ self.tools.register(tool, name=name)
76
+ return self
77
+
78
+ # ── Instructions ───────────────────────────────────────────────
79
+
80
+ def with_instructions(self, instructions: str) -> AgentBuilder:
81
+ """Set inline instructions text."""
82
+ self.instructions = instructions
83
+ return self
84
+
85
+ def with_instructions_from_file(
86
+ self, path: Path | str, *, fallback: str | None = None
87
+ ) -> AgentBuilder:
88
+ """Load instructions from *path*.
89
+
90
+ Raises:
91
+ FileNotFoundError: When the file is missing and no
92
+ ``fallback`` is provided.
93
+ """
94
+ try:
95
+ self.instructions = Path(path).read_text(encoding="utf-8")
96
+ except FileNotFoundError:
97
+ if fallback is None:
98
+ raise
99
+ self.instructions = fallback
100
+ return self
101
+
102
+ def with_instructions_template(
103
+ self,
104
+ template_name: str,
105
+ *,
106
+ renderer: Callable[..., str] | None = None,
107
+ ) -> AgentBuilder:
108
+ """Render instructions from a Mustache template.
109
+
110
+ For split templates the builder loads
111
+ ``{template_name}.system.mustache.md`` for instructions and
112
+ ``{template_name}.user.mustache.md`` (when present) for the
113
+ guideline message returned by
114
+ :meth:`get_guideline_user_message`.
115
+
116
+ Args:
117
+ template_name: Template basename. May be qualified with a
118
+ package-relative path (e.g. ``"writing/poem"``).
119
+ renderer: Override the default ``render_prompt_template``
120
+ helper (which loads from this package's ``prompts/``).
121
+ """
122
+ self.instruction_template = template_name
123
+ self.template_renderer = renderer or _default_template_renderer
124
+ return self
125
+
126
+ # ── Persona ────────────────────────────────────────────────────
127
+
128
+ def with_persona(self, persona_folder: Path | str) -> AgentBuilder:
129
+ """Attach a :class:`PersonaLibrary` loaded from *persona_folder*."""
130
+ self.prompt_context.attach_persona_library(
131
+ PersonaLibrary(persona_folder), fallback_to_first=True
132
+ )
133
+ return self
134
+
135
+ def with_persona_id(self, persona_id: str) -> AgentBuilder:
136
+ """Select an active persona by its registered ID."""
137
+ if self.prompt_context.persona_library is None:
138
+ raise ValueError(
139
+ "Persona library not attached. Call .with_persona(folder) first."
140
+ )
141
+ self.prompt_context.set_persona_by_id(persona_id)
142
+ return self
143
+
144
+ def with_persona_data(self, persona_data: dict[str, Any]) -> AgentBuilder:
145
+ """Activate a persona from inline data (no library required)."""
146
+ self.prompt_context.set_persona_from_data(persona_data)
147
+ return self
148
+
149
+ def with_persona_from_path(self, path: Path | str) -> AgentBuilder:
150
+ """Activate a persona from an arbitrary file path."""
151
+ self.prompt_context.set_persona_from_path(path)
152
+ return self
153
+
154
+ # ── Guideline ──────────────────────────────────────────────────
155
+
156
+ def with_guideline(self, guideline_folder: Path | str) -> AgentBuilder:
157
+ """Attach a :class:`GuidelineLibrary` loaded from *guideline_folder*."""
158
+ self.prompt_context.attach_guideline_library(
159
+ GuidelineLibrary(guideline_folder)
160
+ )
161
+ return self
162
+
163
+ def with_guideline_id(self, guideline_id: str) -> AgentBuilder:
164
+ """Select an active guideline by its registered ID."""
165
+ if self.prompt_context.guideline_library is None:
166
+ raise ValueError(
167
+ "Guideline library not attached. Call .with_guideline(folder) first."
168
+ )
169
+ self.prompt_context.set_guideline_by_id(guideline_id)
170
+ return self
171
+
172
+ def with_guideline_text(self, text: str) -> AgentBuilder:
173
+ """Activate a guideline from inline text."""
174
+ self.prompt_context.set_guideline_from_text(text)
175
+ return self
176
+
177
+ def with_guideline_from_path(self, path: Path | str) -> AgentBuilder:
178
+ """Activate a guideline from an arbitrary file path."""
179
+ self.prompt_context.set_guideline_from_path(path)
180
+ return self
181
+
182
+ def clear_guideline(self) -> None:
183
+ """Deactivate the currently selected guideline."""
184
+ self.prompt_context.clear_guideline()
185
+
186
+ # ── Convenience read-out ───────────────────────────────────────
187
+
188
+ @property
189
+ def current_persona_id(self) -> str | None:
190
+ return self.prompt_context.current_persona_id
191
+
192
+ @property
193
+ def current_guideline_id(self) -> str | None:
194
+ return self.prompt_context.current_guideline_id
195
+
196
+ def get_guideline_user_message(self) -> str | None:
197
+ """Return the rendered guideline user-message, if available.
198
+
199
+ Only meaningful when both a guideline is active and the
200
+ instructions are template-driven with a sibling
201
+ ``{template}.user.mustache.md`` file.
202
+ """
203
+ guideline_text = self.prompt_context.get_guideline_text()
204
+ if not guideline_text or self.instruction_template is None:
205
+ return None
206
+ if self.template_renderer is None:
207
+ return None
208
+ user_template = f"{self.instruction_template}.user.mustache.md"
209
+ try:
210
+ rendered = self.template_renderer(
211
+ user_template, guideline=guideline_text
212
+ )
213
+ except FileNotFoundError:
214
+ return None
215
+ return rendered.strip() or None
216
+
217
+ # ── Build ──────────────────────────────────────────────────────
218
+
219
+ def build(self) -> Any:
220
+ """Materialize the agent through the configured backend."""
221
+ if self.model is None:
222
+ raise ValueError(
223
+ "Model must be set before building. Call .with_model(...)"
224
+ )
225
+
226
+ instructions = self._resolve_instructions()
227
+ return self.backend.create_agent(
228
+ name=self.name,
229
+ instructions=instructions,
230
+ tools=self.tools,
231
+ model=self.model,
232
+ )
233
+
234
+ async def run(self, task: str, *, max_turns: int = 10, **kwargs: Any) -> Any:
235
+ """Build and run the agent on *task* using the backend."""
236
+ agent = self.build()
237
+ return await self.backend.run(
238
+ agent, task, max_turns=max_turns, **kwargs
239
+ )
240
+
241
+ # ── Internals ──────────────────────────────────────────────────
242
+
243
+ def _resolve_instructions(self) -> str:
244
+ if self.instruction_template is not None:
245
+ if self.template_renderer is None:
246
+ raise ValueError("Template renderer is not set")
247
+ persona_text = self.prompt_context.get_persona_text()
248
+ if persona_text is None:
249
+ raise ValueError(
250
+ "Template-mode instructions require an active persona. "
251
+ "Call .with_persona(...) and select a persona first."
252
+ )
253
+ system_template = f"{self.instruction_template}.system.mustache.md"
254
+ return self.template_renderer(
255
+ system_template, persona=persona_text, guideline=""
256
+ )
257
+ if self.instructions is not None:
258
+ return self.instructions
259
+ raise ValueError(
260
+ "Instructions are not set. Use .with_instructions(...) "
261
+ "or .with_instructions_template(...)."
262
+ )
263
+
264
+
265
+ # ── Module-level helpers ───────────────────────────────────────────
266
+
267
+
268
+ def _default_template_renderer(template_name: str, /, **context: Any) -> str:
269
+ """Default template renderer rooted at this package's ``prompts/``."""
270
+ return render_prompt_template(
271
+ template_name,
272
+ package="ellements.agents",
273
+ resource_root="prompts",
274
+ **context,
275
+ )
276
+
277
+
278
+ def check_openai_api_key() -> bool:
279
+ """Return whether ``OPENAI_API_KEY`` is set; print a hint when not."""
280
+ if os.getenv("OPENAI_API_KEY"):
281
+ return True
282
+ print("Error: OPENAI_API_KEY environment variable not set.")
283
+ print(" export OPENAI_API_KEY='your-api-key'")
284
+ return False
285
+
286
+
287
+ def load_prompt_file(
288
+ path: Path | str, *, fallback: str | None = None
289
+ ) -> str:
290
+ """Load a prompt file with an optional fallback string."""
291
+ try:
292
+ return Path(path).read_text(encoding="utf-8")
293
+ except FileNotFoundError:
294
+ if fallback is None:
295
+ raise
296
+ return fallback
297
+
298
+
299
+ __all__ = [
300
+ "AgentBuilder",
301
+ "check_openai_api_key",
302
+ "load_prompt_file",
303
+ ]