handoff-guard 0.2.0__tar.gz → 0.2.1__tar.gz
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_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/publish.yml +2 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/AGENTS.md +13 -2
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/PKG-INFO +31 -25
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/README.md +29 -23
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/pyproject.toml +2 -2
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/__init__.py +3 -2
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/utils.py +95 -5
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_utils.py +76 -1
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/dependabot.yml +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/ci.yml +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/pr-title.yml +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.gitignore +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.pre-commit-config.yaml +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/LICENSE +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/README.md +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/__init__.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/agents.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/run_demo.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/schemas.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/README.md +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/__init__.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/pipeline.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/run_demo.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/schemas.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/core.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/guard.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/langgraph.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/retry.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/testing.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_guard.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_retry.py +0 -0
- {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_testing.py +0 -0
|
@@ -55,7 +55,7 @@ examples/
|
|
|
55
55
|
```python
|
|
56
56
|
from handoff import guard, GuardConfig, HandoffViolation, ViolationContext
|
|
57
57
|
from handoff import retry, RetryState, Diagnostic, AttemptRecord
|
|
58
|
-
from handoff import parse_json, ParseError
|
|
58
|
+
from handoff import parse_json, ParseResult, ParseError
|
|
59
59
|
from handoff.langgraph import guarded_node, validate_state
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -105,6 +105,17 @@ Parses JSON from LLM text output. Processing chain:
|
|
|
105
105
|
5. Repair malformed JSON via `json-repair` (trailing commas, single quotes, unquoted keys, missing braces, JS comments)
|
|
106
106
|
6. Raise `ParseError` with detailed error message
|
|
107
107
|
|
|
108
|
+
```python
|
|
109
|
+
# Default mode: returns data directly
|
|
110
|
+
data = parse_json(text)
|
|
111
|
+
|
|
112
|
+
# Detailed mode: returns ParseResult with truncation/repair info
|
|
113
|
+
result = parse_json(text, detailed=True)
|
|
114
|
+
result.data # dict or list
|
|
115
|
+
result.truncated # True if unmatched braces (likely max_tokens hit)
|
|
116
|
+
result.repaired # True if JSON had syntax errors that were auto-fixed
|
|
117
|
+
```
|
|
118
|
+
|
|
108
119
|
`ParseError` includes:
|
|
109
120
|
- `.raw_output` — truncated input for debugging
|
|
110
121
|
- `.original` — the underlying `JSONDecodeError` with line/column info
|
|
@@ -184,6 +195,6 @@ Tests are split across four files:
|
|
|
184
195
|
- `test_guard.py` — Guard decorator: valid passthrough, invalid input/output raises, on_fail modes, custom node_name, input/output-only, async support, violation context and serialization
|
|
185
196
|
- `test_retry.py` — Retry loop: succeeds on later attempt, exhausts max_attempts, RetryState injection, proxy behavior, feedback text, violation history, parse error retry, retry_on filtering, on_fail after retry, input validation skips retry, async retry
|
|
186
197
|
- `test_testing.py` — `mock_retry()` context manager sets context and proxy works
|
|
187
|
-
- `test_utils.py` — `parse_json`: valid JSON, code fence stripping, conversational wrapper stripping (preamble, postamble, combined, multiline, nested, arrays, escaped strings), JSON repair (trailing commas, single quotes, unquoted keys, missing braces, comments), invalid raises ParseError with original exception, non-string raises, BOM stripping, error location and suggestions (context snippet, pointer, preview)
|
|
198
|
+
- `test_utils.py` — `parse_json`: valid JSON, code fence stripping, conversational wrapper stripping (preamble, postamble, combined, multiline, nested, arrays, escaped strings), JSON repair (trailing commas, single quotes, unquoted keys, missing braces, comments), invalid raises ParseError with original exception, non-string raises, BOM stripping, error location and suggestions (context snippet, pointer, preview), detailed mode with truncation/repair detection
|
|
188
199
|
|
|
189
200
|
Run with: `pytest tests/ -v`
|
|
@@ -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,9 +1,10 @@
|
|
|
1
1
|
# handoff-guard
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.
|
|
4
4
|
|
|
5
5
|
[](https://pypi.org/project/handoff-guard/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
7
8
|
|
|
8
9
|
## The Problem
|
|
9
10
|
|
|
@@ -19,7 +20,7 @@ Which node? Which field? What was passed? Can the agent fix it?
|
|
|
19
20
|
## The Solution
|
|
20
21
|
|
|
21
22
|
```python
|
|
22
|
-
from handoff import guard, retry, parse_json
|
|
23
|
+
from handoff import guard, retry, parse_json # PyPI: handoff-guard
|
|
23
24
|
from pydantic import BaseModel, Field
|
|
24
25
|
|
|
25
26
|
class WriterOutput(BaseModel):
|
|
@@ -35,11 +36,11 @@ def writer_agent(state: dict) -> dict:
|
|
|
35
36
|
if retry.is_retry:
|
|
36
37
|
prompt += f"\n\nYour previous attempt failed:\n{retry.feedback()}"
|
|
37
38
|
|
|
38
|
-
response = call_llm(prompt)
|
|
39
|
-
return parse_json(response)
|
|
39
|
+
response = call_llm(prompt) # your LLM call here
|
|
40
|
+
return parse_json(response) # @guard validates dict against WriterOutput, returns dict as-is
|
|
40
41
|
```
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
If valid, the function returns normally. If invalid, it retries with feedback. After all attempts exhausted:
|
|
43
44
|
|
|
44
45
|
```
|
|
45
46
|
HandoffViolation in 'writer' (attempt 3/3):
|
|
@@ -56,13 +57,18 @@ HandoffViolation in 'writer' (attempt 3/3):
|
|
|
56
57
|
pip install handoff-guard
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
# See retry-with-feedback in action (no API key needed)
|
|
61
|
-
python -m examples.llm_demo.run_demo
|
|
60
|
+
Requires Python 3.10+ and Pydantic v2.
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
```python
|
|
63
|
+
from handoff import guard, retry, parse_json # PyPI: handoff-guard
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To run demos, clone the repo:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/acartag7/handoff-guard && cd handoff-guard
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
python -m examples.llm_demo.run_demo # no API key needed
|
|
66
72
|
```
|
|
67
73
|
|
|
68
74
|
## Features
|
|
@@ -71,9 +77,10 @@ python -m examples.llm_demo.run_demo --pipeline --api
|
|
|
71
77
|
- **Know which node failed** — No more guessing from stack traces
|
|
72
78
|
- **Know which field failed** — Exact path to the problem
|
|
73
79
|
- **Get fix suggestions** — Actionable error messages
|
|
74
|
-
- **`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
|
|
75
|
-
- **Framework agnostic** — Works with LangGraph
|
|
76
|
-
- **
|
|
80
|
+
- **`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
|
|
81
|
+
- **Framework agnostic** — Works with LangGraph or plain Python
|
|
82
|
+
- **Async supported** — Works with `async def` functions (context-local retry state)
|
|
83
|
+
- **Lightweight** — Pydantic + json-repair, no Docker, no telemetry
|
|
77
84
|
|
|
78
85
|
## API
|
|
79
86
|
|
|
@@ -111,7 +118,7 @@ retry.history # List of AttemptRecord objects
|
|
|
111
118
|
### `parse_json`
|
|
112
119
|
|
|
113
120
|
```python
|
|
114
|
-
from handoff import parse_json
|
|
121
|
+
from handoff import parse_json # ParseResult is the return type when detailed=True
|
|
115
122
|
|
|
116
123
|
# Handles code fences
|
|
117
124
|
data = parse_json('```json\n{"key": "value"}\n```')
|
|
@@ -128,6 +135,12 @@ data = parse_json('{a: 1}') # unquoted keys
|
|
|
128
135
|
data = parse_json('{"a": 1') # missing brace
|
|
129
136
|
data = parse_json('{"a": 1 // comment}') # JS comments
|
|
130
137
|
|
|
138
|
+
# Detailed mode: detect truncation and repair
|
|
139
|
+
result = parse_json('{"draft": "long text...', detailed=True)
|
|
140
|
+
result.data # the parsed dict/list
|
|
141
|
+
result.truncated # True if unmatched braces (e.g., token limit or stream cutoff)
|
|
142
|
+
result.repaired # True if JSON had syntax errors that were auto-fixed
|
|
143
|
+
|
|
131
144
|
# Raises ParseError on failure (retryable by @guard)
|
|
132
145
|
# ParseError includes detailed context:
|
|
133
146
|
# - Line/column location
|
|
@@ -190,7 +203,7 @@ def router(state: dict) -> dict:
|
|
|
190
203
|
|
|
191
204
|
## Why not just use Pydantic directly?
|
|
192
205
|
|
|
193
|
-
You should!
|
|
206
|
+
You should! handoff-guard uses Pydantic under the hood.
|
|
194
207
|
|
|
195
208
|
The difference:
|
|
196
209
|
|
|
@@ -202,13 +215,6 @@ The difference:
|
|
|
202
215
|
| No retry | Automatic retry with feedback |
|
|
203
216
|
| Errors are for developers | Errors are actionable for agents |
|
|
204
217
|
|
|
205
|
-
## Roadmap
|
|
206
|
-
|
|
207
|
-
- [ ] Invariant contracts (input/output relationships)
|
|
208
|
-
- [ ] CrewAI adapter
|
|
209
|
-
- [x] Retry with feedback loop
|
|
210
|
-
- [ ] VS Code extension for violation inspection
|
|
211
|
-
|
|
212
218
|
## Contributing
|
|
213
219
|
|
|
214
220
|
Contributions welcome! Please open an issue first to discuss what you'd like to change.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "handoff-guard"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Lightweight validation at agent boundaries. Know what broke and where."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.12",
|
|
19
19
|
]
|
|
20
20
|
dependencies = [
|
|
21
|
-
"pydantic>=2.0.0",
|
|
21
|
+
"pydantic>=2.0.0,<3",
|
|
22
22
|
"json-repair>=0.55.0",
|
|
23
23
|
]
|
|
24
24
|
|
|
@@ -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"
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
"""Tests for handoff.utils."""
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
-
from handoff.utils import parse_json, ParseError
|
|
4
|
+
from handoff.utils import parse_json, ParseError, ParseResult
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class TestParseJson:
|
|
@@ -195,3 +195,78 @@ class TestParseJson:
|
|
|
195
195
|
# Should have location info
|
|
196
196
|
assert "line" in msg
|
|
197
197
|
assert "column" in msg or "col" in msg
|
|
198
|
+
|
|
199
|
+
# --- Detailed mode with truncation/repair detection (HG-13) ---
|
|
200
|
+
|
|
201
|
+
def test_detailed_returns_parse_result(self):
|
|
202
|
+
result = parse_json('{"a": 1}', detailed=True)
|
|
203
|
+
assert isinstance(result, ParseResult)
|
|
204
|
+
assert result.data == {"a": 1}
|
|
205
|
+
assert result.truncated is False
|
|
206
|
+
assert result.repaired is False
|
|
207
|
+
|
|
208
|
+
def test_detailed_false_returns_data_directly(self):
|
|
209
|
+
result = parse_json('{"a": 1}', detailed=False)
|
|
210
|
+
assert result == {"a": 1}
|
|
211
|
+
assert not isinstance(result, ParseResult)
|
|
212
|
+
|
|
213
|
+
def test_detailed_detects_truncation_missing_brace(self):
|
|
214
|
+
# Simulates max_tokens cutoff mid-object
|
|
215
|
+
text = '{"draft": "This is a long article about technology'
|
|
216
|
+
result = parse_json(text, detailed=True)
|
|
217
|
+
assert result.truncated is True
|
|
218
|
+
assert result.repaired is True # json_repair fixes it
|
|
219
|
+
assert result.data == {"draft": "This is a long article about technology"}
|
|
220
|
+
|
|
221
|
+
def test_detailed_detects_truncation_missing_bracket(self):
|
|
222
|
+
# Simulates max_tokens cutoff mid-array
|
|
223
|
+
text = '{"items": [1, 2, 3, 4, 5'
|
|
224
|
+
result = parse_json(text, detailed=True)
|
|
225
|
+
assert result.truncated is True
|
|
226
|
+
assert result.repaired is True
|
|
227
|
+
assert result.data == {"items": [1, 2, 3, 4, 5]}
|
|
228
|
+
|
|
229
|
+
def test_detailed_detects_truncation_nested(self):
|
|
230
|
+
# Deeply nested structure cut off
|
|
231
|
+
text = '{"a": {"b": {"c": "value'
|
|
232
|
+
result = parse_json(text, detailed=True)
|
|
233
|
+
assert result.truncated is True
|
|
234
|
+
assert result.repaired is True
|
|
235
|
+
|
|
236
|
+
def test_detailed_detects_repair_trailing_comma(self):
|
|
237
|
+
# Trailing comma needs repair but is not truncation
|
|
238
|
+
text = '{"a": 1, "b": 2,}'
|
|
239
|
+
result = parse_json(text, detailed=True)
|
|
240
|
+
assert result.truncated is False # Balanced braces
|
|
241
|
+
assert result.repaired is True
|
|
242
|
+
assert result.data == {"a": 1, "b": 2}
|
|
243
|
+
|
|
244
|
+
def test_detailed_detects_repair_single_quotes(self):
|
|
245
|
+
text = "{'a': 'hello'}"
|
|
246
|
+
result = parse_json(text, detailed=True)
|
|
247
|
+
assert result.truncated is False
|
|
248
|
+
assert result.repaired is True
|
|
249
|
+
assert result.data == {"a": "hello"}
|
|
250
|
+
|
|
251
|
+
def test_detailed_valid_json_no_flags(self):
|
|
252
|
+
# Valid JSON should have both flags as False
|
|
253
|
+
text = '{"valid": true, "count": 42}'
|
|
254
|
+
result = parse_json(text, detailed=True)
|
|
255
|
+
assert result.truncated is False
|
|
256
|
+
assert result.repaired is False
|
|
257
|
+
assert result.data == {"valid": True, "count": 42}
|
|
258
|
+
|
|
259
|
+
def test_detailed_with_wrappers_no_repair(self):
|
|
260
|
+
# Conversational wrappers don't count as repair
|
|
261
|
+
text = 'Sure! Here is the JSON:\n{"a": 1}'
|
|
262
|
+
result = parse_json(text, detailed=True)
|
|
263
|
+
assert result.truncated is False
|
|
264
|
+
assert result.repaired is False # Extraction != repair
|
|
265
|
+
assert result.data == {"a": 1}
|
|
266
|
+
|
|
267
|
+
def test_detailed_truncated_with_wrappers(self):
|
|
268
|
+
# Truncated JSON inside wrappers
|
|
269
|
+
text = 'Here you go:\n{"items": [1, 2, 3'
|
|
270
|
+
result = parse_json(text, detailed=True)
|
|
271
|
+
assert result.truncated is True
|
|
272
|
+
assert result.repaired is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|