handoff-guard 0.2.0__py3-none-any.whl → 0.2.1__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.
- handoff/__init__.py +3 -2
- handoff/utils.py +95 -5
- {handoff_guard-0.2.0.dist-info → handoff_guard-0.2.1.dist-info}/METADATA +31 -25
- {handoff_guard-0.2.0.dist-info → handoff_guard-0.2.1.dist-info}/RECORD +6 -6
- {handoff_guard-0.2.0.dist-info → handoff_guard-0.2.1.dist-info}/WHEEL +0 -0
- {handoff_guard-0.2.0.dist-info → handoff_guard-0.2.1.dist-info}/licenses/LICENSE +0 -0
handoff/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from handoff.core import HandoffViolation, ViolationContext
|
|
2
2
|
from handoff.guard import guard, GuardConfig
|
|
3
3
|
from handoff.retry import retry, RetryState, Diagnostic, AttemptRecord
|
|
4
|
-
from handoff.utils import ParseError, parse_json
|
|
4
|
+
from handoff.utils import ParseError, ParseResult, parse_json
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"guard",
|
|
@@ -13,7 +13,8 @@ __all__ = [
|
|
|
13
13
|
"Diagnostic",
|
|
14
14
|
"AttemptRecord",
|
|
15
15
|
"ParseError",
|
|
16
|
+
"ParseResult",
|
|
16
17
|
"parse_json",
|
|
17
18
|
]
|
|
18
19
|
|
|
19
|
-
__version__ = "0.2.
|
|
20
|
+
__version__ = "0.2.1"
|
handoff/utils.py
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
"""Utility functions for handoff."""
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
3
4
|
import json
|
|
4
5
|
import re
|
|
6
|
+
from typing import overload, Literal
|
|
5
7
|
|
|
6
8
|
from json_repair import loads as repair_json
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
@dataclass
|
|
12
|
+
class ParseResult:
|
|
13
|
+
"""Result of parse_json with detailed=True.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
data: The parsed JSON data (dict or list).
|
|
17
|
+
truncated: True if the input appeared to be truncated (e.g., hit max_tokens).
|
|
18
|
+
repaired: True if the JSON had syntax errors that were auto-fixed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
data: dict | list
|
|
22
|
+
truncated: bool = False
|
|
23
|
+
repaired: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
9
26
|
def _format_context_snippet(text: str, lineno: int, colno: int) -> str:
|
|
10
27
|
"""Format a snippet of text around the error location.
|
|
11
28
|
|
|
@@ -205,7 +222,59 @@ def _extract_json_substring(text: str) -> str | None:
|
|
|
205
222
|
return None
|
|
206
223
|
|
|
207
224
|
|
|
208
|
-
def
|
|
225
|
+
def _is_likely_truncated(text: str) -> bool:
|
|
226
|
+
"""Detect if JSON appears to be truncated (e.g., hit max_tokens).
|
|
227
|
+
|
|
228
|
+
Returns True if there are unmatched opening braces/brackets,
|
|
229
|
+
indicating the JSON was cut off before completion.
|
|
230
|
+
"""
|
|
231
|
+
if not text:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# Count unmatched braces/brackets, respecting JSON string boundaries
|
|
235
|
+
depth_brace = 0
|
|
236
|
+
depth_bracket = 0
|
|
237
|
+
in_string = False
|
|
238
|
+
escape = False
|
|
239
|
+
|
|
240
|
+
for ch in text:
|
|
241
|
+
if escape:
|
|
242
|
+
escape = False
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if ch == "\\":
|
|
246
|
+
if in_string:
|
|
247
|
+
escape = True
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if ch == '"':
|
|
251
|
+
in_string = not in_string
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if in_string:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
if ch == "{":
|
|
258
|
+
depth_brace += 1
|
|
259
|
+
elif ch == "}":
|
|
260
|
+
depth_brace -= 1
|
|
261
|
+
elif ch == "[":
|
|
262
|
+
depth_bracket += 1
|
|
263
|
+
elif ch == "]":
|
|
264
|
+
depth_bracket -= 1
|
|
265
|
+
|
|
266
|
+
return depth_brace > 0 or depth_bracket > 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@overload
|
|
270
|
+
def parse_json(text: str, *, detailed: Literal[False] = False) -> dict | list: ...
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@overload
|
|
274
|
+
def parse_json(text: str, *, detailed: Literal[True]) -> ParseResult: ...
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def parse_json(text: str, *, detailed: bool = False) -> dict | list | ParseResult:
|
|
209
278
|
"""Parse JSON from text, handling common LLM output quirks.
|
|
210
279
|
|
|
211
280
|
Strips UTF-8 BOM, markdown code fences, and conversational
|
|
@@ -213,6 +282,13 @@ def parse_json(text: str) -> dict:
|
|
|
213
282
|
common JSON malformations (trailing commas, single quotes,
|
|
214
283
|
unquoted keys, missing braces, comments).
|
|
215
284
|
|
|
285
|
+
Args:
|
|
286
|
+
text: The text to parse as JSON.
|
|
287
|
+
detailed: If True, return a ParseResult with truncation/repair info.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
The parsed JSON data (dict or list), or ParseResult if detailed=True.
|
|
291
|
+
|
|
216
292
|
Raises:
|
|
217
293
|
ParseError: If text cannot be parsed as JSON.
|
|
218
294
|
"""
|
|
@@ -228,16 +304,29 @@ def parse_json(text: str) -> dict:
|
|
|
228
304
|
# Track first decode error for actionable feedback
|
|
229
305
|
first_error: json.JSONDecodeError | None = None
|
|
230
306
|
|
|
307
|
+
# Track status for detailed mode
|
|
308
|
+
was_repaired = False
|
|
309
|
+
was_truncated = False
|
|
310
|
+
|
|
311
|
+
# Check for truncation before any processing
|
|
312
|
+
if _is_likely_truncated(cleaned):
|
|
313
|
+
was_truncated = True
|
|
314
|
+
|
|
315
|
+
def _return(data: dict | list) -> dict | list | ParseResult:
|
|
316
|
+
if detailed:
|
|
317
|
+
return ParseResult(data=data, truncated=was_truncated, repaired=was_repaired)
|
|
318
|
+
return data
|
|
319
|
+
|
|
231
320
|
# Fast path: try parsing directly
|
|
232
321
|
try:
|
|
233
|
-
return json.loads(cleaned)
|
|
322
|
+
return _return(json.loads(cleaned))
|
|
234
323
|
except json.JSONDecodeError as e:
|
|
235
324
|
first_error = e
|
|
236
325
|
|
|
237
326
|
# Strip code fences and retry
|
|
238
327
|
stripped = _strip_code_fences(cleaned)
|
|
239
328
|
try:
|
|
240
|
-
return json.loads(stripped)
|
|
329
|
+
return _return(json.loads(stripped))
|
|
241
330
|
except json.JSONDecodeError:
|
|
242
331
|
pass
|
|
243
332
|
|
|
@@ -245,7 +334,7 @@ def parse_json(text: str) -> dict:
|
|
|
245
334
|
extracted = _extract_json_substring(cleaned)
|
|
246
335
|
if extracted is not None:
|
|
247
336
|
try:
|
|
248
|
-
return json.loads(extracted)
|
|
337
|
+
return _return(json.loads(extracted))
|
|
249
338
|
except json.JSONDecodeError:
|
|
250
339
|
pass
|
|
251
340
|
|
|
@@ -255,7 +344,8 @@ def parse_json(text: str) -> dict:
|
|
|
255
344
|
repaired = repair_json(repair_target)
|
|
256
345
|
# Only accept dict/list results (json_repair returns "" for invalid input)
|
|
257
346
|
if isinstance(repaired, (dict, list)):
|
|
258
|
-
|
|
347
|
+
was_repaired = True
|
|
348
|
+
return _return(repaired)
|
|
259
349
|
except Exception:
|
|
260
350
|
pass
|
|
261
351
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: handoff-guard
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Lightweight validation at agent boundaries. Know what broke and where.
|
|
5
5
|
Project-URL: Homepage, https://github.com/acartag7/handoff-guard
|
|
6
6
|
Project-URL: Repository, https://github.com/acartag7/handoff-guard
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Requires-Dist: json-repair>=0.55.0
|
|
19
|
-
Requires-Dist: pydantic
|
|
19
|
+
Requires-Dist: pydantic<3,>=2.0.0
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: pre-commit>=3.0.0; extra == 'dev'
|
|
22
22
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
@@ -31,10 +31,11 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
# handoff-guard
|
|
33
33
|
|
|
34
|
-
>
|
|
34
|
+
> Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.
|
|
35
35
|
|
|
36
36
|
[](https://pypi.org/project/handoff-guard/)
|
|
37
37
|
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
38
39
|
|
|
39
40
|
## The Problem
|
|
40
41
|
|
|
@@ -50,7 +51,7 @@ Which node? Which field? What was passed? Can the agent fix it?
|
|
|
50
51
|
## The Solution
|
|
51
52
|
|
|
52
53
|
```python
|
|
53
|
-
from handoff import guard, retry, parse_json
|
|
54
|
+
from handoff import guard, retry, parse_json # PyPI: handoff-guard
|
|
54
55
|
from pydantic import BaseModel, Field
|
|
55
56
|
|
|
56
57
|
class WriterOutput(BaseModel):
|
|
@@ -66,11 +67,11 @@ def writer_agent(state: dict) -> dict:
|
|
|
66
67
|
if retry.is_retry:
|
|
67
68
|
prompt += f"\n\nYour previous attempt failed:\n{retry.feedback()}"
|
|
68
69
|
|
|
69
|
-
response = call_llm(prompt)
|
|
70
|
-
return parse_json(response)
|
|
70
|
+
response = call_llm(prompt) # your LLM call here
|
|
71
|
+
return parse_json(response) # @guard validates dict against WriterOutput, returns dict as-is
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
If valid, the function returns normally. If invalid, it retries with feedback. After all attempts exhausted:
|
|
74
75
|
|
|
75
76
|
```
|
|
76
77
|
HandoffViolation in 'writer' (attempt 3/3):
|
|
@@ -87,13 +88,18 @@ HandoffViolation in 'writer' (attempt 3/3):
|
|
|
87
88
|
pip install handoff-guard
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
# See retry-with-feedback in action (no API key needed)
|
|
92
|
-
python -m examples.llm_demo.run_demo
|
|
91
|
+
Requires Python 3.10+ and Pydantic v2.
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
```python
|
|
94
|
+
from handoff import guard, retry, parse_json # PyPI: handoff-guard
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
To run demos, clone the repo:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/acartag7/handoff-guard && cd handoff-guard
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
python -m examples.llm_demo.run_demo # no API key needed
|
|
97
103
|
```
|
|
98
104
|
|
|
99
105
|
## Features
|
|
@@ -102,9 +108,10 @@ python -m examples.llm_demo.run_demo --pipeline --api
|
|
|
102
108
|
- **Know which node failed** — No more guessing from stack traces
|
|
103
109
|
- **Know which field failed** — Exact path to the problem
|
|
104
110
|
- **Get fix suggestions** — Actionable error messages
|
|
105
|
-
- **`parse_json`** — Strips code fences, conversational wrappers, handles BOM, repairs malformed JSON (trailing commas, single quotes, unquoted keys, missing braces, comments), raises `ParseError` with actionable line/column info
|
|
106
|
-
- **Framework agnostic** — Works with LangGraph
|
|
107
|
-
- **
|
|
111
|
+
- **`parse_json`** — Strips code fences, conversational wrappers, handles BOM, repairs malformed JSON (trailing commas, single quotes, unquoted keys, missing braces, comments), raises `ParseError` with actionable line/column info. Use `detailed=True` to detect truncation and repair status
|
|
112
|
+
- **Framework agnostic** — Works with LangGraph or plain Python
|
|
113
|
+
- **Async supported** — Works with `async def` functions (context-local retry state)
|
|
114
|
+
- **Lightweight** — Pydantic + json-repair, no Docker, no telemetry
|
|
108
115
|
|
|
109
116
|
## API
|
|
110
117
|
|
|
@@ -142,7 +149,7 @@ retry.history # List of AttemptRecord objects
|
|
|
142
149
|
### `parse_json`
|
|
143
150
|
|
|
144
151
|
```python
|
|
145
|
-
from handoff import parse_json
|
|
152
|
+
from handoff import parse_json # ParseResult is the return type when detailed=True
|
|
146
153
|
|
|
147
154
|
# Handles code fences
|
|
148
155
|
data = parse_json('```json\n{"key": "value"}\n```')
|
|
@@ -159,6 +166,12 @@ data = parse_json('{a: 1}') # unquoted keys
|
|
|
159
166
|
data = parse_json('{"a": 1') # missing brace
|
|
160
167
|
data = parse_json('{"a": 1 // comment}') # JS comments
|
|
161
168
|
|
|
169
|
+
# Detailed mode: detect truncation and repair
|
|
170
|
+
result = parse_json('{"draft": "long text...', detailed=True)
|
|
171
|
+
result.data # the parsed dict/list
|
|
172
|
+
result.truncated # True if unmatched braces (e.g., token limit or stream cutoff)
|
|
173
|
+
result.repaired # True if JSON had syntax errors that were auto-fixed
|
|
174
|
+
|
|
162
175
|
# Raises ParseError on failure (retryable by @guard)
|
|
163
176
|
# ParseError includes detailed context:
|
|
164
177
|
# - Line/column location
|
|
@@ -221,7 +234,7 @@ def router(state: dict) -> dict:
|
|
|
221
234
|
|
|
222
235
|
## Why not just use Pydantic directly?
|
|
223
236
|
|
|
224
|
-
You should!
|
|
237
|
+
You should! handoff-guard uses Pydantic under the hood.
|
|
225
238
|
|
|
226
239
|
The difference:
|
|
227
240
|
|
|
@@ -233,13 +246,6 @@ The difference:
|
|
|
233
246
|
| No retry | Automatic retry with feedback |
|
|
234
247
|
| Errors are for developers | Errors are actionable for agents |
|
|
235
248
|
|
|
236
|
-
## Roadmap
|
|
237
|
-
|
|
238
|
-
- [ ] Invariant contracts (input/output relationships)
|
|
239
|
-
- [ ] CrewAI adapter
|
|
240
|
-
- [x] Retry with feedback loop
|
|
241
|
-
- [ ] VS Code extension for violation inspection
|
|
242
|
-
|
|
243
249
|
## Contributing
|
|
244
250
|
|
|
245
251
|
Contributions welcome! Please open an issue first to discuss what you'd like to change.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
handoff/__init__.py,sha256=
|
|
1
|
+
handoff/__init__.py,sha256=2dMVFewz6vrM0v6VYkdX0Vn0OOiSwbEu8AEI8wj4ExI,481
|
|
2
2
|
handoff/core.py,sha256=zVGKyLO7TxwoJS_J5n-c47OdQGUmrQJjIvRW97tInBs,3173
|
|
3
3
|
handoff/guard.py,sha256=gmrg9odUin8YSUUjtUSzIMTz7u9DPWyLXYjfvcJlA4g,18603
|
|
4
4
|
handoff/langgraph.py,sha256=MLm4G2uZWiE30lFKU99DEA_LjlSHzTvb5Ci7urFlyF8,1931
|
|
5
5
|
handoff/retry.py,sha256=QVuPAygS3hEEXKS9h073uW4N0erURXiyIYFllsaR1YY,3891
|
|
6
6
|
handoff/testing.py,sha256=WlsEKyOX8_M8cfUc3xbcoMFU4Pyn-j1ffD8xPigAQ_Q,915
|
|
7
|
-
handoff/utils.py,sha256=
|
|
8
|
-
handoff_guard-0.2.
|
|
9
|
-
handoff_guard-0.2.
|
|
10
|
-
handoff_guard-0.2.
|
|
11
|
-
handoff_guard-0.2.
|
|
7
|
+
handoff/utils.py,sha256=hMsvdxgoFtF324iBMVrlniS-6mZKLsCVuDYD5mRRJts,10688
|
|
8
|
+
handoff_guard-0.2.1.dist-info/METADATA,sha256=MwcPliqUxlc7XFd36rZfuZSc4SAeN9hz6K9oA2qMLLk,8554
|
|
9
|
+
handoff_guard-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
handoff_guard-0.2.1.dist-info/licenses/LICENSE,sha256=HroDuVS_iz3RlUAJhww1Ma4olb4onXHg1MXaIZCb06s,1063
|
|
11
|
+
handoff_guard-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|