slmeval-cli 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.
- slmeval/__init__.py +71 -0
- slmeval/__main__.py +14 -0
- slmeval/adapters/__init__.py +24 -0
- slmeval/adapters/instrument.py +135 -0
- slmeval/adapters/langgraph.py +237 -0
- slmeval/cli/__init__.py +15 -0
- slmeval/cli/commands/__init__.py +35 -0
- slmeval/cli/commands/baseline_cmd.py +201 -0
- slmeval/cli/commands/cache_cmd.py +249 -0
- slmeval/cli/commands/dataset_cmd.py +52 -0
- slmeval/cli/commands/doctor_cmd.py +68 -0
- slmeval/cli/commands/gate_cmd.py +602 -0
- slmeval/cli/commands/init_cmd.py +144 -0
- slmeval/cli/commands/judge_cmd.py +284 -0
- slmeval/cli/commands/record_cmd.py +154 -0
- slmeval/cli/commands/report_cmd.py +98 -0
- slmeval/cli/commands/run_cmd.py +247 -0
- slmeval/cli/commands/show_cmd.py +482 -0
- slmeval/cli/commands/stubs.py +57 -0
- slmeval/cli/commands/validate_cmd.py +296 -0
- slmeval/cli/loading.py +149 -0
- slmeval/cli/main.py +91 -0
- slmeval/cli/render.py +251 -0
- slmeval/cli/wrap.py +31 -0
- slmeval/config/__init__.py +47 -0
- slmeval/config/loader.py +363 -0
- slmeval/config/locate.py +97 -0
- slmeval/config/models.py +430 -0
- slmeval/config/templates.py +246 -0
- slmeval/core/__init__.py +78 -0
- slmeval/core/adapter.py +253 -0
- slmeval/core/context.py +418 -0
- slmeval/core/outcome.py +260 -0
- slmeval/core/router.py +319 -0
- slmeval/core/runner.py +1209 -0
- slmeval/core/trace.py +188 -0
- slmeval/core/verdict.py +127 -0
- slmeval/dataset/__init__.py +8 -0
- slmeval/dataset/loader.py +130 -0
- slmeval/dataset/models.py +108 -0
- slmeval/doctor/__init__.py +7 -0
- slmeval/doctor/checks.py +380 -0
- slmeval/errors.py +76 -0
- slmeval/eval/__init__.py +64 -0
- slmeval/eval/assertions.py +356 -0
- slmeval/eval/calibration.py +364 -0
- slmeval/eval/cost.py +291 -0
- slmeval/eval/pipeline.py +273 -0
- slmeval/eval/stats.py +376 -0
- slmeval/eval/tier1.py +510 -0
- slmeval/eval/tier2.py +582 -0
- slmeval/eval/tier3.py +415 -0
- slmeval/exit_codes.py +28 -0
- slmeval/gate.py +722 -0
- slmeval/providers/__init__.py +69 -0
- slmeval/providers/base.py +426 -0
- slmeval/providers/fake.py +760 -0
- slmeval/providers/jsonx.py +239 -0
- slmeval/providers/ladder.py +339 -0
- slmeval/providers/openai_compat.py +392 -0
- slmeval/providers/registry.py +185 -0
- slmeval/providers/retry.py +81 -0
- slmeval/providers/schema.py +136 -0
- slmeval/providers/tokens.py +150 -0
- slmeval/py.typed +0 -0
- slmeval/report/__init__.py +115 -0
- slmeval/report/attribution.py +197 -0
- slmeval/report/baseline.py +353 -0
- slmeval/report/drilldown.py +1395 -0
- slmeval/report/export.py +450 -0
- slmeval/report/model.py +562 -0
- slmeval/report/progress.py +257 -0
- slmeval/report/scorecard.py +367 -0
- slmeval/sandbox/__init__.py +145 -0
- slmeval/sandbox/diagnosis.py +690 -0
- slmeval/sandbox/envelope.py +486 -0
- slmeval/sandbox/faults.py +794 -0
- slmeval/sandbox/keys.py +458 -0
- slmeval/sandbox/normalizers.py +333 -0
- slmeval/sandbox/store.py +525 -0
- slmeval/sandbox/tool.py +685 -0
- slmeval/storage/__init__.py +73 -0
- slmeval/storage/blobs.py +308 -0
- slmeval/storage/codec.py +93 -0
- slmeval/storage/db.py +1334 -0
- slmeval/storage/ingest.py +346 -0
- slmeval/storage/migrations.py +358 -0
- slmeval/storage/models.py +554 -0
- slmeval/storage/provenance.py +201 -0
- slmeval_cli-0.1.0.dist-info/METADATA +484 -0
- slmeval_cli-0.1.0.dist-info/RECORD +94 -0
- slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
- slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
slmeval/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""slmeval — benchmark agent workflows across small language models.
|
|
2
|
+
|
|
3
|
+
The public API is intentionally tiny. Integration is two touch points:
|
|
4
|
+
|
|
5
|
+
from slmeval import chat_model, node, sandbox_tool
|
|
6
|
+
|
|
7
|
+
async def run_case(case_input: dict) -> dict:
|
|
8
|
+
with node("intent_router"): # names the row in the scorecard
|
|
9
|
+
llm = chat_model() # routed per candidate, per node
|
|
10
|
+
decision = await llm.complete(msgs, schema=Routing)
|
|
11
|
+
|
|
12
|
+
`node` is a no-op outside a benchmark run, and `chat_model` falls through to
|
|
13
|
+
whichever model you name for production, so this is safe to leave in shipped
|
|
14
|
+
code:
|
|
15
|
+
|
|
16
|
+
llm = chat_model(provider="openai", model="gpt-4o-mini")
|
|
17
|
+
|
|
18
|
+
The second touch point is `@sandbox_tool`, which records a tool call once and
|
|
19
|
+
then replays it for every candidate, so a 90-execution matrix never touches your
|
|
20
|
+
real APIs:
|
|
21
|
+
|
|
22
|
+
@sandbox_tool(match_on=["order_id"], ignore_args=["request_id"])
|
|
23
|
+
async def fetch_order(order_id: str, request_id: str) -> dict:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
It is a no-op outside a benchmark too, so in production your tools do exactly
|
|
27
|
+
what they always did.
|
|
28
|
+
|
|
29
|
+
With LangGraph there is a third, which replaces the `node()` blocks entirely:
|
|
30
|
+
|
|
31
|
+
builder = slmeval.instrument_langgraph(StateGraph(State))
|
|
32
|
+
builder.add_node("intent_router", route)
|
|
33
|
+
|
|
34
|
+
Every node then names itself in the scorecard, and the graph still runs
|
|
35
|
+
unchanged outside a benchmark.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
from slmeval.adapters import instrument_langgraph, instrument_node
|
|
43
|
+
from slmeval.core.context import RunContext, node
|
|
44
|
+
from slmeval.core.router import ChatModel, chat_model
|
|
45
|
+
from slmeval.providers.base import (
|
|
46
|
+
Completion,
|
|
47
|
+
Msg,
|
|
48
|
+
ProviderError,
|
|
49
|
+
StructuredOutputError,
|
|
50
|
+
ToolCall,
|
|
51
|
+
ToolSpec,
|
|
52
|
+
)
|
|
53
|
+
from slmeval.sandbox.tool import CassetteMiss, sandbox_tool
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"CassetteMiss",
|
|
57
|
+
"ChatModel",
|
|
58
|
+
"Completion",
|
|
59
|
+
"Msg",
|
|
60
|
+
"ProviderError",
|
|
61
|
+
"RunContext",
|
|
62
|
+
"StructuredOutputError",
|
|
63
|
+
"ToolCall",
|
|
64
|
+
"ToolSpec",
|
|
65
|
+
"__version__",
|
|
66
|
+
"chat_model",
|
|
67
|
+
"instrument_langgraph",
|
|
68
|
+
"instrument_node",
|
|
69
|
+
"node",
|
|
70
|
+
"sandbox_tool",
|
|
71
|
+
]
|
slmeval/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""``python -m slmeval``, for when the console script is not on PATH.
|
|
2
|
+
|
|
3
|
+
A fresh virtualenv on Windows often is not activated in the shell the user
|
|
4
|
+
happens to be in, and "slmeval: command not found" five seconds after a
|
|
5
|
+
successful install is a bad first five minutes. This is the same entry point
|
|
6
|
+
the ``slmeval`` command uses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from slmeval.cli.main import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Framework adapters: the same benchmark, without the user marking anything.
|
|
2
|
+
|
|
3
|
+
There are two, and the gradient between them is deliberate.
|
|
4
|
+
|
|
5
|
+
:mod:`slmeval.adapters.instrument` is the generic machinery - a wrapper that
|
|
6
|
+
turns one callable into a timed, attributed, traced node. It knows nothing
|
|
7
|
+
about any framework and is what the others are built from.
|
|
8
|
+
|
|
9
|
+
:mod:`slmeval.adapters.langgraph` wraps every node of a LangGraph builder, so
|
|
10
|
+
a user with a graph writes one line and marks nothing by hand.
|
|
11
|
+
|
|
12
|
+
The adapter is never where the measurement happens. Model routing comes from
|
|
13
|
+
the ambient context, tool interception from ``@sandbox_tool``, and both work
|
|
14
|
+
with no adapter at all - so an adapter can only ever *add* attribution. That is
|
|
15
|
+
what keeps a framework integration from becoming a second implementation of the
|
|
16
|
+
harness, which is the way integrations usually rot.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from slmeval.adapters.instrument import instrument_node, state_keys
|
|
22
|
+
from slmeval.adapters.langgraph import instrument_langgraph
|
|
23
|
+
|
|
24
|
+
__all__ = ["instrument_langgraph", "instrument_node", "state_keys"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Turning one callable into an attributed node.
|
|
2
|
+
|
|
3
|
+
This is the piece every framework adapter is built from, and it is where the
|
|
4
|
+
blueprint's node wrapper had three bugs worth fixing by name, because each one
|
|
5
|
+
corrupts a number the scorecard prints.
|
|
6
|
+
|
|
7
|
+
**Exit through ``reset(token)``.** The blueprint restored the contextvar with
|
|
8
|
+
``set(token.old_value)``, which is not the contextvars API and goes wrong the
|
|
9
|
+
first time nodes nest - which is the first thing any real graph does. Entry and
|
|
10
|
+
exit both go through :func:`slmeval.core.context.entering_node` here, so there
|
|
11
|
+
is exactly one place that could get it wrong.
|
|
12
|
+
|
|
13
|
+
**Record in ``finally``, not per-``except``.** The blueprint's sync wrapper
|
|
14
|
+
caught the schema error and lost every other exception: a node that raised
|
|
15
|
+
``KeyError`` left no trace at all, so the denominator of every per-node rate
|
|
16
|
+
silently shrank. One ``finally``, one record, whatever happened - including
|
|
17
|
+
cancellation, which is how a benchmark's own ``timeout_s`` arrives.
|
|
18
|
+
|
|
19
|
+
**A crash is not a schema failure.** The blueprint recorded
|
|
20
|
+
``schema_valid=True`` on the generic-exception path, which quietly mixed
|
|
21
|
+
unrelated crashes into the one metric this tool exists to report. Schema
|
|
22
|
+
validity is tri-state, it lives on the model call
|
|
23
|
+
(:class:`~slmeval.core.trace.SchemaStatus`), and nothing here touches it: a
|
|
24
|
+
node that raised records an *error*, which is a different column.
|
|
25
|
+
|
|
26
|
+
Outside a benchmark the wrapper is a thin pass-through - it enters a node whose
|
|
27
|
+
trajectory nobody is collecting and calls the original. That is what makes it
|
|
28
|
+
safe to leave an instrumented graph in shipped code.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import inspect
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable, Mapping
|
|
36
|
+
from functools import wraps
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from slmeval.core.context import entering_node
|
|
40
|
+
from slmeval.core.trace import NodeVisit
|
|
41
|
+
|
|
42
|
+
ERROR_CHARS = 300
|
|
43
|
+
"""How much of an exception message is kept.
|
|
44
|
+
|
|
45
|
+
Enough to recognise, not enough to be a liability: one of these is stored per
|
|
46
|
+
node per execution, and a 4 kB traceback times ninety executions is a trace
|
|
47
|
+
store that costs more than the thing it measures. The *type* is what
|
|
48
|
+
aggregates anyway.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def state_keys(value: Any) -> tuple[str, ...]:
|
|
53
|
+
"""The keys of a state object, sorted, or ``()`` if it has none.
|
|
54
|
+
|
|
55
|
+
Keys and never values. A trace store must not quietly become a copy of a
|
|
56
|
+
customer's conversation state, and the shape is what carries the finding
|
|
57
|
+
anyway: a node that was supposed to write ``refund_eligible`` and returned
|
|
58
|
+
an empty dict is visible from the keys alone.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(value, Mapping):
|
|
61
|
+
return tuple(sorted(str(k) for k in value))
|
|
62
|
+
# Off the class, not the instance: Pydantic deprecated the instance
|
|
63
|
+
# attribute, and a benchmark that printed a DeprecationWarning per node per
|
|
64
|
+
# execution would teach people to filter our warnings.
|
|
65
|
+
owner = value if isinstance(value, type) else type(value)
|
|
66
|
+
fields = getattr(owner, "model_fields", None) or getattr(owner, "__dataclass_fields__", None)
|
|
67
|
+
if isinstance(fields, Mapping):
|
|
68
|
+
return tuple(sorted(str(k) for k in fields))
|
|
69
|
+
return ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _record(
|
|
73
|
+
visit: NodeVisit | None, started: float, *, error: BaseException | None
|
|
74
|
+
) -> None:
|
|
75
|
+
if visit is None:
|
|
76
|
+
return
|
|
77
|
+
visit.latency_ms = (time.perf_counter() - started) * 1000.0
|
|
78
|
+
if error is not None:
|
|
79
|
+
visit.error_type = type(error).__name__
|
|
80
|
+
visit.error = str(error)[:ERROR_CHARS] or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def instrument_node(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
84
|
+
"""Wrap ``func`` so that calling it enters the node ``name``.
|
|
85
|
+
|
|
86
|
+
Async-ness is preserved: a framework that awaits its nodes has to get a
|
|
87
|
+
coroutine function back, and one that calls them synchronously must not be
|
|
88
|
+
handed a coroutine it never awaits.
|
|
89
|
+
"""
|
|
90
|
+
if inspect.iscoroutinefunction(func):
|
|
91
|
+
|
|
92
|
+
@wraps(func)
|
|
93
|
+
async def async_node(*args: Any, **kwargs: Any) -> Any:
|
|
94
|
+
with entering_node(name) as visit:
|
|
95
|
+
if visit is not None and args:
|
|
96
|
+
visit.input_keys = state_keys(args[0])
|
|
97
|
+
started = time.perf_counter()
|
|
98
|
+
failure: BaseException | None = None
|
|
99
|
+
try:
|
|
100
|
+
result = await func(*args, **kwargs)
|
|
101
|
+
except BaseException as exc:
|
|
102
|
+
failure = exc
|
|
103
|
+
raise
|
|
104
|
+
else:
|
|
105
|
+
if visit is not None:
|
|
106
|
+
visit.output_keys = state_keys(result)
|
|
107
|
+
return result
|
|
108
|
+
finally:
|
|
109
|
+
_record(visit, started, error=failure)
|
|
110
|
+
|
|
111
|
+
return async_node
|
|
112
|
+
|
|
113
|
+
@wraps(func)
|
|
114
|
+
def sync_node(*args: Any, **kwargs: Any) -> Any:
|
|
115
|
+
with entering_node(name) as visit:
|
|
116
|
+
if visit is not None and args:
|
|
117
|
+
visit.input_keys = state_keys(args[0])
|
|
118
|
+
started = time.perf_counter()
|
|
119
|
+
failure: BaseException | None = None
|
|
120
|
+
try:
|
|
121
|
+
result = func(*args, **kwargs)
|
|
122
|
+
except BaseException as exc:
|
|
123
|
+
failure = exc
|
|
124
|
+
raise
|
|
125
|
+
else:
|
|
126
|
+
if visit is not None:
|
|
127
|
+
visit.output_keys = state_keys(result)
|
|
128
|
+
return result
|
|
129
|
+
finally:
|
|
130
|
+
_record(visit, started, error=failure)
|
|
131
|
+
|
|
132
|
+
return sync_node
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = ["ERROR_CHARS", "instrument_node", "state_keys"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The LangGraph adapter: one line, and every node is attributed.
|
|
2
|
+
|
|
3
|
+
import slmeval
|
|
4
|
+
from langgraph.graph import StateGraph
|
|
5
|
+
|
|
6
|
+
builder = slmeval.instrument_langgraph(StateGraph(State))
|
|
7
|
+
builder.add_node("intent_router", route)
|
|
8
|
+
builder.add_node("order_lookup", lookup)
|
|
9
|
+
graph = builder.compile()
|
|
10
|
+
|
|
11
|
+
Nothing else changes. The user writes no ``with node(...)`` blocks and the
|
|
12
|
+
scorecard still has a row per node - which is the whole value of a framework
|
|
13
|
+
adapter, because per-node attribution is the part nobody will hand-annotate
|
|
14
|
+
across a thirty-node graph.
|
|
15
|
+
|
|
16
|
+
**Instrument the builder, then add the nodes.** That ordering is the supported
|
|
17
|
+
path and the reason this adapter is boring: it replaces the builder's own
|
|
18
|
+
``add_node``, so each node's callable is wrapped *before* LangGraph ever sees
|
|
19
|
+
it, and from that point on the library's internals are none of our business.
|
|
20
|
+
Calling it after the nodes are added also works - the registered callables are
|
|
21
|
+
wrapped in place - but that path has to know how a given LangGraph version
|
|
22
|
+
stores a node, and it says so loudly rather than wrapping nothing when it does
|
|
23
|
+
not recognise one. An adapter that quietly attributed every call to ``None``
|
|
24
|
+
would print a flat scorecard, which reads as a finding rather than as a bug.
|
|
25
|
+
|
|
26
|
+
LangGraph itself is never imported here. It is an optional extra
|
|
27
|
+
(``pip install "slmeval-cli[langgraph]"``), the core must install in seconds
|
|
28
|
+
without a model framework, and the only thing this file needs to know is that
|
|
29
|
+
a builder has named nodes holding callables.
|
|
30
|
+
|
|
31
|
+
The adapter adds attribution and nothing else. Model routing still comes from
|
|
32
|
+
the ambient context and tool interception from ``@sandbox_tool``, both of which
|
|
33
|
+
work with no adapter at all - so an instrumented graph and a hand-marked one
|
|
34
|
+
produce the same trajectory. ``examples/support_agent`` is benchmarked both
|
|
35
|
+
ways to keep that true.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from slmeval.adapters.instrument import instrument_node
|
|
44
|
+
from slmeval.errors import ConfigError
|
|
45
|
+
|
|
46
|
+
MARK = "_slmeval_instrumented"
|
|
47
|
+
"""Set on anything this module has wrapped, so a second call is a no-op.
|
|
48
|
+
|
|
49
|
+
``instrument_langgraph(builder)`` twice - in a test fixture and again in the
|
|
50
|
+
graph's own factory, say - would otherwise enter every node twice, and Tier 2
|
|
51
|
+
would read the doubled trajectory as the agent looping.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
#: Where a node's callable lives, in the versions this has been built against.
|
|
55
|
+
#: ``func``/``afunc`` are LangGraph's own ``RunnableCallable``; ``invoke``/
|
|
56
|
+
#: ``ainvoke`` are the generic Runnable surface, wrapped only as a fallback.
|
|
57
|
+
CALLABLE_ATTRS = ("func", "afunc", "invoke", "ainvoke")
|
|
58
|
+
|
|
59
|
+
#: Where a builder keeps the runnable for a node.
|
|
60
|
+
NODE_ATTRS = ("runnable", "action", "bound")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def instrument_langgraph(builder: Any, *, prefix: str = "") -> Any:
|
|
64
|
+
"""Wrap every node of a LangGraph builder so each one names itself.
|
|
65
|
+
|
|
66
|
+
Returns the same builder, so it reads as a pipeline step either way:
|
|
67
|
+
|
|
68
|
+
builder = slmeval.instrument_langgraph(StateGraph(State))
|
|
69
|
+
...
|
|
70
|
+
graph = builder.compile()
|
|
71
|
+
|
|
72
|
+
``prefix`` namespaces the node names, for an agent that composes two graphs
|
|
73
|
+
with a ``plan`` node in each. Without it the two share one scorecard row
|
|
74
|
+
and "which plan node is failing" has no answer.
|
|
75
|
+
"""
|
|
76
|
+
if not hasattr(builder, "add_node"):
|
|
77
|
+
raise ConfigError(
|
|
78
|
+
f"instrument_langgraph() needs a graph builder, not {type(builder).__name__}",
|
|
79
|
+
hint=(
|
|
80
|
+
"Pass the StateGraph, not the compiled graph: a compiled "
|
|
81
|
+
"graph's nodes are already bound, and wrapping them would not "
|
|
82
|
+
"change what runs."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
_patch_add_node(builder, prefix)
|
|
87
|
+
existing = _existing_nodes(builder)
|
|
88
|
+
for name, holder in existing:
|
|
89
|
+
_wrap_node(_named(name, prefix), holder)
|
|
90
|
+
|
|
91
|
+
# The failure worth reporting is "nothing here was even a callable", not
|
|
92
|
+
# "nothing was wrapped": a second call finds every node already wrapped,
|
|
93
|
+
# which is success.
|
|
94
|
+
if existing and not any(_reachable(holder) for _, holder in existing):
|
|
95
|
+
raise ConfigError(
|
|
96
|
+
f"instrument_langgraph() could not reach the callable behind "
|
|
97
|
+
f"{len(existing)} already-registered node(s)",
|
|
98
|
+
hint=(
|
|
99
|
+
"Instrument the builder *before* adding nodes - "
|
|
100
|
+
"`builder = slmeval.instrument_langgraph(StateGraph(State))` - "
|
|
101
|
+
"which needs to know nothing about how your LangGraph version "
|
|
102
|
+
'stores a node. Or use adapter = "generic": call '
|
|
103
|
+
"slmeval.chat_model() in your node functions and wrap each in "
|
|
104
|
+
'`with node("name")`, for the same attribution by hand.'
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
return builder
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
# Before the nodes are added: replace add_node
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _patch_add_node(builder: Any, prefix: str) -> None:
|
|
116
|
+
"""Wrap each node's callable on its way in.
|
|
117
|
+
|
|
118
|
+
The supported path, and the one that needs no knowledge of LangGraph's
|
|
119
|
+
internals: ``add_node(name, action)`` is the public API, and the action is
|
|
120
|
+
still the user's own function when it arrives here.
|
|
121
|
+
"""
|
|
122
|
+
original = builder.add_node
|
|
123
|
+
if getattr(original, MARK, False):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
def add_node(node: Any = None, action: Any = None, **kwargs: Any) -> Any:
|
|
127
|
+
name, func, rest = _split_add_node_args(node, action)
|
|
128
|
+
if func is not None:
|
|
129
|
+
name = name or getattr(func, "__name__", None)
|
|
130
|
+
if name:
|
|
131
|
+
func = _instrument(_named(str(name), prefix), func)
|
|
132
|
+
if rest is None:
|
|
133
|
+
return original(name, func, **kwargs) if name else original(func, **kwargs)
|
|
134
|
+
return original(*rest, **kwargs)
|
|
135
|
+
|
|
136
|
+
add_node._slmeval_instrumented = True # type: ignore[attr-defined]
|
|
137
|
+
builder.add_node = add_node
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _split_add_node_args(
|
|
141
|
+
node: Any, action: Any
|
|
142
|
+
) -> tuple[str | None, Callable[..., Any] | None, tuple[Any, ...] | None]:
|
|
143
|
+
"""Make sense of ``add_node``'s two accepted shapes.
|
|
144
|
+
|
|
145
|
+
``add_node("name", func)`` and ``add_node(func)`` - where the node takes
|
|
146
|
+
the function's own name - are both public, and a wrapper that handled only
|
|
147
|
+
the first would break every graph written in the second style. Anything
|
|
148
|
+
else is passed through untouched rather than guessed at.
|
|
149
|
+
"""
|
|
150
|
+
if action is None and callable(node):
|
|
151
|
+
return None, node, None
|
|
152
|
+
if isinstance(node, str) and callable(action):
|
|
153
|
+
return node, action, None
|
|
154
|
+
return None, None, (node, action)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
# After the nodes are added: wrap what is registered
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _existing_nodes(builder: Any) -> list[tuple[str, Any]]:
|
|
163
|
+
nodes = getattr(builder, "nodes", None)
|
|
164
|
+
if not isinstance(nodes, dict):
|
|
165
|
+
return []
|
|
166
|
+
return [(str(name), holder) for name, holder in nodes.items()]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _wrap_node(name: str, holder: Any) -> bool:
|
|
170
|
+
"""Instrument one registered node in place. True if anything was wrapped.
|
|
171
|
+
|
|
172
|
+
The holder is a node spec whose ``runnable`` holds the callable, or - in
|
|
173
|
+
the simplest builders, and in the stub this is tested against - the
|
|
174
|
+
callable itself. The runnable is wrapped *in place* rather than replaced,
|
|
175
|
+
because LangGraph's executor calls methods on it that a bare function does
|
|
176
|
+
not have.
|
|
177
|
+
"""
|
|
178
|
+
for attribute in NODE_ATTRS:
|
|
179
|
+
runnable = getattr(holder, attribute, None)
|
|
180
|
+
if runnable is not None and _wrap_in_place(name, runnable):
|
|
181
|
+
return True
|
|
182
|
+
if callable(holder):
|
|
183
|
+
return _wrap_in_place(name, holder)
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _wrap_in_place(name: str, runnable: Any) -> bool:
|
|
188
|
+
if getattr(runnable, MARK, False):
|
|
189
|
+
return False
|
|
190
|
+
wrapped = False
|
|
191
|
+
for attribute in CALLABLE_ATTRS:
|
|
192
|
+
current = getattr(runnable, attribute, None)
|
|
193
|
+
if current is None or not callable(current):
|
|
194
|
+
continue
|
|
195
|
+
if _set(runnable, attribute, _instrument(name, current)):
|
|
196
|
+
wrapped = True
|
|
197
|
+
if wrapped:
|
|
198
|
+
_set(runnable, MARK, True)
|
|
199
|
+
return wrapped
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _instrument(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
203
|
+
if getattr(func, MARK, False):
|
|
204
|
+
return func
|
|
205
|
+
wrapped = instrument_node(name, func)
|
|
206
|
+
_set(wrapped, MARK, True)
|
|
207
|
+
return wrapped
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _set(obj: Any, attribute: str, value: Any) -> bool:
|
|
211
|
+
"""Assign, through whatever the object allows. False if it allows nothing."""
|
|
212
|
+
try:
|
|
213
|
+
setattr(obj, attribute, value)
|
|
214
|
+
except (AttributeError, TypeError, ValueError):
|
|
215
|
+
try:
|
|
216
|
+
object.__setattr__(obj, attribute, value)
|
|
217
|
+
except (AttributeError, TypeError, ValueError):
|
|
218
|
+
return False
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _reachable(holder: Any) -> bool:
|
|
223
|
+
"""Whether this node holds a callable the adapter could reach at all."""
|
|
224
|
+
for attribute in NODE_ATTRS:
|
|
225
|
+
runnable = getattr(holder, attribute, None)
|
|
226
|
+
if runnable is None:
|
|
227
|
+
continue
|
|
228
|
+
if any(callable(getattr(runnable, a, None)) for a in CALLABLE_ATTRS):
|
|
229
|
+
return True
|
|
230
|
+
return callable(holder)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _named(name: str, prefix: str) -> str:
|
|
234
|
+
return f"{prefix}{name}" if prefix else name
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["CALLABLE_ATTRS", "MARK", "NODE_ATTRS", "instrument_langgraph"]
|
slmeval/cli/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Terminal interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["app", "main"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name: str) -> object:
|
|
9
|
+
# Lazy so `import slmeval.cli` stays cheap and import cycles cannot form
|
|
10
|
+
# between main.py and the command modules.
|
|
11
|
+
if name in __all__:
|
|
12
|
+
from slmeval.cli import main as _main
|
|
13
|
+
|
|
14
|
+
return getattr(_main, name)
|
|
15
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""One module per command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from slmeval.cli.commands import (
|
|
6
|
+
baseline_cmd,
|
|
7
|
+
cache_cmd,
|
|
8
|
+
dataset_cmd,
|
|
9
|
+
doctor_cmd,
|
|
10
|
+
gate_cmd,
|
|
11
|
+
init_cmd,
|
|
12
|
+
judge_cmd,
|
|
13
|
+
record_cmd,
|
|
14
|
+
report_cmd,
|
|
15
|
+
run_cmd,
|
|
16
|
+
show_cmd,
|
|
17
|
+
stubs,
|
|
18
|
+
validate_cmd,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"baseline_cmd",
|
|
23
|
+
"cache_cmd",
|
|
24
|
+
"dataset_cmd",
|
|
25
|
+
"doctor_cmd",
|
|
26
|
+
"gate_cmd",
|
|
27
|
+
"init_cmd",
|
|
28
|
+
"judge_cmd",
|
|
29
|
+
"record_cmd",
|
|
30
|
+
"report_cmd",
|
|
31
|
+
"run_cmd",
|
|
32
|
+
"show_cmd",
|
|
33
|
+
"stubs",
|
|
34
|
+
"validate_cmd",
|
|
35
|
+
]
|