saidso 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.
- saidso/__init__.py +57 -0
- saidso/_fuzz.py +49 -0
- saidso/attestation.py +74 -0
- saidso/context.py +74 -0
- saidso/grounding.py +198 -0
- saidso/matcher.py +398 -0
- saidso/normalize.py +363 -0
- saidso/policy.py +31 -0
- saidso/py.typed +0 -0
- saidso/result.py +138 -0
- saidso/testing.py +129 -0
- saidso/transcript.py +94 -0
- saidso-0.1.0.dist-info/METADATA +202 -0
- saidso-0.1.0.dist-info/RECORD +16 -0
- saidso-0.1.0.dist-info/WHEEL +4 -0
- saidso-0.1.0.dist-info/licenses/LICENSE +21 -0
saidso/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""saidso — a grounding firewall for action-taking AI agents.
|
|
2
|
+
|
|
3
|
+
Sit between an agent and its consequential tools. Refuse to let the agent
|
|
4
|
+
commit any argument that isn't grounded in what the user actually said — and
|
|
5
|
+
keep a transcript-linked audit trail for every action that does run.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from saidso import grounded, Policy, Transcript, call_context, AttestationLog
|
|
10
|
+
|
|
11
|
+
@grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN)
|
|
12
|
+
def register_patient(name, dob): ...
|
|
13
|
+
|
|
14
|
+
tr = Transcript()
|
|
15
|
+
tr.add_user("Hi, this is Maria Gomez.")
|
|
16
|
+
log = AttestationLog()
|
|
17
|
+
|
|
18
|
+
with call_context(tr, ledger=log):
|
|
19
|
+
result = register_patient(name="John Doe", dob="1990-01-01")
|
|
20
|
+
# -> SteerBack(blocked=True): nothing was said about John Doe / that DOB
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .attestation import Attestation, AttestationLog
|
|
26
|
+
from .context import CallContext, call_context, get_context, set_context, reset_context
|
|
27
|
+
from .grounding import GroundingBlocked, GroundingConfig, grounded
|
|
28
|
+
from .policy import DEFAULT_THRESHOLDS, Policy
|
|
29
|
+
from .result import ArgFinding, GroundingResult, Span, SteerBack
|
|
30
|
+
from .transcript import AGENT, SYSTEM, USER, Transcript, Turn
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"grounded",
|
|
36
|
+
"Policy",
|
|
37
|
+
"Transcript",
|
|
38
|
+
"Turn",
|
|
39
|
+
"USER",
|
|
40
|
+
"AGENT",
|
|
41
|
+
"SYSTEM",
|
|
42
|
+
"call_context",
|
|
43
|
+
"CallContext",
|
|
44
|
+
"get_context",
|
|
45
|
+
"set_context",
|
|
46
|
+
"reset_context",
|
|
47
|
+
"SteerBack",
|
|
48
|
+
"GroundingResult",
|
|
49
|
+
"GroundingConfig",
|
|
50
|
+
"GroundingBlocked",
|
|
51
|
+
"Span",
|
|
52
|
+
"ArgFinding",
|
|
53
|
+
"Attestation",
|
|
54
|
+
"AttestationLog",
|
|
55
|
+
"DEFAULT_THRESHOLDS",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
saidso/_fuzz.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Fuzzy string matching with a zero-dependency fallback.
|
|
2
|
+
|
|
3
|
+
Uses ``rapidfuzz`` when it is installed (fast, C-backed) and transparently
|
|
4
|
+
falls back to the stdlib ``difflib`` so ``saidso`` works with no required
|
|
5
|
+
third-party dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
try: # pragma: no cover - exercised indirectly
|
|
11
|
+
from rapidfuzz import fuzz as _rf
|
|
12
|
+
|
|
13
|
+
_HAVE_RAPIDFUZZ = True
|
|
14
|
+
except Exception: # pragma: no cover
|
|
15
|
+
_HAVE_RAPIDFUZZ = False
|
|
16
|
+
import difflib
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ratio(a: str, b: str) -> float:
|
|
20
|
+
"""Whole-string similarity in ``[0, 1]``."""
|
|
21
|
+
if not a or not b:
|
|
22
|
+
return 0.0
|
|
23
|
+
if _HAVE_RAPIDFUZZ:
|
|
24
|
+
return _rf.ratio(a, b) / 100.0
|
|
25
|
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def partial_ratio(needle: str, haystack: str) -> float:
|
|
29
|
+
"""How well ``needle`` appears *inside* ``haystack``, in ``[0, 1]``.
|
|
30
|
+
|
|
31
|
+
This is the workhorse for "did the caller roughly say this?" checks.
|
|
32
|
+
"""
|
|
33
|
+
if not needle or not haystack:
|
|
34
|
+
return 0.0
|
|
35
|
+
if _HAVE_RAPIDFUZZ:
|
|
36
|
+
return _rf.partial_ratio(needle, haystack) / 100.0
|
|
37
|
+
|
|
38
|
+
# difflib fallback: slide a window the size of ``needle`` across ``haystack``.
|
|
39
|
+
n = len(needle)
|
|
40
|
+
if n >= len(haystack):
|
|
41
|
+
return difflib.SequenceMatcher(None, needle, haystack).ratio()
|
|
42
|
+
best = 0.0
|
|
43
|
+
step = max(1, n // 4)
|
|
44
|
+
for i in range(0, len(haystack) - n + 1, step):
|
|
45
|
+
window = haystack[i : i + n]
|
|
46
|
+
r = difflib.SequenceMatcher(None, needle, window).ratio()
|
|
47
|
+
if r > best:
|
|
48
|
+
best = r
|
|
49
|
+
return best
|
saidso/attestation.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The provenance ledger: proof that every committed argument was grounded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .result import ArgFinding
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Attestation:
|
|
16
|
+
"""A receipt: this action ran, and here is what grounded every argument."""
|
|
17
|
+
|
|
18
|
+
action: str
|
|
19
|
+
ts: float
|
|
20
|
+
call_id: Optional[str]
|
|
21
|
+
args: List[ArgFinding] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"action": self.action,
|
|
26
|
+
"ts": self.ts,
|
|
27
|
+
"call_id": self.call_id,
|
|
28
|
+
"args": [
|
|
29
|
+
{
|
|
30
|
+
"arg": f.name,
|
|
31
|
+
"policy": f.result.policy,
|
|
32
|
+
"value": f.result.value,
|
|
33
|
+
"confidence": round(f.result.confidence, 4),
|
|
34
|
+
"span": f.result.span.to_dict() if f.result.span else None,
|
|
35
|
+
}
|
|
36
|
+
for f in self.args
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AttestationLog:
|
|
42
|
+
"""Collects attestations in memory and (optionally) appends them as JSONL.
|
|
43
|
+
|
|
44
|
+
Pass ``path=`` to persist an audit trail; otherwise records are kept in
|
|
45
|
+
memory and reachable via :attr:`records`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: Optional[str] = None) -> None:
|
|
49
|
+
self.path = path
|
|
50
|
+
self._records: List[Attestation] = []
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def record(self, attestation: Attestation) -> Attestation:
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._records.append(attestation)
|
|
56
|
+
if self.path:
|
|
57
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
58
|
+
fh.write(json.dumps(attestation.to_dict()) + "\n")
|
|
59
|
+
return attestation
|
|
60
|
+
|
|
61
|
+
def build(self, action: str, findings: List[ArgFinding], call_id: Optional[str] = None) -> Attestation:
|
|
62
|
+
return self.record(
|
|
63
|
+
Attestation(action=action, ts=time.time(), call_id=call_id, args=list(findings))
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def records(self) -> List[Attestation]:
|
|
68
|
+
return list(self._records)
|
|
69
|
+
|
|
70
|
+
def __len__(self) -> int:
|
|
71
|
+
return len(self._records)
|
|
72
|
+
|
|
73
|
+
def export(self) -> List[dict]:
|
|
74
|
+
return [a.to_dict() for a in self._records]
|
saidso/context.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Per-call context: the transcript, metadata, clock and attestation sink.
|
|
2
|
+
|
|
3
|
+
Adapters set this once per call (via :func:`call_context`); the ``@grounded``
|
|
4
|
+
decorator reads it implicitly so action functions stay clean. Values can also
|
|
5
|
+
be passed explicitly to the decorated call via ``_transcript=`` / ``_context=``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextvars
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import date
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from .transcript import Transcript
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CallContext:
|
|
21
|
+
"""Everything the firewall needs to judge one call."""
|
|
22
|
+
|
|
23
|
+
transcript: Transcript = field(default_factory=Transcript)
|
|
24
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
now: Optional[date] = None
|
|
26
|
+
call_id: Optional[str] = None
|
|
27
|
+
ledger: Any = None # AttestationLog | None (avoid import cycle)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_CURRENT: contextvars.ContextVar[Optional[CallContext]] = contextvars.ContextVar(
|
|
31
|
+
"saidso_call_context", default=None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_context() -> Optional[CallContext]:
|
|
36
|
+
return _CURRENT.get()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_context(ctx: Optional[CallContext]) -> contextvars.Token:
|
|
40
|
+
return _CURRENT.set(ctx)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reset_context(token: contextvars.Token) -> None:
|
|
44
|
+
_CURRENT.reset(token)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def call_context(
|
|
49
|
+
transcript: Optional[Transcript] = None,
|
|
50
|
+
*,
|
|
51
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
52
|
+
now: Optional[date] = None,
|
|
53
|
+
call_id: Optional[str] = None,
|
|
54
|
+
ledger: Any = None,
|
|
55
|
+
):
|
|
56
|
+
"""Scope a :class:`CallContext` for the duration of a call.
|
|
57
|
+
|
|
58
|
+
Example::
|
|
59
|
+
|
|
60
|
+
with call_context(transcript, metadata={"caller_id": "+1..."}, ledger=log):
|
|
61
|
+
await register_patient(...)
|
|
62
|
+
"""
|
|
63
|
+
ctx = CallContext(
|
|
64
|
+
transcript=transcript if transcript is not None else Transcript(),
|
|
65
|
+
metadata=metadata or {},
|
|
66
|
+
now=now,
|
|
67
|
+
call_id=call_id,
|
|
68
|
+
ledger=ledger,
|
|
69
|
+
)
|
|
70
|
+
token = _CURRENT.set(ctx)
|
|
71
|
+
try:
|
|
72
|
+
yield ctx
|
|
73
|
+
finally:
|
|
74
|
+
_CURRENT.reset(token)
|
saidso/grounding.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The ``@grounded`` decorator: the firewall around your action tools.
|
|
2
|
+
|
|
3
|
+
Wrap a consequential function, declare a policy per argument, and the call is
|
|
4
|
+
intercepted: every guarded argument is verified against the transcript before
|
|
5
|
+
the body runs. Ungrounded? The body never executes and a :class:`SteerBack` is
|
|
6
|
+
returned so the agent re-asks the caller. Grounded? An attestation is written.
|
|
7
|
+
|
|
8
|
+
Production guarantees:
|
|
9
|
+
|
|
10
|
+
* **Validated at decoration time** — a policy naming a non-existent parameter
|
|
11
|
+
raises immediately, so a typo can never silently leave a real argument
|
|
12
|
+
unguarded.
|
|
13
|
+
* **Fail-closed** — if a grounding check raises unexpectedly, the argument is
|
|
14
|
+
treated as ungrounded (blocked) and the error is logged; a firewall must
|
|
15
|
+
never let a call through because the check crashed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import functools
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
25
|
+
|
|
26
|
+
from . import matcher
|
|
27
|
+
from .context import CallContext, get_context
|
|
28
|
+
from .policy import DEFAULT_THRESHOLDS, Policy
|
|
29
|
+
from .result import ArgFinding, GroundingResult, SteerBack
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("saidso")
|
|
32
|
+
|
|
33
|
+
_OVERRIDE_KEYS = ("_context", "_transcript")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class GroundingConfig:
|
|
38
|
+
"""Tunables for the firewall."""
|
|
39
|
+
|
|
40
|
+
thresholds: Optional[Dict[Policy, float]] = None
|
|
41
|
+
raise_on_block: bool = False # default: return SteerBack (slots into tool loops)
|
|
42
|
+
warn_on_missing_context: bool = True
|
|
43
|
+
|
|
44
|
+
def threshold_for(self, policy: Policy) -> float:
|
|
45
|
+
if self.thresholds and policy in self.thresholds:
|
|
46
|
+
return self.thresholds[policy]
|
|
47
|
+
return DEFAULT_THRESHOLDS[policy]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GroundingBlocked(Exception):
|
|
51
|
+
"""Raised instead of returning a SteerBack when ``raise_on_block=True``."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, steer: SteerBack) -> None:
|
|
54
|
+
super().__init__(steer.message)
|
|
55
|
+
self.steer = steer
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def grounded(
|
|
59
|
+
_config: Optional[GroundingConfig] = None,
|
|
60
|
+
**arg_policies: Union[Policy, str],
|
|
61
|
+
) -> Callable:
|
|
62
|
+
"""Decorator factory. Map argument names to :class:`Policy` values.
|
|
63
|
+
|
|
64
|
+
Example::
|
|
65
|
+
|
|
66
|
+
@grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN, phone=Policy.CALLER_ID)
|
|
67
|
+
async def register_patient(name, dob, phone): ...
|
|
68
|
+
"""
|
|
69
|
+
config = _config or GroundingConfig()
|
|
70
|
+
if not arg_policies:
|
|
71
|
+
raise ValueError("@grounded requires at least one argument policy")
|
|
72
|
+
policies: Dict[str, Policy] = {}
|
|
73
|
+
for name, value in arg_policies.items():
|
|
74
|
+
try:
|
|
75
|
+
policies[name] = value if isinstance(value, Policy) else Policy(value)
|
|
76
|
+
except ValueError as exc: # unknown policy string
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"@grounded: unknown policy {value!r} for argument {name!r}"
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
def decorate(fn: Callable) -> Callable:
|
|
82
|
+
sig = inspect.signature(fn)
|
|
83
|
+
params = sig.parameters
|
|
84
|
+
var_kw_name = next(
|
|
85
|
+
(n for n, p in params.items() if p.kind is inspect.Parameter.VAR_KEYWORD),
|
|
86
|
+
None,
|
|
87
|
+
)
|
|
88
|
+
accepts_var_kw = var_kw_name is not None
|
|
89
|
+
# Validate at decoration time: every guarded name must be a real param.
|
|
90
|
+
if not accepts_var_kw:
|
|
91
|
+
unknown = [n for n in policies if n not in params]
|
|
92
|
+
if unknown:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"@grounded on {fn.__name__}{sig}: these guarded arguments are "
|
|
95
|
+
f"not parameters of the function: {unknown}. Check for typos."
|
|
96
|
+
)
|
|
97
|
+
# An override key only collides if the function genuinely declares it.
|
|
98
|
+
strip_keys = [k for k in _OVERRIDE_KEYS if k not in params]
|
|
99
|
+
|
|
100
|
+
def evaluate(args, kwargs):
|
|
101
|
+
override_ctx = kwargs.pop("_context", None) if "_context" in strip_keys else None
|
|
102
|
+
override_tr = kwargs.pop("_transcript", None) if "_transcript" in strip_keys else None
|
|
103
|
+
|
|
104
|
+
ctx = override_ctx or get_context()
|
|
105
|
+
if ctx is None:
|
|
106
|
+
if config.warn_on_missing_context:
|
|
107
|
+
logger.warning(
|
|
108
|
+
"saidso: no call_context active for %s; treating transcript "
|
|
109
|
+
"as empty (all guarded args will block).", fn.__name__,
|
|
110
|
+
)
|
|
111
|
+
ctx = CallContext()
|
|
112
|
+
if override_tr is not None:
|
|
113
|
+
ctx = CallContext(
|
|
114
|
+
transcript=override_tr, metadata=ctx.metadata, now=ctx.now,
|
|
115
|
+
call_id=ctx.call_id, ledger=ctx.ledger,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
120
|
+
except TypeError:
|
|
121
|
+
# Let the real function raise its own clear TypeError.
|
|
122
|
+
return _Pass(args, kwargs)
|
|
123
|
+
bound.apply_defaults()
|
|
124
|
+
|
|
125
|
+
def resolve(arg_name):
|
|
126
|
+
if arg_name in bound.arguments and arg_name != var_kw_name:
|
|
127
|
+
return bound.arguments[arg_name]
|
|
128
|
+
if var_kw_name and var_kw_name in bound.arguments:
|
|
129
|
+
return bound.arguments[var_kw_name].get(arg_name)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
failed: List[ArgFinding] = []
|
|
133
|
+
passed: List[ArgFinding] = []
|
|
134
|
+
for name, policy in policies.items():
|
|
135
|
+
value = resolve(name)
|
|
136
|
+
try:
|
|
137
|
+
result = matcher.check(
|
|
138
|
+
value, policy, ctx.transcript, ctx, config.threshold_for(policy)
|
|
139
|
+
)
|
|
140
|
+
except Exception as exc: # fail closed: never let a crash open the gate
|
|
141
|
+
logger.exception(
|
|
142
|
+
"saidso: grounding check errored for %s.%s; blocking.",
|
|
143
|
+
fn.__name__, name,
|
|
144
|
+
)
|
|
145
|
+
result = GroundingResult(
|
|
146
|
+
grounded=False, confidence=0.0, policy=policy.value,
|
|
147
|
+
value=value, reason=f"grounding check errored: {exc}",
|
|
148
|
+
)
|
|
149
|
+
finding = ArgFinding(name=name, result=result)
|
|
150
|
+
(passed if result.grounded else failed).append(finding)
|
|
151
|
+
|
|
152
|
+
if failed:
|
|
153
|
+
steer = SteerBack(action=fn.__name__, failed=failed, grounded=passed)
|
|
154
|
+
logger.info(
|
|
155
|
+
"saidso blocked %s: ungrounded args %s",
|
|
156
|
+
fn.__name__, [f.name for f in failed],
|
|
157
|
+
)
|
|
158
|
+
return steer
|
|
159
|
+
|
|
160
|
+
if ctx.ledger is not None:
|
|
161
|
+
ctx.ledger.build(fn.__name__, passed, call_id=ctx.call_id)
|
|
162
|
+
return _Pass(args, kwargs)
|
|
163
|
+
|
|
164
|
+
if inspect.iscoroutinefunction(fn):
|
|
165
|
+
|
|
166
|
+
@functools.wraps(fn)
|
|
167
|
+
async def awrapper(*args, **kwargs):
|
|
168
|
+
outcome = evaluate(args, kwargs)
|
|
169
|
+
if isinstance(outcome, SteerBack):
|
|
170
|
+
if config.raise_on_block:
|
|
171
|
+
raise GroundingBlocked(outcome)
|
|
172
|
+
return outcome
|
|
173
|
+
return await fn(*outcome.args, **outcome.kwargs)
|
|
174
|
+
|
|
175
|
+
awrapper.__grounded_policies__ = policies
|
|
176
|
+
return awrapper
|
|
177
|
+
|
|
178
|
+
@functools.wraps(fn)
|
|
179
|
+
def swrapper(*args, **kwargs):
|
|
180
|
+
outcome = evaluate(args, kwargs)
|
|
181
|
+
if isinstance(outcome, SteerBack):
|
|
182
|
+
if config.raise_on_block:
|
|
183
|
+
raise GroundingBlocked(outcome)
|
|
184
|
+
return outcome
|
|
185
|
+
return fn(*outcome.args, **outcome.kwargs)
|
|
186
|
+
|
|
187
|
+
swrapper.__grounded_policies__ = policies
|
|
188
|
+
return swrapper
|
|
189
|
+
|
|
190
|
+
return decorate
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class _Pass:
|
|
195
|
+
"""Internal: the (possibly override-stripped) args to forward to the body."""
|
|
196
|
+
|
|
197
|
+
args: tuple
|
|
198
|
+
kwargs: dict
|