Coverage for core/langgraph/_envelope.py: 99%

139 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-28 00:19 +0000

1"""_envelope.py — Core agent output envelope contract and validation. 

2 

3The engine owns a single deterministic definition of the agent output 

4envelope: the status set, the hard-failure error code, the prompt-injection 

5contract text, and the pure validators for the parsed envelope object. 

6 

7The module also owns the envelope's parse ladder: it fits raw LLM output into 

8the locked form via strict JSON, bounded wrapper recovery (object or list) (at most 

9``ENVELOPE_MAX_UNWRAP_LAYERS`` quote/code-fence layers), and a raw-content 

10fallback. It intentionally does not import ``_llm`` (which imports this 

11module) to avoid a circular import. 

12""" 

13from __future__ import annotations 

14 

15import json 

16from collections.abc import Callable 

17from dataclasses import dataclass 

18from typing import Any 

19 

20_ENVELOPE_STATUS_ENUM: tuple[str, ...] = ("ok", "partial", "no_success", "error") 

21 

22ENVELOPE_STATUSES: frozenset[str] = frozenset(_ENVELOPE_STATUS_ENUM) 

23 

24ENVELOPE_ERROR_CODE: str = "AGENT_ERROR" 

25 

26ENVELOPE_MAX_UNWRAP_LAYERS: int = 1 

27 

28 

29@dataclass(frozen=True) 

30class _EnvelopeField: 

31 """One envelope field: its contract prose and its validator, if any.""" 

32 

33 name: str 

34 contract: str 

35 validate: Callable[[dict], str | None] | None = None 

36 

37 

38def _validate_status(value: dict) -> str | None: 

39 status = value.get("status") 

40 if not isinstance(status, str): 

41 return "status must be a string" 

42 if status not in ENVELOPE_STATUSES: 

43 return f"status must be one of {sorted(ENVELOPE_STATUSES)}" 

44 return None 

45 

46 

47def _validate_error_field(value: dict) -> str | None: 

48 error = value.get("error") 

49 if value.get("status") != "error": 

50 if error is not None: 

51 return "error must be absent or null unless status is 'error'" 

52 return None 

53 if error is None: 

54 return "error is required when status is 'error'" 

55 if not isinstance(error, dict): 

56 return "error must be an object" 

57 return _validate_error_object(error) 

58 

59 

60def _validate_error_object(error: dict) -> str | None: 

61 if not _is_non_empty_string(error.get("code")): 

62 return "error.code must be a non-empty string" 

63 if not _is_non_empty_string(error.get("message")): 

64 return "error.message must be a non-empty string" 

65 if "details" in error and not isinstance(error["details"], dict): 

66 return "error.details must be an object when present" 

67 return None 

68 

69 

70def _validate_iteration(value: dict) -> str | None: 

71 if "iteration" not in value: 

72 return None 

73 if not isinstance(value["iteration"], list): 

74 return "iteration must be a list" 

75 return None 

76 

77 

78def _is_non_empty_string(value: Any) -> bool: 

79 return isinstance(value, str) and bool(value) 

80 

81 

82def _format_status_enum() -> str: 

83 return ", ".join(f'"{status}"' for status in _ENVELOPE_STATUS_ENUM) 

84 

85 

86_ENVELOPE_FIELDS: tuple[_EnvelopeField, ...] = ( 

87 _EnvelopeField( 

88 name="status", 

89 contract=f'- "status": required. The inference status; one of {_format_status_enum()}.', 

90 validate=_validate_status, 

91 ), 

92 _EnvelopeField( 

93 name="error", 

94 contract='- "error": the structured failure information; required only when ' 

95 '"status" is "error"; absent or null otherwise. When present it is an object ' 

96 'with a non-empty string "code", a non-empty string "message", and an ' 

97 'optional object "details".', 

98 validate=_validate_error_field, 

99 ), 

100 _EnvelopeField( 

101 name="content", 

102 contract='- "content": optional. The value handed to the next actor in the pipeline; put the result your task instructions asked you to produce here. content is an opaque, ' 

103 "unconstrained value. It is not required to be JSON; JSON is merely one " 

104 "possible shape. Core does not type, validate, parse, or interpret it. " 

105 "May be null or absent.", 

106 ), 

107 _EnvelopeField( 

108 name="iteration", 

109 contract='- "iteration": optional. The iteration/topology (fan-out) signal; the ' 

110 "LLM-inference-provided list of items to iterate on the next round; when " 

111 "present it must be a JSON array (an empty array means no iteration); omit it " 

112 "rather than sending null.", 

113 validate=_validate_iteration, 

114 ), 

115) 

116 

117_ENVELOPE_FIELD_NAMES: frozenset[str] = frozenset(field.name for field in _ENVELOPE_FIELDS) 

118 

119_CONTRACT_HEADER: str = ( 

120 "## Agent output envelope\n" 

121 "\n" 

122 "Emit the entire envelope — {status, error, content, iteration} — as one " 

123 "raw JSON object. The envelope is the single top-level JSON object you " 

124 "output, not your task result itself. Your task instructions tell you what " 

125 "result to produce (for example a JSON object); put that result in the " 

126 "\"content\" field of this envelope. The envelope has these fields:\n" 

127 "\n" 

128 "Output ONLY the raw JSON envelope — no markdown code fences and no " 

129 "surrounding prose.\n" 

130 "\n" 

131 "Do not include any chain-of-thought or reasoning text outside the " 

132 "envelope; keep all reasoning internal and never emit it in the output.\n" 

133 "\n" 

134 "Tool calls are never part of this envelope. A tool call, its name, its " 

135 "arguments, and any tool-call syntax must never appear in this envelope or " 

136 "in its \"content\" field; tool calls travel only through the native " 

137 "tool-call channel (message.tool_calls).\n" 

138 "\n" 

139) 

