zjcode 0.0.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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,694 @@
1
+ """Shared streaming tool-call buffering and hook-payload construction.
2
+
3
+ Both execution surfaces reassemble the same streamed tool-call state and fire
4
+ the same `tool.use` / `tool.result` / `tool.error` hook payloads:
5
+
6
+ - the interactive Textual TUI (`deepagents_code.tui.textual_adapter`), and
7
+ - the headless runner (`deepagents_code.client.non_interactive`).
8
+
9
+ This module holds the single implementation of the buffering, argument parsing,
10
+ and payload-shape logic, so the two surfaces cannot drift *in those layers*. The
11
+ dispatch, gating, result-correlation, and buffer-lifecycle layers are still
12
+ implemented separately in each surface (each calls
13
+ `dispatch_hook_fire_and_forget` from its own namespace — the dispatch seam tests
14
+ patch) and must be kept in sync by hand.
15
+
16
+ Why the split exists
17
+ --------------------
18
+
19
+ The two surfaces have fundamentally different object lifecycles, which prevents
20
+ a single shared dispatch function:
21
+
22
+ - **TUI**: tool calls are backed by `ToolCallMessage` widgets that persist
23
+ parsed args as instance state and render success/error visually. `tool.use`
24
+ fires at widget-mount time because that is when the parsed args are available
25
+ — they are what the widget is constructed with (or come from a validated HITL
26
+ interrupt). Result correlation reads args from `tool_msg.args` (a widget
27
+ property).
28
+
29
+ - **Headless**: there are no widgets. Tool-call state lives in a plain dict on
30
+ `StreamState` (`in_flight_tool_calls`, keyed by tool-call id). `tool.use`
31
+ fires in the stream loop once args parse and the tool-call id is known.
32
+ Result correlation pops the record from that dict.
33
+
34
+ Additionally, only the TUI has interactive HITL approval *widgets* that can be
35
+ rejected before any `ToolMessage` streams back, so only it needs
36
+ `_dispatch_terminal_tool_result_hooks` and the `completed_tool_result_ids`
37
+ duplicate-suppression set for synthetic middleware `ToolMessage` re-arrivals
38
+ after a resumed turn. The headless runner *does* have a HITL interrupt/resume
39
+ flow, but it never pre-dispatches terminal hooks before a resume — a rejection
40
+ arrives as a synthetic `ToolMessage` handled by the normal result path — so
41
+ there is no already-emitted terminal hook to suppress and therefore no
42
+ equivalent race.
43
+
44
+ Unifying these into a single function would require either a common
45
+ widget-or-dict abstraction (indirection with no behavioral gain) or pushing
46
+ widget-aware logic into this shared module (coupling headless mode to TUI
47
+ concepts it does not use). The split is deliberate: share everything that *can*
48
+ drift (payload shapes, arg parsing, status normalization, output truncation, and
49
+ the end-of-stream classification of tool calls that never emitted a `tool.use`)
50
+ here, and keep everything that *must* differ (when to dispatch, where args come
51
+ from, HITL handling) in each surface.
52
+
53
+ Parity contract for hook consumers
54
+ ----------------------------------
55
+
56
+ A hook consumer can rely on these guarantees being identical across surfaces:
57
+
58
+ - **Payload shape**: every `tool.use`, `tool.result`, and `tool.error` payload
59
+ is built by the shared builders in this module, so field names and
60
+ truncation are the same.
61
+ - **Event completeness**: every executed tool emits `tool.result`, including
62
+ tools whose args never parsed or that carried no tool-call id (both surfaces
63
+ emit with `{}` args in that case). A tool whose stream is aborted before its
64
+ result — a cancelled turn or a mid-stream error — is also closed with a
65
+ terminal `tool.error`/`tool.result` (TUI via
66
+ `_dispatch_terminal_tool_result_hooks`, headless via
67
+ `_dispatch_orphaned_tool_result_hooks`), so no `tool.use` is left dangling.
68
+ - **Fire-once-per-id**: `tool.use` fires at most once per tool-call id on both
69
+ surfaces, each gated by a monotonic id set that is never discarded within a
70
+ turn (TUI via `displayed_tool_ids`, headless via `emitted_tool_use_ids`). A
71
+ redelivery of the same id's arg chunks *after* its result — non-standard for
72
+ `stream_mode="messages"` — is therefore ignored on both surfaces rather than
73
+ re-firing `tool.use`. Headless additionally keeps `in_flight_tool_calls` for
74
+ result correlation and the orphan drain; that map *is* cleared per result,
75
+ so it is deliberately not the fire-once guard.
76
+ - **`tool.error` co-firing**: whenever `tool_status` is `"error"`, both
77
+ surfaces emit `tool.error` alongside `tool.result`.
78
+
79
+ What is allowed to differ (and is not part of the contract):
80
+
81
+ - **Dispatch timing**: the TUI dispatches `tool.use` at widget mount;
82
+ headless dispatches it in the stream loop once args parse. A hook subscriber
83
+ should not assume `tool.use` fires at an identical point in the surface's
84
+ internal lifecycle — only that it fires before `tool.result` for the same
85
+ `tool_id`.
86
+ - **HITL rejection events**: both surfaces emit `tool.error`/`tool.result` for
87
+ a rejected tool, but by different routes. Headless (and the TUI on a resumed
88
+ turn) emits them from the synthetic `ToolMessage` on the normal result path;
89
+ the TUI *additionally* emits them via the dedicated
90
+ `_dispatch_terminal_tool_result_hooks` drain for tools rejected or cancelled
91
+ in the interactive UI *before* any `ToolMessage` streams back. Only that
92
+ pre-`ToolMessage` terminal drain is TUI-only — headless has no interactive
93
+ approval path that can reject a call before its result arrives.
94
+
95
+ When changing the dispatch or gating logic in one surface, verify the parity
96
+ contract still holds against the other surface.
97
+
98
+ The payload schema is documented in `deepagents_code.hooks`.
99
+ """
100
+
101
+ from __future__ import annotations
102
+
103
+ import json
104
+ import logging
105
+ import sys
106
+ from dataclasses import dataclass, field
107
+ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict
108
+
109
+ from deepagents_code.hooks import HOOK_TOOL_OUTPUT_LIMIT
110
+
111
+ if TYPE_CHECKING:
112
+ from collections.abc import Iterable
113
+
114
+ logger = logging.getLogger(__name__)
115
+
116
+ ToolStatus = Literal["success", "error"]
117
+ """Terminal status of a tool call, mirroring `ToolMessage.status`."""
118
+
119
+ ToolCallBufferKey = int | str
120
+ """Key for buffering an in-progress streamed tool call.
121
+
122
+ The streaming `index` (an `int`) when present, else the tool-call `id` (a
123
+ `str`), else a unique placeholder string (see `tool_call_buffer_key`). Exported
124
+ so both surfaces annotate their buffer maps identically rather than each
125
+ spelling the union — the exact drift this module exists to prevent.
126
+ """
127
+
128
+ ProviderToolArgs = dict[str, Any] | list[Any] | str | int | float | bool | None
129
+ """A whole tool-call arguments value delivered by a provider in one chunk.
130
+
131
+ Usually a `dict`; rarely a bare scalar or list. `None` means no whole value has
132
+ arrived yet (the args may instead be streaming as fragments in `args_parts`).
133
+ An `isinstance` check narrows it to the declared `dict[str, Any]` arg type at the
134
+ single `parse_args` return site.
135
+ """
136
+
137
+ TOOL_OUTPUT_TRUNCATION_MARKER = "…[output truncated]"
138
+ """Suffix appended to a `tool.result` `tool_output` that hit
139
+ `HOOK_TOOL_OUTPUT_LIMIT`, so a consumer can distinguish a capped result from a
140
+ genuinely short one. The marker is counted within the cap, so the final
141
+ `tool_output` length never exceeds `HOOK_TOOL_OUTPUT_LIMIT`."""
142
+
143
+ UNRENDERABLE_TOOL_OUTPUT = "<tool output could not be rendered>"
144
+ """Sentinel `tool_output` used when formatting/coercing a tool result raises.
145
+
146
+ Lets both surfaces keep the terminal `tool.result` dispatch unconditional
147
+ without re-touching the offending content (whose `__str__`/`__repr__` may itself
148
+ raise), so a hook consumer still sees the result rather than a dropped event."""
149
+
150
+ MAX_JSON_CONTAINER_DEPTH = sys.getrecursionlimit()
151
+ """Maximum JSON container nesting accepted for streamed tool-call args."""
152
+
153
+
154
+ def normalize_tool_status(raw_status: object, tool_name: str) -> ToolStatus:
155
+ """Map a raw `ToolMessage.status` to the two-value hook domain, fail-closed.
156
+
157
+ `"error"` and `"success"` pass through. Any other *present* value — a future
158
+ provider status, an explicit `None`, or a typo — is unexpected and treated as
159
+ `"error"` (and logged), so an audit or notification hook is never told a
160
+ non-successful tool succeeded. Callers pass
161
+ `getattr(message, "status", "success")`, so a missing status arrives as
162
+ `"success"` and is not warned about.
163
+
164
+ Args:
165
+ raw_status: The raw `status` value read off the `ToolMessage`.
166
+ tool_name: The tool name, included in the warning for context.
167
+
168
+ Returns:
169
+ `"success"` or `"error"`.
170
+ """
171
+ if raw_status == "error":
172
+ return "error"
173
+ if raw_status == "success":
174
+ return "success"
175
+ logger.warning(
176
+ "Unexpected ToolMessage.status %r for tool %s; treating as error",
177
+ raw_status,
178
+ tool_name,
179
+ )
180
+ return "error"
181
+
182
+
183
+ class ToolUsePayload(TypedDict):
184
+ """`tool.use` hook payload (schema documented in `hooks`)."""
185
+
186
+ tool_name: str
187
+ """The tool being invoked."""
188
+
189
+ tool_id: str
190
+ """The tool-call id. Always a real id — a call with no id never produces a
191
+ `tool.use` (see `hooks`), so unlike `tool.result` this is never `None`."""
192
+
193
+ tool_args: dict[str, Any]
194
+ """The parsed tool-call arguments."""
195
+
196
+
197
+ class ToolErrorPayload(TypedDict):
198
+ """`tool.error` hook payload (schema documented in `hooks`)."""
199
+
200
+ tool_names: list[str]
201
+ """Names of the tools whose calls failed or were rejected."""
202
+
203
+
204
+ class ToolResultPayload(TypedDict):
205
+ """`tool.result` hook payload (schema documented in `hooks`)."""
206
+
207
+ tool_name: str
208
+ """The tool that produced the result. Usually the real tool name; falls back
209
+ to `""` in the rare uncorrelated case where the `ToolMessage` carries no
210
+ `name` and no `tool.use` was correlated to supply one."""
211
+
212
+ tool_id: str | None
213
+ """The tool-call id, or `None` when it could not be correlated."""
214
+
215
+ tool_args: dict[str, Any]
216
+ """The parsed tool-call arguments, or `{}` when uncorrelated."""
217
+
218
+ tool_status: ToolStatus
219
+ """`"success"`, or `"error"` for a failed, rejected, or cancelled call."""
220
+
221
+ tool_output: str
222
+ """The tool's returned content, capped to `HOOK_TOOL_OUTPUT_LIMIT`. When the
223
+ full output exceeded the cap, the value ends with
224
+ `TOOL_OUTPUT_TRUNCATION_MARKER` so a consumer (e.g. a secret/policy scanner)
225
+ can tell a truncated result from a genuinely short one."""
226
+
227
+
228
+ def tool_call_buffer_key(
229
+ index: int | str | None, tool_id: str | None, count: int
230
+ ) -> ToolCallBufferKey:
231
+ """Compute a stable key for buffering an in-progress streamed tool call.
232
+
233
+ Prefers the streaming `index` (stable across fragments of one call), then
234
+ the tool-call `id`, falling back to a positional placeholder so unrelated
235
+ id-less calls don't collide.
236
+
237
+ Args:
238
+ index: The `index` field from the streamed tool-call chunk, if any.
239
+ Typed loosely because it is read from an untyped content-block dict.
240
+ tool_id: The tool-call `id` from the chunk, if any.
241
+ count: The current number of buffered calls, used to make the fallback
242
+ placeholder unique.
243
+
244
+ Returns:
245
+ The chunk `index`, else the `tool_id`, else a unique placeholder string.
246
+ """
247
+ if index is not None:
248
+ return index
249
+ if tool_id is not None:
250
+ return tool_id
251
+ return f"unknown-{count}"
252
+
253
+
254
+ def _exceeds_json_container_depth(s: str) -> bool:
255
+ """Return whether `s` exceeds the safe nesting depth for JSON args."""
256
+ depth = 0
257
+ in_string = False
258
+ escaped = False
259
+ for ch in s:
260
+ if in_string:
261
+ if escaped:
262
+ escaped = False
263
+ elif ch == "\\":
264
+ escaped = True
265
+ elif ch == '"':
266
+ in_string = False
267
+ continue
268
+ if ch == '"':
269
+ in_string = True
270
+ elif ch in "{[":
271
+ depth += 1
272
+ if depth > MAX_JSON_CONTAINER_DEPTH:
273
+ return True
274
+ elif ch in "}]":
275
+ depth -= 1
276
+ return False
277
+
278
+
279
+ def _looks_structurally_complete(s: str) -> bool:
280
+ """Return whether `s` is a balanced JSON container, string-state aware.
281
+
282
+ Scans for bracket balance while tracking whether the cursor is inside a
283
+ string literal (honoring backslash escapes), so a value is "complete" only
284
+ when every `{`/`[` is matched and the text does not end mid-string. Used
285
+ solely to decide whether a `json.loads` failure is worth warning about: a
286
+ balanced-but-unparseable value is malformed, whereas a partial stream — e.g.
287
+ a chunk boundary landing right after an inner `}` in `{"edits": [{"a": 1}` —
288
+ leaves an outer container open and returns `False`, so no false warning fires
289
+ on a healthy mid-stream fragment. A stray closer (`depth < 0`) is unbalanced
290
+ and can never be completed by more input, so it is reported complete
291
+ (malformed) too. Iterative, so it never raises `RecursionError` on
292
+ pathologically nested input.
293
+
294
+ Args:
295
+ s: The accumulated, stripped tool-call argument text.
296
+
297
+ Returns:
298
+ `True` if the brackets are matched (or over-closed) and the text does not
299
+ end inside a string; `False` while a container is still open.
300
+ """
301
+ depth = 0
302
+ in_string = False
303
+ escaped = False
304
+ for ch in s:
305
+ if in_string:
306
+ if escaped:
307
+ escaped = False
308
+ elif ch == "\\":
309
+ escaped = True
310
+ elif ch == '"':
311
+ in_string = False
312
+ continue
313
+ if ch == '"':
314
+ in_string = True
315
+ elif ch in "{[":
316
+ depth += 1
317
+ elif ch in "}]":
318
+ depth -= 1
319
+ if depth < 0:
320
+ # More closers than openers: unbalanced, not a partial stream.
321
+ return True
322
+ return depth == 0 and not in_string
323
+
324
+
325
+ @dataclass
326
+ class ToolCallBuffer:
327
+ """In-progress state for a single streamed tool call.
328
+
329
+ `args` and `args_parts` are two representations of the arguments used one at
330
+ a time, depending on whether the provider delivers the value whole or in
331
+ JSON string fragments.
332
+ """
333
+
334
+ name: str | None = None
335
+ """The tool name, once a chunk has supplied it."""
336
+
337
+ tool_id: str | None = None
338
+ """The tool-call id, once a chunk has supplied it."""
339
+
340
+ args: ProviderToolArgs = None
341
+ """A fully materialized arguments value delivered by a single chunk (dict or,
342
+ rarely, a scalar). Mutually exclusive with `args_parts` (see `__post_init__`
343
+ and `ingest`)."""
344
+
345
+ args_parts: list[str] = field(default_factory=list)
346
+ """JSON string fragments accumulated across chunks, reassembled by
347
+ `parse_args` once the payload looks complete. Mutually exclusive with
348
+ `args` (see `__post_init__` and `ingest`)."""
349
+
350
+ displayed: bool = False
351
+ """One-shot latch guarding the single "Calling tool" console line (used only
352
+ by the headless surface)."""
353
+
354
+ warned: bool = False
355
+ """One-shot latch so a malformed-but-complete payload is logged at most once,
356
+ even though `parse_args` re-runs on every later chunk for the retained
357
+ buffer."""
358
+
359
+ def __post_init__(self) -> None:
360
+ """Enforce the `args` XOR `args_parts` invariant at construction time.
361
+
362
+ `ingest` maintains this on every chunk. This guard catches an illegal
363
+ buffer built directly with both fields set; the fields are public and
364
+ mutable, though, so a caller can still reach the both-populated state by
365
+ assigning them after construction. `parse_args` re-checks the invariant
366
+ at read time (raising rather than silently reading `args` first and
367
+ masking a conflicting `args_parts`), so the illegal state fails loudly
368
+ wherever it originates.
369
+
370
+ Raises:
371
+ ValueError: If both `args` and `args_parts` are set.
372
+ """
373
+ if self.args is not None and self.args_parts:
374
+ msg = "ToolCallBuffer cannot hold both args and args_parts"
375
+ raise ValueError(msg)
376
+
377
+ def _reset_for_new_call(self) -> None:
378
+ """Discard retained state before this buffer is reused for another call."""
379
+ self.name = None
380
+ self.tool_id = None
381
+ self.args = None
382
+ self.args_parts = []
383
+ self.displayed = False
384
+ self.warned = False
385
+
386
+ def ingest(
387
+ self,
388
+ *,
389
+ name: str | None,
390
+ tool_id: str | None,
391
+ args: Any, # noqa: ANN401 # provider-shaped tool-call args chunk
392
+ ) -> None:
393
+ """Fold one streamed tool-call chunk's fields into the buffer.
394
+
395
+ A dict `args` replaces any accumulated fragments (the provider delivered
396
+ the whole value at once); a string `args` is appended as a fragment; any
397
+ other non-`None` value is stored as-is.
398
+
399
+ Args:
400
+ name: The tool name from this chunk, if present.
401
+ tool_id: The tool-call id from this chunk, if present.
402
+ args: The `args` field from this chunk (dict, string fragment, or
403
+ other scalar).
404
+ """
405
+ # A differing id on the same buffer key means a *new* call has reused
406
+ # this streaming index. Indices restart per message, so a buffer retained
407
+ # from an earlier message or HITL-resume round (e.g. one whose args never
408
+ # parsed) can collide here. Reset the accumulated arg state so the new
409
+ # call's fragments never append onto the old call's leftover — which would
410
+ # otherwise leave both unparseable and silently drop the new call's
411
+ # `tool.use`. Per-call metadata is reset too, so stale name/display state
412
+ # cannot bleed into the new call if its first chunk only carries the id.
413
+ #
414
+ # Some providers deliver the new call's name/args before its replacement
415
+ # id. In that delayed-id shape, a retained `self.tool_id` is already stale
416
+ # even though this chunk has no id to compare against; clear it before
417
+ # folding the new name/args so parsed args cannot dispatch under the old
418
+ # call id.
419
+ #
420
+ # This assumes the standard LangChain streaming contract: a call's name
421
+ # arrives only on its first chunk, so id-less continuation chunks for the
422
+ # same call carry only args and keep accumulating below. A non-standard
423
+ # provider that *repeats* the name on an id-less continuation chunk would
424
+ # trip `delayed_id_reuses_index` mid-call and reset the accumulated args.
425
+ # That degrades gracefully rather than crashing: the call's `tool.use`
426
+ # (and its parsed args) is lost, but the tool still executes and its
427
+ # `tool.result` still fires via the uncorrelated `{}`-args path. No
428
+ # observed provider (Anthropic/OpenAI) streams that shape.
429
+ new_id_reuses_index = (
430
+ tool_id is not None and self.tool_id is not None and tool_id != self.tool_id
431
+ )
432
+ delayed_id_reuses_index = (
433
+ tool_id is None
434
+ and self.tool_id is not None
435
+ and name is not None
436
+ and self.name is not None
437
+ )
438
+ if new_id_reuses_index or delayed_id_reuses_index:
439
+ self._reset_for_new_call()
440
+
441
+ if name:
442
+ self.name = name
443
+ if tool_id:
444
+ self.tool_id = tool_id
445
+
446
+ # `args` (a whole value) and `args_parts` (JSON fragments) are mutually
447
+ # exclusive; each branch clears the counterpart so the buffer never holds
448
+ # both. Together with the `__post_init__` guard this keeps the invariant
449
+ # true for every buffer, so `parse_args` reads `args` first without
450
+ # masking a conflicting `args_parts` via read order. A provider streams a
451
+ # single call as either whole values or fragments, never a mix, so no
452
+ # real sequence loses data.
453
+ if isinstance(args, dict):
454
+ self.args = args
455
+ self.args_parts = []
456
+ elif isinstance(args, str):
457
+ # Append every non-empty fragment unconditionally. An earlier
458
+ # TUI-only version skipped a fragment equal to the immediately
459
+ # preceding one to dedup accidental redelivery; that guard was
460
+ # dropped because a stream can legitimately emit two identical
461
+ # consecutive deltas (e.g. two `", "` fragments) and skipping one
462
+ # corrupts the reassembled JSON. Standard `stream_mode="messages"`
463
+ # streaming never redelivers a fragment, so unconditional append is
464
+ # both lossless and correct in practice.
465
+ if args:
466
+ self.args = None
467
+ self.args_parts.append(args)
468
+ elif args is not None:
469
+ self.args = args
470
+ self.args_parts = []
471
+
472
+ def parse_args(self) -> dict[str, Any] | None:
473
+ """Return the tool-call args once enough data has arrived, else `None`.
474
+
475
+ A non-object JSON value (a bare scalar or list — rare for tool calls) is
476
+ wrapped as `{"value": ...}` so a caller's `tool_args` is always an
477
+ object.
478
+
479
+ Returns:
480
+ Parsed tool-call arguments, or `None` when the args are not yet
481
+ complete (still streaming), empty, or structurally complete but
482
+ unparseable — malformed or too deeply nested (the
483
+ structurally-complete malformed case is logged once via
484
+ `warned`).
485
+
486
+ Raises:
487
+ ValueError: If both `args` and `args_parts` are populated, violating
488
+ the invariant `ingest`/`__post_init__` maintain. Guarded here so
489
+ a caller that assigned the public fields directly fails loudly
490
+ instead of silently dropping the fragments (read order would
491
+ otherwise return `args` and discard `args_parts`).
492
+ """
493
+ if self.args is not None and self.args_parts:
494
+ msg = "ToolCallBuffer cannot hold both args and args_parts"
495
+ raise ValueError(msg)
496
+ if isinstance(self.args, dict):
497
+ # A whole-value dict delivered by the provider. The `isinstance`
498
+ # narrows `ProviderToolArgs` to the declared `dict[str, Any]` arg
499
+ # type, so this returns without a cast.
500
+ return self.args
501
+ if self.args is not None:
502
+ return {"value": self.args}
503
+
504
+ if not self.args_parts:
505
+ return None
506
+ joined = "".join(self.args_parts)
507
+ stripped = joined.strip()
508
+ if not stripped:
509
+ return None
510
+ # Cheap structural pre-check: bail while a JSON object/array is still
511
+ # open so we don't attempt to parse a partial streamed fragment. A
512
+ # well-formed object's closing brace is always its last char. The
513
+ # `"".join` still runs per fragment (so accumulation stays O(n^2) across
514
+ # the stream); the win is deferring the costlier `json.loads` until the
515
+ # value looks complete, rather than re-parsing the whole prefix on every
516
+ # fragment.
517
+ #
518
+ # A bracketed value with trailing junk after its close (e.g.
519
+ # `{"a": 1} x`) also returns here and is treated as still-streaming, so
520
+ # it never reaches the malformed warning below — the same deliberate
521
+ # trade as the non-bracketed-scalar exclusion documented at the end of
522
+ # this method. Real provider arg streams are pure JSON, so this shape
523
+ # does not occur in practice; if such a call still executes, its
524
+ # `tool.result` logs the correlation miss.
525
+ if stripped[0] in "{[":
526
+ if not stripped.endswith(("}", "]")):
527
+ return None
528
+ if _exceeds_json_container_depth(stripped):
529
+ if not self.warned:
530
+ self.warned = True
531
+ logger.warning(
532
+ "Tool-call args look complete but failed to parse: %r",
533
+ joined[:200],
534
+ )
535
+ return None
536
+ try:
537
+ parsed = json.loads(joined)
538
+ except (json.JSONDecodeError, RecursionError):
539
+ # Args that are structurally balanced (all brackets matched, not
540
+ # ending mid-string) but still fail to parse are malformed, not
541
+ # mid-stream — surface them rather than silently dropping the
542
+ # tool.use hook. `_looks_structurally_complete` does a string-aware
543
+ # balance check rather than the cheaper "starts with {/[ and ends
544
+ # with }/]" heuristic, which false-positives on a normal chunk
545
+ # boundary inside nested args (e.g. `{"edits": [{"a": 1}`) and would
546
+ # warn on a perfectly healthy stream. `RecursionError` is caught
547
+ # alongside the decode error: pathologically nested model output
548
+ # makes `json.loads` exceed the interpreter recursion limit, and that
549
+ # is one malformed call to skip, not a reason to let the exception
550
+ # escape and abort the whole turn. `%r` escapes any control
551
+ # characters in the model-generated fragment. The `warned` latch
552
+ # keeps this to one line per call, since the buffer is retained and
553
+ # parse_args re-runs on every later chunk for the same call.
554
+ if (
555
+ stripped[0] in "{["
556
+ and _looks_structurally_complete(stripped)
557
+ and not self.warned
558
+ ):
559
+ self.warned = True
560
+ logger.warning(
561
+ "Tool-call args look complete but failed to parse: %r",
562
+ joined[:200],
563
+ )
564
+ # A non-bracketed complete-but-malformed value is deliberately not
565
+ # warned here: it is indistinguishable from a still-streaming scalar
566
+ # fragment, so warning would be noisy. If such a call still executes,
567
+ # its `tool.result` logs the correlation miss at info; if it never
568
+ # executes there is no result to audit, so nothing is lost.
569
+ return None
570
+ if not isinstance(parsed, dict):
571
+ return {"value": parsed}
572
+ return parsed
573
+
574
+
575
+ class UnemittedToolCalls(NamedTuple):
576
+ """Counts of buffered tool calls that never emitted a `tool.use`.
577
+
578
+ A `NamedTuple` so callers can still unpack positionally while the field names
579
+ keep the two same-typed slots from being load-bearing at every call site (a
580
+ bare `(int, int)` invites a silent transposition). Both surfaces log the two
581
+ counts as separate diagnostics.
582
+ """
583
+
584
+ unparsed: int
585
+ """Named buffers whose args never parsed."""
586
+
587
+ idless_parsed: int
588
+ """Named buffers whose args parsed but whose `tool_id` stayed `None`."""
589
+
590
+
591
+ def count_unemitted_tool_calls(buffers: Iterable[ToolCallBuffer]) -> UnemittedToolCalls:
592
+ """Classify buffered tool calls that never emitted a `tool.use`.
593
+
594
+ Both surfaces log the same end-of-stream diagnostic for tool calls still in
595
+ their buffer map when the stream ends: those whose args never parsed, and
596
+ those whose args parsed but whose `tool_id` stayed `None` (so `tool.use` was
597
+ gated out). Sharing the classification here keeps the two diagnostics from
598
+ drifting; each surface still emits its own log lines. `parse_args` is safe to
599
+ re-run (idempotent bar its one-shot `warned` latch).
600
+
601
+ Args:
602
+ buffers: The in-progress tool-call buffers remaining at stream end.
603
+
604
+ Returns:
605
+ The `unparsed` and `idless_parsed` counts (see `UnemittedToolCalls`).
606
+ """
607
+ unparsed = 0
608
+ idless_parsed = 0
609
+ for buffer in buffers:
610
+ if buffer.name is None:
611
+ continue
612
+ if buffer.parse_args() is None:
613
+ unparsed += 1
614
+ elif buffer.tool_id is None:
615
+ idless_parsed += 1
616
+ return UnemittedToolCalls(unparsed, idless_parsed)
617
+
618
+
619
+ def build_tool_use_payload(
620
+ tool_name: str, tool_id: str, tool_args: dict[str, Any]
621
+ ) -> ToolUsePayload:
622
+ """Build the `tool.use` hook payload (schema documented in `hooks`).
623
+
624
+ Args:
625
+ tool_name: The tool being invoked.
626
+ tool_id: The tool-call id. Always a real id for an emitted `tool.use`
627
+ (a call with no id never produces one).
628
+ tool_args: The parsed tool-call arguments.
629
+
630
+ Returns:
631
+ The `tool.use` payload dict.
632
+ """
633
+ return {
634
+ "tool_name": tool_name,
635
+ "tool_id": tool_id,
636
+ "tool_args": tool_args,
637
+ }
638
+
639
+
640
+ def build_tool_error_payload(tool_name: str) -> ToolErrorPayload:
641
+ """Build the `tool.error` hook payload (schema documented in `hooks`).
642
+
643
+ Args:
644
+ tool_name: The tool whose call failed or was rejected.
645
+
646
+ Returns:
647
+ The `tool.error` payload dict.
648
+ """
649
+ return {"tool_names": [tool_name]}
650
+
651
+
652
+ def build_tool_result_payload(
653
+ tool_name: str,
654
+ tool_id: str | None,
655
+ tool_args: dict[str, Any],
656
+ tool_status: ToolStatus,
657
+ tool_output: str,
658
+ ) -> ToolResultPayload:
659
+ """Build the `tool.result` hook payload (schema documented in `hooks`).
660
+
661
+ `tool_output` is capped to `HOOK_TOOL_OUTPUT_LIMIT` here so both surfaces
662
+ apply the identical cap regardless of where the raw output originates. When
663
+ the cap fires the value ends with `TOOL_OUTPUT_TRUNCATION_MARKER` (counted
664
+ within the cap, so the result never exceeds the limit) so a consumer can tell
665
+ a capped result from a short one. `tool_args` is intentionally not truncated
666
+ (see `HOOK_TOOL_OUTPUT_LIMIT`).
667
+
668
+ Args:
669
+ tool_name: The tool that produced the result.
670
+ tool_id: The tool-call id, or `None` when it could not be correlated.
671
+ tool_args: The parsed tool-call arguments, or `{}` when uncorrelated.
672
+ tool_status: `"success"` or `"error"`.
673
+ tool_output: The tool's returned content (capped in the payload).
674
+
675
+ Returns:
676
+ The `tool.result` payload dict with `tool_output` capped and marked when
677
+ truncation occurred.
678
+ """
679
+ if len(tool_output) > HOOK_TOOL_OUTPUT_LIMIT:
680
+ # `max(..., 0)` guards a future `HOOK_TOOL_OUTPUT_LIMIT` set below the
681
+ # marker length: a negative `keep` would slice from the end and keep the
682
+ # tail instead of truncating. Positive with today's constants (2000 vs a
683
+ # ~19-char marker); this only future-proofs a constant change.
684
+ keep = max(HOOK_TOOL_OUTPUT_LIMIT - len(TOOL_OUTPUT_TRUNCATION_MARKER), 0)
685
+ capped_output = tool_output[:keep] + TOOL_OUTPUT_TRUNCATION_MARKER
686
+ else:
687
+ capped_output = tool_output
688
+ return {
689
+ "tool_name": tool_name,
690
+ "tool_id": tool_id,
691
+ "tool_args": tool_args,
692
+ "tool_status": tool_status,
693
+ "tool_output": capped_output,
694
+ }