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
@@ -0,0 +1,244 @@
1
+ """Async-safe, date-rolling JSON-lines logger for LLM calls.
2
+
3
+ :class:`JsonlPromptLogger` is the canonical reference implementation of
4
+ :class:`~ellements.core.observability.LLMObserver`. It writes one
5
+ self-contained JSON object per LLM call to a daily ``.jsonl`` file:
6
+
7
+ - **One file per UTC day**: the active filename incorporates the
8
+ current UTC date, so file rotation happens automatically without
9
+ background timers.
10
+ - **Race-free**: every write is serialized through an
11
+ :class:`asyncio.Lock`, so concurrent strategies sharing a logger
12
+ cannot interleave bytes.
13
+ - **Resilient**: I/O failures are downgraded to a warning rather than
14
+ propagated. Telemetry should never break the production path it is
15
+ observing.
16
+ - **Self-contained records**: the request, response, tool calls,
17
+ usage, and metadata for a single call all live in one line. There
18
+ is no cross-line schema to reassemble.
19
+
20
+ For a human-readable view of an existing log, see
21
+ :func:`~ellements.core.observability.format_log_markdown`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import os
29
+ from datetime import UTC, datetime
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from pydantic import BaseModel, Field
34
+
35
+ from .events import LLMErrorEvent, LLMRequestEvent, LLMResponseEvent
36
+
37
+ _logger = logging.getLogger(__name__)
38
+
39
+
40
+ class LoggedCall(BaseModel):
41
+ """One self-contained log record for a single LLM call.
42
+
43
+ Pydantic gives us free JSON round-tripping (including for
44
+ multimodal content embedded inside ``messages``) and machine-
45
+ readable schema introspection for downstream tooling.
46
+
47
+ The model is intentionally permissive on input (``messages`` and
48
+ ``tools`` are ``list[dict]``) because LLM payloads evolve faster
49
+ than we can express their full structure in types.
50
+
51
+ Attributes:
52
+ call_id: UUID matching the originating
53
+ :class:`LLMRequestEvent`.
54
+ timestamp: ISO 8601 UTC timestamp of the call start.
55
+ method: The :class:`LLMClient` method name (e.g.
56
+ ``"complete"``).
57
+ status: ``"ok"``, ``"error"``, or ``"pending"`` (the latter
58
+ is an internal transient state never written to disk).
59
+ model: The model identifier the call was sent to.
60
+ temperature: The temperature parameter, if specified.
61
+ max_tokens: The max-tokens parameter, if specified.
62
+ messages: The serialized message list.
63
+ tools: Provider-formatted tool definitions.
64
+ response: The final assistant text on success.
65
+ tool_calls: Tool calls the model issued.
66
+ usage: Provider-reported token usage.
67
+ duration_ms: Wall-clock latency.
68
+ extra_params: Additional provider-specific parameters.
69
+ metadata: Free-form annotation channel from observers.
70
+ error_type: Exception class name on failure.
71
+ error_message: Exception ``str()`` on failure.
72
+ """
73
+
74
+ call_id: str = Field(description="UUID identifying this call across events")
75
+ timestamp: str = Field(description="ISO 8601 UTC timestamp of the call start")
76
+ method: str = Field(description="LLMClient method name (e.g. 'complete')")
77
+ status: str = Field(description="'ok' or 'error'")
78
+ model: str
79
+ temperature: float | None = None
80
+ max_tokens: int | None = None
81
+ messages: list[dict[str, Any]] = Field(default_factory=list)
82
+ tools: list[dict[str, Any]] = Field(default_factory=list)
83
+ response: str = ""
84
+ tool_calls: list[dict[str, Any]] = Field(default_factory=list)
85
+ usage: dict[str, Any] | None = None
86
+ duration_ms: int = 0
87
+ extra_params: dict[str, Any] = Field(default_factory=dict)
88
+ metadata: dict[str, Any] = Field(default_factory=dict)
89
+ error_type: str | None = None
90
+ error_message: str | None = None
91
+
92
+
93
+ class JsonlPromptLogger:
94
+ """Async-safe, UTC-day-rolling JSON-lines observer for LLM calls.
95
+
96
+ The logger stages each call as a :class:`LoggedCall` in an
97
+ in-memory pending map keyed by ``call_id``. ``on_request`` opens
98
+ the record; ``on_response``/``on_error`` close it and flush a
99
+ single JSON line to disk. This means **the file only contains
100
+ completed calls** — partial calls (e.g. interrupted) leave no
101
+ trace.
102
+
103
+ Args:
104
+ log_dir: Directory where ``llm-log-YYYY-MM-DD.jsonl`` files
105
+ are written. Created if it does not exist.
106
+ filename_pattern: ``strftime``-formatted prefix; the resulting
107
+ file is ``log_dir / f"{filename_pattern}.jsonl"``. The
108
+ default ``"llm-log-%Y-%m-%d"`` produces
109
+ ``llm-log-2024-05-16.jsonl``.
110
+
111
+ Concurrency:
112
+ All writes are serialized by an :class:`asyncio.Lock`, so
113
+ multiple concurrent strategies sharing a single logger
114
+ produce a valid JSON-lines file (no torn rows).
115
+
116
+ Date rollover:
117
+ Every write computes the current UTC date through
118
+ :attr:`current_path`. When the date changes, the next write
119
+ opens a new file. No background timers, no locking on the
120
+ clock.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ log_dir: str | Path,
126
+ *,
127
+ filename_pattern: str = "llm-log-%Y-%m-%d",
128
+ ) -> None:
129
+ self.log_dir = Path(log_dir)
130
+ self.log_dir.mkdir(parents=True, exist_ok=True)
131
+ if os.name != "nt":
132
+ self.log_dir.chmod(0o700)
133
+ self._filename_pattern = filename_pattern
134
+ self._lock = asyncio.Lock()
135
+ # In-flight requests indexed by call_id so on_response / on_error
136
+ # can merge with the request payload (messages, tools, params)
137
+ # without every method plumbing a full record around.
138
+ self._pending: dict[str, LoggedCall] = {}
139
+
140
+ # ── Observer protocol ────────────────────────────────────────────
141
+
142
+ async def on_request(self, event: LLMRequestEvent) -> None:
143
+ """Stage a pending record for *event*.
144
+
145
+ The record is not flushed to disk until the matching response
146
+ or error arrives. This guarantees logged lines correspond to
147
+ completed calls.
148
+ """
149
+ record = LoggedCall(
150
+ call_id=event.call_id,
151
+ timestamp=datetime.now(UTC).isoformat(),
152
+ method=event.method,
153
+ status="pending",
154
+ model=event.model,
155
+ temperature=event.temperature,
156
+ max_tokens=event.max_tokens,
157
+ messages=list(event.messages),
158
+ tools=list(event.tools),
159
+ extra_params=dict(event.extra_params),
160
+ metadata=dict(event.metadata),
161
+ )
162
+ self._pending[event.call_id] = record
163
+
164
+ async def on_response(self, event: LLMResponseEvent) -> None:
165
+ """Finalize the pending record with success data and flush."""
166
+ record = self._pending.pop(event.call_id, None)
167
+ if record is None:
168
+ # The pending record may be missing if this logger was
169
+ # attached mid-flight; fall back to a partial record that
170
+ # still captures everything the response event knows.
171
+ record = LoggedCall(
172
+ call_id=event.call_id,
173
+ timestamp=datetime.now(UTC).isoformat(),
174
+ method=event.method,
175
+ status="ok",
176
+ model=event.model,
177
+ )
178
+ record.status = "ok"
179
+ record.response = event.response
180
+ record.tool_calls = list(event.tool_calls)
181
+ record.usage = event.usage
182
+ record.duration_ms = event.duration_ms
183
+ record.metadata = {**record.metadata, **event.metadata}
184
+ await self._write(record)
185
+
186
+ async def on_error(self, event: LLMErrorEvent) -> None:
187
+ """Finalize the pending record with error data and flush."""
188
+ record = self._pending.pop(event.call_id, None)
189
+ if record is None:
190
+ record = LoggedCall(
191
+ call_id=event.call_id,
192
+ timestamp=datetime.now(UTC).isoformat(),
193
+ method=event.method,
194
+ status="error",
195
+ model=event.model,
196
+ )
197
+ record.status = "error"
198
+ record.duration_ms = event.duration_ms
199
+ record.error_type = type(event.error).__name__
200
+ record.error_message = str(event.error)
201
+ record.metadata = {**record.metadata, **event.metadata}
202
+ await self._write(record)
203
+
204
+ # ── Paths ────────────────────────────────────────────────────────
205
+
206
+ @property
207
+ def current_path(self) -> Path:
208
+ """The absolute path the next write will land in.
209
+
210
+ Computed fresh on every access so UTC day rollover applies
211
+ without any external state.
212
+ """
213
+ stamp = datetime.now(UTC).strftime(self._filename_pattern)
214
+ return self.log_dir / f"{stamp}.jsonl"
215
+
216
+ # ── Internals ────────────────────────────────────────────────────
217
+
218
+ async def _write(self, record: LoggedCall) -> None:
219
+ """Serialize *record* and append it to :attr:`current_path`.
220
+
221
+ I/O happens inside :func:`asyncio.to_thread` so the event loop
222
+ is not blocked even on slow filesystems. The lock guarantees
223
+ ordering between concurrent writes.
224
+ """
225
+ line = record.model_dump_json() + "\n"
226
+ async with self._lock:
227
+ path = self.current_path
228
+ try:
229
+ await asyncio.to_thread(self._append_line, path, line)
230
+ except OSError as exc:
231
+ # Telemetry must never break the path it is observing.
232
+ _logger.warning(
233
+ "JsonlPromptLogger write failed for %s: %s", path, exc
234
+ )
235
+
236
+ @staticmethod
237
+ def _append_line(path: Path, line: str) -> None:
238
+ with path.open("a", encoding="utf-8") as handle:
239
+ handle.write(line)
240
+ if os.name != "nt":
241
+ path.chmod(0o600)
242
+
243
+
244
+ __all__ = ["JsonlPromptLogger", "LoggedCall"]
@@ -0,0 +1,197 @@
1
+ """Render an LLM call log as human-readable Markdown.
2
+
3
+ This is a pure pretty-printer with no I/O dependencies beyond reading
4
+ the input ``.jsonl`` file. It is decoupled from
5
+ :class:`JsonlPromptLogger` so it works against any
6
+ JSON-lines log that follows the :class:`LoggedCall` schema, including
7
+ files merged from multiple sources.
8
+
9
+ Output format:
10
+
11
+ - One ``## 🤖`` heading per call.
12
+ - A parameter table (model, temperature, max tokens, duration).
13
+ - A ``### 📨 Messages`` block, each message in a code fence.
14
+ - A ``### 🔧 Available Tools`` line listing tool names, if present.
15
+ - A ``### ⚙️ Tool Calls`` list of invocations with arguments and
16
+ truncated results.
17
+ - Either a ``### 💬 Response`` block (on success) or a ``### ❌ Error``
18
+ block (on failure).
19
+ - A ``### 📋 Metadata`` bullet list, if any.
20
+
21
+ Truncation:
22
+ Long fields are clipped with a footer that records the total
23
+ length. The defaults (3 KB response, 2 KB messages, 300 B tool
24
+ results) keep the document scannable even for very chatty calls.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from collections.abc import Iterable
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+
35
+ def _truncate(text: str, limit: int) -> str:
36
+ """Clip *text* at *limit* chars and append a length footer."""
37
+ if len(text) <= limit:
38
+ return text
39
+ return text[:limit] + f"\n\n… *(truncated, {len(text):,} chars total)*"
40
+
41
+
42
+ def _render_messages_markdown(messages: Iterable[dict[str, Any]]) -> str:
43
+ """Render a message list as a sequence of fenced code blocks."""
44
+ parts: list[str] = []
45
+ for i, message in enumerate(messages, 1):
46
+ role = str(message.get("role", "unknown")).upper()
47
+ content = message.get("content", "")
48
+ if isinstance(content, list):
49
+ rendered = json.dumps(content, indent=2)
50
+ else:
51
+ rendered = str(content)
52
+ parts.append(
53
+ f"**Message {i} — {role}**\n\n```\n{_truncate(rendered, 2000)}\n```"
54
+ )
55
+ return "\n\n".join(parts)
56
+
57
+
58
+ def _render_call_header(record: dict[str, Any]) -> list[str]:
59
+ """Render the call heading + parameter table."""
60
+ lines: list[str] = []
61
+ lines.append(f"## 🤖 {record.get('method', '?')} — {record.get('timestamp', '?')}")
62
+ lines.append("")
63
+ lines.append("| Parameter | Value |")
64
+ lines.append("|-----------|-------|")
65
+ lines.append(f"| **Status** | `{record.get('status', '?')}` |")
66
+ lines.append(f"| **Model** | `{record.get('model', '?')}` |")
67
+ if record.get("temperature") is not None:
68
+ lines.append(f"| **Temperature** | {record['temperature']} |")
69
+ if record.get("max_tokens") is not None:
70
+ lines.append(f"| **Max Tokens** | {record['max_tokens']:,} |")
71
+ duration = record.get("duration_ms") or 0
72
+ lines.append(f"| **Duration** | {duration/1000:.2f}s ({duration:,}ms) |")
73
+ if record.get("extra_params"):
74
+ for key, value in record["extra_params"].items():
75
+ lines.append(f"| **{key}** | `{value}` |")
76
+ lines.append("")
77
+ return lines
78
+
79
+
80
+ def _render_tools_section(record: dict[str, Any]) -> list[str]:
81
+ """Render the available-tools line + the executed tool-call list."""
82
+ lines: list[str] = []
83
+
84
+ tools = record.get("tools") or []
85
+ if tools:
86
+ names = [
87
+ (tool.get("function") or {}).get("name", "?")
88
+ for tool in tools
89
+ ]
90
+ lines.append(f"### 🔧 Available Tools: {', '.join(names)}")
91
+ lines.append("")
92
+
93
+ tool_calls = record.get("tool_calls") or []
94
+ if tool_calls:
95
+ lines.append("### ⚙️ Tool Calls")
96
+ lines.append("")
97
+ for i, call in enumerate(tool_calls, 1):
98
+ name = call.get("name", "?")
99
+ args = call.get("arguments", {})
100
+ result = call.get("result", "")
101
+ lines.append(f"**{i}. `{name}`**")
102
+ lines.append(f"- Args: `{json.dumps(args)}`")
103
+ lines.append(f"- Result: {_truncate(str(result), 300)}")
104
+ lines.append("")
105
+ return lines
106
+
107
+
108
+ def _render_outcome(record: dict[str, Any]) -> list[str]:
109
+ """Render either the success response or the error block."""
110
+ lines: list[str] = []
111
+ if record.get("status") == "ok":
112
+ lines.append("### 💬 Response")
113
+ lines.append("")
114
+ lines.append(f"```\n{_truncate(str(record.get('response', '')), 3000)}\n```")
115
+ lines.append("")
116
+ else:
117
+ lines.append("### ❌ Error")
118
+ lines.append("")
119
+ lines.append(f"`{record.get('error_type', 'UnknownError')}`")
120
+ lines.append("")
121
+ lines.append(f"```\n{_truncate(str(record.get('error_message', '')), 2000)}\n```")
122
+ lines.append("")
123
+ return lines
124
+
125
+
126
+ def _render_metadata(record: dict[str, Any]) -> list[str]:
127
+ """Render the optional metadata bullet list."""
128
+ lines: list[str] = []
129
+ metadata = record.get("metadata") or {}
130
+ if metadata:
131
+ lines.append("### 📋 Metadata")
132
+ lines.append("")
133
+ for key, value in metadata.items():
134
+ lines.append(f"- **{key}**: {value}")
135
+ lines.append("")
136
+ return lines
137
+
138
+
139
+ def _render_call_markdown(record: dict[str, Any]) -> str:
140
+ """Render a single :class:`LoggedCall`-shaped dict as Markdown.
141
+
142
+ Composed of four section helpers — header, messages, tools, and
143
+ outcome + metadata — so each piece stays small and individually
144
+ testable.
145
+ """
146
+ lines: list[str] = []
147
+ lines.extend(_render_call_header(record))
148
+
149
+ messages = record.get("messages") or []
150
+ if messages:
151
+ lines.append("### 📨 Messages")
152
+ lines.append("")
153
+ lines.append(_render_messages_markdown(messages))
154
+ lines.append("")
155
+
156
+ lines.extend(_render_tools_section(record))
157
+ lines.extend(_render_outcome(record))
158
+ lines.extend(_render_metadata(record))
159
+
160
+ lines.append("---")
161
+ return "\n".join(lines)
162
+
163
+
164
+ def format_log_markdown(jsonl_path: str | Path) -> str:
165
+ """Render every record in a ``.jsonl`` log file as Markdown.
166
+
167
+ Args:
168
+ jsonl_path: Path to a file produced by
169
+ :class:`~ellements.core.observability.JsonlPromptLogger`,
170
+ or any JSON-lines file following the same schema.
171
+
172
+ Returns:
173
+ A Markdown document with one section per logged LLM call,
174
+ separated by ``---`` rules.
175
+
176
+ Raises:
177
+ FileNotFoundError: If ``jsonl_path`` does not exist.
178
+ """
179
+ path = Path(jsonl_path)
180
+ if not path.exists():
181
+ raise FileNotFoundError(f"Log file not found: {path}")
182
+
183
+ sections: list[str] = []
184
+ for raw in path.read_text(encoding="utf-8").splitlines():
185
+ line = raw.strip()
186
+ if not line:
187
+ continue
188
+ try:
189
+ record = json.loads(line)
190
+ except json.JSONDecodeError as exc:
191
+ sections.append(f"<!-- Skipping malformed line: {exc} -->")
192
+ continue
193
+ sections.append(_render_call_markdown(record))
194
+ return "\n\n".join(sections)
195
+
196
+
197
+ __all__ = ["format_log_markdown"]
@@ -0,0 +1,56 @@
1
+ """Observer Protocols for ellements events.
2
+
3
+ An *observer* receives uniform events from one of ellements' event
4
+ streams without coupling to the producer. Two protocols live here:
5
+
6
+ :class:`LLMObserver` — push-based. Every
7
+ :class:`~ellements.core.LLMClient` method fires the same three events
8
+ (:class:`LLMRequestEvent`, :class:`LLMResponseEvent`,
9
+ :class:`LLMErrorEvent`) to every attached observer. Observers are
10
+ ``async`` so they can do I/O (file writes, network telemetry) without
11
+ blocking the event loop.
12
+
13
+ Agent events are pulled, not pushed: callers iterate the async stream
14
+ returned by :meth:`~ellements.agents.AgentBackend.stream_run`. There is
15
+ no ``AgentObserver`` Protocol because streaming itself is the observer
16
+ mechanism — the same backend supports zero, one, or many concurrent
17
+ consumers via independent ``stream_run`` invocations.
18
+
19
+ To convert a streaming agent into a push-based observable, callers
20
+ typically wrap the stream and forward events to whatever sink they
21
+ want (e.g. a GUI bridge). Such adapters belong outside core.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Protocol, runtime_checkable
27
+
28
+ from .events import LLMErrorEvent, LLMRequestEvent, LLMResponseEvent
29
+
30
+
31
+ @runtime_checkable
32
+ class LLMObserver(Protocol):
33
+ """Async push-based observer attached to an :class:`LLMClient`.
34
+
35
+ The client guarantees the event triple semantics: every call fires
36
+ exactly one ``on_request``, followed by exactly one of
37
+ ``on_response`` (success) or ``on_error`` (failure). The events
38
+ share a ``call_id`` so stateful observers can correlate them.
39
+
40
+ All three methods are ``async`` to permit non-blocking I/O. The
41
+ client awaits each method in sequence; observers that need to
42
+ fan out to many sinks should buffer internally rather than block
43
+ the LLM call.
44
+ """
45
+
46
+ async def on_request(self, event: LLMRequestEvent) -> None:
47
+ """Receive the pre-flight event for an LLM call."""
48
+
49
+ async def on_response(self, event: LLMResponseEvent) -> None:
50
+ """Receive the success event for an LLM call."""
51
+
52
+ async def on_error(self, event: LLMErrorEvent) -> None:
53
+ """Receive the failure event for an LLM call."""
54
+
55
+
56
+ __all__ = ["LLMObserver"]
@@ -0,0 +1,14 @@
1
+ """Prompting-related core mechanisms."""
2
+
3
+ from .context import PromptContext
4
+ from .guideline import GuidelineLibrary
5
+ from .persona import PersonaLibrary
6
+ from .sources import GuidelineSource, PersonaSource
7
+
8
+ __all__ = [
9
+ "GuidelineLibrary",
10
+ "GuidelineSource",
11
+ "PersonaLibrary",
12
+ "PersonaSource",
13
+ "PromptContext",
14
+ ]