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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- 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
|
+
]
|