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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
}
|