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 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.0"
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 parse_json(text: str) -> dict:
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
- return repaired
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.0
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>=2.0.0
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
- > Validation for LLM agents that retries with feedback.
34
+ > Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.
35
35
 
36
36
  [![PyPI version](https://img.shields.io/pypi/v/handoff-guard)](https://pypi.org/project/handoff-guard/)
37
37
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
38
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
- When validation fails, the agent retries with feedback about what went wrong. After all attempts are exhausted:
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
- ```bash
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
- # Run with real LLM calls
95
- export OPENROUTER_API_KEY=your_key
96
- python -m examples.llm_demo.run_demo --pipeline --api
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, CrewAI, or plain Python
107
- - **Lightweight** — Just Pydantic, no Docker, no telemetry servers
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! Handoff uses Pydantic under the hood.
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=RDO2wqA1zROrtM2GE_B_1pLA4OOClCzzjMARaorB-Oo,449
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=u3zsnvwf5vcmzVUCiy73iNaskaUNpRnlljle57tu3Ks,8281
8
- handoff_guard-0.2.0.dist-info/METADATA,sha256=PsauQeZ7e_ez0ilb4JIrL4O5-9qSQkz1pMD7qraApp0,7868
9
- handoff_guard-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
- handoff_guard-0.2.0.dist-info/licenses/LICENSE,sha256=HroDuVS_iz3RlUAJhww1Ma4olb4onXHg1MXaIZCb06s,1063
11
- handoff_guard-0.2.0.dist-info/RECORD,,
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,,