140 

141ENVELOPE_CONTRACT_BLOCK: str = _CONTRACT_HEADER + "\n".join( 

142 field.contract for field in _ENVELOPE_FIELDS 

143) + "\n" 

144 

145 

146def _validate_field_set(value: dict) -> str | None: 

147 unknown = sorted(key for key in value if key not in _ENVELOPE_FIELD_NAMES) 

148 if unknown: 

149 return f"envelope must not contain unknown fields: {unknown}" 

150 return None 

151 

152 

153def validate_envelope(value: Any) -> str | None: 

154 """Return ``None`` when *value* is a valid envelope, else a short error.""" 

155 if not isinstance(value, dict): 

156 return "envelope must be a JSON object" 

157 

158 error = _validate_field_set(value) 

159 if error is not None: 

160 return error 

161 

162 for field in _ENVELOPE_FIELDS: 

163 if field.validate is None: 

164 continue 

165 error = field.validate(value) 

166 if error is not None: 

167 return error 

168 return None 

169 

170 

171def is_error_envelope(value: Any) -> bool: 

172 """True iff *value* is a dict whose ``status`` is ``"error"``.""" 

173 return isinstance(value, dict) and value.get("status") == "error" 

174 

175 

176def _strict_parse(text: str) -> tuple[Any, str | None]: 

177 """Decode *text* as strict JSON; ``(value, None)`` or ``(None, error)``.""" 

178 try: 

179 return json.loads(text), None 

180 except json.JSONDecodeError as error: 

181 return None, str(error) 

182 

183 

184def _strip_quote_layer(text: str) -> str | None: 

185 """Return the string a JSON-string *text* encodes, else ``None``.""" 

186 value, error = _strict_parse(text) 

187 if error is not None or not isinstance(value, str): 

188 return None 

189 return value 

190 

191 

192def _strip_fence_layer(text: str) -> str | None: 

193 """Return a whole-output markdown code-fence body, else ``None``.""" 

194 lines = text.splitlines() 

195 if not lines or not _is_fence_opening(lines[0].strip()): 

196 return None 

197 closing = _last_non_empty_line_index(lines) 

198 if closing is None or closing == 0 or lines[closing].strip() != "```": 

199 return None 

200 return "\n".join(lines[1:closing]) 

201 

202 

203def _is_fence_opening(line: str) -> bool: 

204 if not line.startswith("```"): 

205 return False 

206 tag = line[3:] 

207 return not any(character.isspace() for character in tag) and "`" not in tag 

208 

209 

210def _last_non_empty_line_index(lines: list[str]) -> int | None: 

211 for index in range(len(lines) - 1, -1, -1): 

212 if lines[index].strip(): 

213 return index 

214 return None 

215 

216 

217def _strip_one_layer(text: str) -> str | None: 

218 """Peel one quote or code-fence wrapping layer; ``None`` if there is none.""" 

219 quoted = _strip_quote_layer(text) 

220 if quoted is not None: 

221 return quoted 

222 return _strip_fence_layer(text) 

223 

224 

225def _recover_json_value(text: str) -> dict | list | None: 

226 """Peel up to ``ENVELOPE_MAX_UNWRAP_LAYERS`` wrappers seeking a JSON object or list.""" 

227 current = text 

228 for _ in range(ENVELOPE_MAX_UNWRAP_LAYERS): 

229 inner = _strip_one_layer(current) 

230 if inner is None: 

231 return None 

232 value, _error = _strict_parse(inner) 

233 if isinstance(value, (dict, list)): 

234 return value 

235 current = inner 

236 return None 

237 

238 

239def _classify_object(value: dict, raw: str) -> tuple[dict | None, str | None]: 

240 if "status" not in value: 

241 return {"status": "ok", "content": raw}, None 

242 error = validate_envelope(value) 

243 if error is not None: 

244 return None, f"ENVELOPE_ERROR: {error}" 

245 return value, None 

246 

247 

248def parse_agent_envelope(raw: str | None) -> tuple[dict | None, str | None]: 

249 """Fit *raw* LLM output into an envelope, or return a technical error. 

250 

251 Strict JSON is extended by bounded deterministic recovery: at most 

252 ``ENVELOPE_MAX_UNWRAP_LAYERS`` quote/code-fence layers are stripped from an 

253 otherwise-valid JSON object or list; a revealed JSON list becomes the 

254 ``iteration`` and a revealed object is the envelope. A bare JSON array 

255 becomes the ``iteration``. 

256 A ``status``-keyed object that fails ``validate_envelope`` is always a 

257 technical error, never recovered. Every other output — including prose, 

258 malformed JSON, scalars, and objects without ``status`` — becomes 

259 ``{"status": "ok", "content": <raw>}`` with the raw output verbatim. 

260 """ 

261 if raw is None or not raw.strip(): 

262 return None, "empty" 

263 text = raw.strip() 

264 parsed, _ = _strict_parse(text) 

265 if isinstance(parsed, dict): 

266 return _classify_object(parsed, raw) 

267 recovered = _recover_json_value(text) 

268 value = recovered if recovered is not None else parsed 

269 if isinstance(value, dict): 

270 return _classify_object(value, raw) 

271 if isinstance(value, list): 

272 return {"status": "ok", "iteration": value}, None 

273 return {"status": "ok", "content": raw}, None