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.
Files changed (32) hide show
  1. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/publish.yml +2 -0
  2. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/AGENTS.md +13 -2
  3. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/PKG-INFO +31 -25
  4. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/README.md +29 -23
  5. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/pyproject.toml +2 -2
  6. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/__init__.py +3 -2
  7. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/utils.py +95 -5
  8. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_utils.py +76 -1
  9. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/dependabot.yml +0 -0
  10. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/ci.yml +0 -0
  11. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.github/workflows/pr-title.yml +0 -0
  12. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.gitignore +0 -0
  13. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/.pre-commit-config.yaml +0 -0
  14. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/LICENSE +0 -0
  15. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/README.md +0 -0
  16. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/__init__.py +0 -0
  17. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/agents.py +0 -0
  18. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/run_demo.py +0 -0
  19. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/llm_demo/schemas.py +0 -0
  20. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/README.md +0 -0
  21. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/__init__.py +0 -0
  22. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/pipeline.py +0 -0
  23. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/run_demo.py +0 -0
  24. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/examples/rag_demo/schemas.py +0 -0
  25. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/core.py +0 -0
  26. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/guard.py +0 -0
  27. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/langgraph.py +0 -0
  28. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/retry.py +0 -0
  29. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/src/handoff/testing.py +0 -0
  30. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_guard.py +0 -0
  31. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_retry.py +0 -0
  32. {handoff_guard-0.2.0 → handoff_guard-0.2.1}/tests/test_testing.py +0 -0
@@ -16,6 +16,8 @@ jobs:
16
16
 
17
17
  - name: Set up uv
18
18
  uses: astral-sh/setup-uv@v5
19
+ with:
20
+ enable-cache: false
19
21
 
20
22
  - name: Build package
21
23
  run: uv build
@@ -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.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,9 +1,10 @@
1
1
  # handoff-guard
2
2
 
3
- > Validation for LLM agents that retries with feedback.
3
+ > Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.
4
4
 
5
5
  [![PyPI version](https://img.shields.io/pypi/v/handoff-guard)](https://pypi.org/project/handoff-guard/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
- When validation fails, the agent retries with feedback about what went wrong. After all attempts are exhausted:
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
- ```bash
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
- # Run with real LLM calls
64
- export OPENROUTER_API_KEY=your_key
65
- python -m examples.llm_demo.run_demo --pipeline --api
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, CrewAI, or plain Python
76
- - **Lightweight** — Just Pydantic, no Docker, no telemetry servers
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! Handoff uses Pydantic under the hood.
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.0"
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.0"
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 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,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