browserwright 0.8.2__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {browserwright-0.8.2/src/browserwright.egg-info → browserwright-0.9.1}/PKG-INFO +2 -1
- {browserwright-0.8.2 → browserwright-0.9.1}/pyproject.toml +27 -1
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/__init__.py +2 -1
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/process.py +183 -31
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/protocol.py +13 -6
- browserwright-0.9.1/src/browserwright/_text.py +195 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/cli.py +164 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/cli.py +25 -2
- browserwright-0.9.1/src/browserwright/daemon/launchagent.py +529 -0
- browserwright-0.9.1/src/browserwright/daemon/restart_guard.py +217 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/extension_upstream.py +12 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/errors.py +28 -0
- browserwright-0.9.1/src/browserwright/repl/_md_convert.py +101 -0
- browserwright-0.9.1/src/browserwright/repl/_md_normalize.py +234 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/_namespace.py +36 -3
- browserwright-0.9.1/src/browserwright/repl/markdown.py +266 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/snapshot.py +43 -38
- browserwright-0.9.1/src/browserwright/repl/vendor/README.md +44 -0
- browserwright-0.9.1/src/browserwright/repl/vendor/readability.js +2812 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_runtime.py +12 -3
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/skill_runtime.md +81 -6
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/version.py +47 -0
- {browserwright-0.8.2 → browserwright-0.9.1/src/browserwright.egg-info}/PKG-INFO +2 -1
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/SOURCES.txt +7 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/requires.txt +1 -0
- browserwright-0.8.2/src/browserwright/daemon/launchagent.py +0 -236
- {browserwright-0.8.2 → browserwright-0.9.1}/LICENSE +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/README.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/setup.cfg +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/__main__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/__main__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/client.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/cdp.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_ipc.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_net.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_rpc.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_stale.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/base.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/cdp.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/extension.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/config.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/doctor.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/errors.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/launch_chrome.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/observability.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/platforms.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/probe.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/relay_status.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/resolver.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/daemon.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/executor_registry.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/facade.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/facade_extension.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/listener.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/proxy.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/relay.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/state.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/status.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/upstream.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/verbs.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/supervise.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/userscripts.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/discovery.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/health.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/install.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_lock.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_md.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_yaml.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/global_mem.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/site_mem.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/mode_b_client.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/output_schema.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/discovery_api.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/http.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/site.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/__init__.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/_smart_goto.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/inline.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/playwright_handle.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_create.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_ctx.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_registry.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/skill_doc.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/task_runner.py +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/dependency_links.txt +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/entry_points.txt +0 -0
- {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -10,6 +10,7 @@ Requires-Dist: pillow==12.2.0
|
|
|
10
10
|
Requires-Dist: httpx>=0.27
|
|
11
11
|
Requires-Dist: playwright>=1.60.0
|
|
12
12
|
Requires-Dist: pyyaml>=6.0.3
|
|
13
|
+
Requires-Dist: html-to-markdown<4,>=3.10.6
|
|
13
14
|
Provides-Extra: ux
|
|
14
15
|
Requires-Dist: rich>=13; extra == "ux"
|
|
15
16
|
Dynamic: license-file
|
|
@@ -15,7 +15,7 @@ name = "browserwright"
|
|
|
15
15
|
# stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
|
|
16
16
|
# it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
|
|
17
17
|
# of that before a tag is ever pushed. See RELEASING.md.
|
|
18
|
-
version = "0.
|
|
18
|
+
version = "0.9.1"
|
|
19
19
|
description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
|
|
20
20
|
requires-python = ">=3.11"
|
|
21
21
|
license = "AGPL-3.0-only"
|
|
@@ -30,6 +30,26 @@ dependencies = [
|
|
|
30
30
|
# daemon-resolved Chrome and does not need Playwright's bundled browser.
|
|
31
31
|
"playwright>=1.60.0",
|
|
32
32
|
"pyyaml>=6.0.3",
|
|
33
|
+
# HTML -> Markdown for the `read_markdown()` content view (ADR-0006/0007).
|
|
34
|
+
# Rust core + PyO3, and it pulls in NOTHING transitively — which is why it
|
|
35
|
+
# beat `markdownify` (bs4 + soupsieve) and `trafilatura` (lxml + a 32 MB
|
|
36
|
+
# babel locale tree) here. It is also the only converter measured that
|
|
37
|
+
# carries `class="language-*"` into a fenced ```lang block and keeps
|
|
38
|
+
# `rowspan` cells, both of which matter on the documentation pages this
|
|
39
|
+
# view exists to read.
|
|
40
|
+
#
|
|
41
|
+
# We deliberately track upstream rather than freeze: the project is running
|
|
42
|
+
# a systematic markdown-fidelity campaign (its own "Phase Z..HH" feat
|
|
43
|
+
# series) that keeps improving exactly what we depend on. The safety net for
|
|
44
|
+
# that is `tests/skill/test_markdown_golden.py` — golden files turn an
|
|
45
|
+
# upstream byte-level output change into a CI failure instead of a silent
|
|
46
|
+
# shift in what every agent reads. ADR-0007 makes those tests a precondition
|
|
47
|
+
# of this `>=`, not an optional extra.
|
|
48
|
+
#
|
|
49
|
+
# The `<4` cap is not conservatism about features: 3.0.0 removed the v1
|
|
50
|
+
# `convert_to_markdown()` entry point outright, so a major bump can break us
|
|
51
|
+
# at import time. Raise it deliberately, with the golden files as the diff.
|
|
52
|
+
"html-to-markdown>=3.10.6,<4",
|
|
33
53
|
]
|
|
34
54
|
|
|
35
55
|
[project.optional-dependencies]
|
|
@@ -58,6 +78,11 @@ browserwright = [
|
|
|
58
78
|
"site_skills_starter/**/*.md",
|
|
59
79
|
"site_skills_starter/**/*.py",
|
|
60
80
|
"skill_runtime.md",
|
|
81
|
+
# Vendored Mozilla Readability, injected as SOURCE TEXT via page.evaluate
|
|
82
|
+
# (add_script_tag is blocked by page CSP; page.evaluate is not). It has to
|
|
83
|
+
# ship as a real file for that reason — see repl/vendor/README.md.
|
|
84
|
+
"repl/vendor/*.js",
|
|
85
|
+
"repl/vendor/*.md",
|
|
61
86
|
]
|
|
62
87
|
|
|
63
88
|
[tool.setuptools.exclude-package-data]
|
|
@@ -69,6 +94,7 @@ asyncio_mode = "auto"
|
|
|
69
94
|
testpaths = ["tests"]
|
|
70
95
|
markers = [
|
|
71
96
|
"real_chrome: end-to-end test that launches a real Chrome + extension (skipped unless explicitly selected)",
|
|
97
|
+
"launchd: loads throwaway LaunchAgents to measure real launchd behaviour; macOS-only, skipped unless explicitly selected (issue #57)",
|
|
72
98
|
]
|
|
73
99
|
|
|
74
100
|
# Lint config. `select` is explicit on purpose: ruff's *default* rule set grew
|
|
@@ -42,6 +42,7 @@ from .errors import ( # noqa: F401
|
|
|
42
42
|
NeedsUserConfirm,
|
|
43
43
|
NetworkError,
|
|
44
44
|
PageLoadFailed,
|
|
45
|
+
UnsupportedContentType,
|
|
45
46
|
)
|
|
46
47
|
from .primitives.discovery_api import ( # noqa: F401
|
|
47
48
|
list_site_skills,
|
|
@@ -69,7 +70,7 @@ EXPORTS = [
|
|
|
69
70
|
# errors
|
|
70
71
|
"BrowserwrightError", "PageLoadFailed", "ElementNotFound", "AuthWall",
|
|
71
72
|
"Captcha", "NetworkError", "DaemonUnavailable", "CDPError",
|
|
72
|
-
"NeedsUserConfirm",
|
|
73
|
+
"NeedsUserConfirm", "UnsupportedContentType",
|
|
73
74
|
]
|
|
74
75
|
|
|
75
76
|
__all__ = EXPORTS
|
|
@@ -40,6 +40,7 @@ from collections.abc import Iterator
|
|
|
40
40
|
from contextlib import contextmanager, redirect_stdout
|
|
41
41
|
from typing import Any
|
|
42
42
|
|
|
43
|
+
from .._text import spill_text, truncate_hard
|
|
43
44
|
from ..errors import BrowserwrightError, serialize
|
|
44
45
|
from .protocol import (
|
|
45
46
|
MAX_TEXT_CHARS,
|
|
@@ -58,6 +59,92 @@ from .protocol import (
|
|
|
58
59
|
_COLD_START_CONNECT_ATTEMPTS = 13
|
|
59
60
|
_COLD_START_CONNECT_BACKOFF_S = 0.75
|
|
60
61
|
|
|
62
|
+
# `_bw_warn` is callable from a loop, so the warnings LIST needs a bound as much
|
|
63
|
+
# as each entry does. The overflow is folded into one final notice rather than
|
|
64
|
+
# dropped silently.
|
|
65
|
+
MAX_WARNINGS = 100
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cut_notice(what: str, path: str | None) -> str:
|
|
69
|
+
"""The out-of-band line that makes a cut recoverable rather than lossy."""
|
|
70
|
+
if path:
|
|
71
|
+
return (
|
|
72
|
+
f"{what} truncated to {MAX_TEXT_CHARS} chars; the full text is at "
|
|
73
|
+
f"{path}"
|
|
74
|
+
)
|
|
75
|
+
return (
|
|
76
|
+
f"{what} truncated to {MAX_TEXT_CHARS} chars; the full text could not "
|
|
77
|
+
f"be written to disk, so re-run producing less output"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cap_text(value: str, *, prefix: str) -> tuple[str, bool, str | None]:
|
|
82
|
+
"""Bound one text channel. Returns (value, was_truncated, full_text_path).
|
|
83
|
+
|
|
84
|
+
Whole-line aware (``truncate_hard``): the transport must never be the layer
|
|
85
|
+
that severs a `[ref=eN]` a producer took care to keep intact, because the
|
|
86
|
+
severed token is not merely corrupt — `[ref=e2` cut from `[ref=e291]` is a
|
|
87
|
+
VALID ref for a different live element, so acting on it fails silently.
|
|
88
|
+
|
|
89
|
+
The bound is about what an agent can READ, so the untruncated value is
|
|
90
|
+
spilled to disk rather than dropped (ADR-0007 §5) and the path is reported
|
|
91
|
+
out-of-band by the caller. ``None`` means the spill itself failed, which
|
|
92
|
+
shortens the result but never fails the call.
|
|
93
|
+
"""
|
|
94
|
+
if len(value) <= MAX_TEXT_CHARS:
|
|
95
|
+
return value, False, None
|
|
96
|
+
return truncate_hard(value, MAX_TEXT_CHARS), True, spill_text(
|
|
97
|
+
value, prefix=prefix
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cap_warnings(warnings: list[str]) -> tuple[list[str], bool]:
|
|
102
|
+
"""Bound both the number of warnings and the length of each one.
|
|
103
|
+
|
|
104
|
+
Deliberately NOT via ``_cap_text``: warnings are the out-of-band METADATA
|
|
105
|
+
channel, not content, so they are truncated but never spilled. Routing them
|
|
106
|
+
through ``_cap_text`` writes one file per over-long warning — which buries
|
|
107
|
+
the very spill paths these lines exist to carry, and accumulates fast
|
|
108
|
+
because a warning loop produces up to MAX_WARNINGS of them per call.
|
|
109
|
+
"""
|
|
110
|
+
capped = [
|
|
111
|
+
truncate_hard(str(w), MAX_TEXT_CHARS) for w in warnings[:MAX_WARNINGS]
|
|
112
|
+
]
|
|
113
|
+
dropped = len(warnings) - len(capped)
|
|
114
|
+
if dropped > 0:
|
|
115
|
+
capped.append(f"… {dropped} further warning(s) suppressed")
|
|
116
|
+
return capped, dropped > 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cap_task_result_json(raw: str | None) -> tuple[str | None, bool]:
|
|
120
|
+
"""Bound the task result JSON WITHOUT ever emitting invalid JSON.
|
|
121
|
+
|
|
122
|
+
Truncating a JSON document yields one that fails
|
|
123
|
+
``ExecuteResponse.from_dict``'s own ``json.loads`` validation, which the
|
|
124
|
+
client reports as a malformed response and answers by REAPING the executor
|
|
125
|
+
— losing the session's persistent ``state`` over an oversized payload, a
|
|
126
|
+
strictly worse outcome than the payload itself. So an over-budget result is
|
|
127
|
+
replaced by a valid marker object carrying the path to the full JSON, which
|
|
128
|
+
a caller that actually wants it can read.
|
|
129
|
+
"""
|
|
130
|
+
if raw is None or len(raw) <= MAX_TEXT_CHARS:
|
|
131
|
+
return raw, False
|
|
132
|
+
path = spill_text(raw, prefix="task-result")
|
|
133
|
+
marker: dict[str, Any] = {
|
|
134
|
+
"_browserwright_truncated": True,
|
|
135
|
+
"original_size": len(raw),
|
|
136
|
+
"full_result_path": path,
|
|
137
|
+
}
|
|
138
|
+
marker["reason"] = (
|
|
139
|
+
f"task result exceeded the {MAX_TEXT_CHARS}-char response bound; "
|
|
140
|
+
+ (
|
|
141
|
+
f"the full JSON is at {path}"
|
|
142
|
+
if path
|
|
143
|
+
else "the full JSON could not be written to disk"
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return json.dumps(marker), True
|
|
147
|
+
|
|
61
148
|
|
|
62
149
|
@contextmanager
|
|
63
150
|
def _request_environment(env: dict[str, str]) -> Iterator[None]:
|
|
@@ -83,11 +170,15 @@ def _request_environment(env: dict[str, str]) -> Iterator[None]:
|
|
|
83
170
|
|
|
84
171
|
|
|
85
172
|
class _LivePageHolder:
|
|
86
|
-
"""Adapts a live Playwright ``page`` to the ``.page`` attribute
|
|
87
|
-
``snapshot.make_snapshot``
|
|
88
|
-
``PlaywrightHandle``). We rebind ``.page``
|
|
89
|
-
session tab (cold-start /
|
|
90
|
-
observes the executor's current live page.
|
|
173
|
+
"""Adapts a live Playwright ``page`` to the ``.page`` attribute the **view**
|
|
174
|
+
factories expect (``snapshot.make_snapshot`` / ``markdown.make_read_markdown``
|
|
175
|
+
were both written against the lazy ``PlaywrightHandle``). We rebind ``.page``
|
|
176
|
+
whenever the worker re-binds the session tab (cold-start / target change /
|
|
177
|
+
future ``reset()``), so every view observes the executor's current live page.
|
|
178
|
+
|
|
179
|
+
One holder serves ALL views on purpose: a second holder is a second thing to
|
|
180
|
+
remember to rebind, which is exactly how ``read_markdown`` shipped bound to a
|
|
181
|
+
handle nobody was updating (issue #59)."""
|
|
91
182
|
|
|
92
183
|
def __init__(self) -> None:
|
|
93
184
|
self.page: Any = None
|
|
@@ -138,7 +229,9 @@ class _Worker:
|
|
|
138
229
|
self._facade_death_handler: Any = None
|
|
139
230
|
# Persistent per-session state, injected by reference each call (Fork 5).
|
|
140
231
|
self._state: dict[str, Any] = {}
|
|
141
|
-
|
|
232
|
+
# One live-page holder shared by every injected view (snapshot /
|
|
233
|
+
# read_markdown). Rebound wherever `self._page` is.
|
|
234
|
+
self._live_page_holder = _LivePageHolder()
|
|
142
235
|
self._snapshot: Any = None
|
|
143
236
|
# Per-call warnings/screenshots the running code can append to via the
|
|
144
237
|
# injected `_bw_warn` / screenshot helpers. Reset at the start of each
|
|
@@ -351,8 +444,14 @@ class _Worker:
|
|
|
351
444
|
self._context = ph.context_for_browser(self._browser)
|
|
352
445
|
self._page = ph.bind_current_page(self._context, sess)
|
|
353
446
|
self._page_target_id = sess.current_target_id
|
|
354
|
-
self.
|
|
355
|
-
self._snapshot = make_snapshot(
|
|
447
|
+
self._live_page_holder.page = self._page
|
|
448
|
+
self._snapshot = make_snapshot(
|
|
449
|
+
self._live_page_holder,
|
|
450
|
+
# Late-bound on purpose: `_call_warnings` is REPLACED each
|
|
451
|
+
# call, so a bound `.append` captured here would append into
|
|
452
|
+
# the previous call's list and the notice would vanish.
|
|
453
|
+
warn=lambda m: self._call_warnings.append(m),
|
|
454
|
+
)
|
|
356
455
|
self._arm_facade_death()
|
|
357
456
|
# Issue #21: the live page must follow the session's current target
|
|
358
457
|
# (``session_runtime.bind_target`` / ``open_session_tab`` /
|
|
@@ -408,8 +507,14 @@ class _Worker:
|
|
|
408
507
|
return
|
|
409
508
|
self._page = page
|
|
410
509
|
self._page_target_id = sess.current_target_id
|
|
411
|
-
self.
|
|
412
|
-
self._snapshot = make_snapshot(
|
|
510
|
+
self._live_page_holder.page = page
|
|
511
|
+
self._snapshot = make_snapshot(
|
|
512
|
+
self._live_page_holder,
|
|
513
|
+
# Late-bound on purpose: `_call_warnings` is REPLACED each
|
|
514
|
+
# call, so a bound `.append` captured here would append into
|
|
515
|
+
# the previous call's list and the notice would vanish.
|
|
516
|
+
warn=lambda m: self._call_warnings.append(m),
|
|
517
|
+
)
|
|
413
518
|
# Same-call visibility: swap `page` inside the RUNNING heredoc's
|
|
414
519
|
# globals so statements after switch_tab() see the new page.
|
|
415
520
|
if self._active_globals is not None:
|
|
@@ -628,23 +733,52 @@ class _Worker:
|
|
|
628
733
|
terminal_reason: str | None = None,
|
|
629
734
|
task_result_json: str | None = None,
|
|
630
735
|
) -> ExecuteResponse:
|
|
631
|
-
"""Assemble the response
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
736
|
+
"""Assemble the response with EVERY text channel bounded, and attach the
|
|
737
|
+
warnings/screenshots collected during the call.
|
|
738
|
+
|
|
739
|
+
All four text channels are capped here, not just ``console``. An
|
|
740
|
+
expression-valued last statement is an entirely ordinary thing for an
|
|
741
|
+
agent to write, and an uncapped ``return_value`` big enough to exceed
|
|
742
|
+
``protocol._MAX_FRAME`` does not merely ship megabytes: the client reads
|
|
743
|
+
it as a transport error and reaps this executor, taking the session's
|
|
744
|
+
live ``page`` and persistent ``state`` with it. Bounding the response by
|
|
745
|
+
construction is what makes that unreachable.
|
|
746
|
+
"""
|
|
747
|
+
console, console_cut, console_path = _cap_text(
|
|
748
|
+
buf.getvalue(), prefix="console"
|
|
749
|
+
)
|
|
750
|
+
rv_cut, rv_path = False, None
|
|
751
|
+
if return_value is not None:
|
|
752
|
+
return_value, rv_cut, rv_path = _cap_text(
|
|
753
|
+
return_value, prefix="return-value"
|
|
754
|
+
)
|
|
755
|
+
task_result_json, task_cut = _cap_task_result_json(task_result_json)
|
|
756
|
+
|
|
757
|
+
raw_warnings = (
|
|
758
|
+
list(self._call_warnings) if warnings is None else list(warnings)
|
|
759
|
+
)
|
|
760
|
+
# The cut is reported out-of-band (ADR-0007 §5): stdout is content,
|
|
761
|
+
# warnings are metadata. Without this the agent sees a short payload and
|
|
762
|
+
# has no way to tell it was ever longer, nor where the rest went.
|
|
763
|
+
if console_cut:
|
|
764
|
+
raw_warnings.append(_cut_notice("output", console_path))
|
|
765
|
+
if rv_cut:
|
|
766
|
+
raw_warnings.append(_cut_notice("[return value]", rv_path))
|
|
767
|
+
if task_cut:
|
|
768
|
+
raw_warnings.append(
|
|
769
|
+
f"task result exceeded the {MAX_TEXT_CHARS}-char response bound "
|
|
770
|
+
f"and was replaced by a marker; use --output json to read it"
|
|
771
|
+
)
|
|
772
|
+
capped_warnings, warn_cut = _cap_warnings(raw_warnings)
|
|
773
|
+
|
|
638
774
|
return ExecuteResponse(
|
|
639
775
|
console=console,
|
|
640
776
|
return_value=return_value,
|
|
641
777
|
error=error,
|
|
642
778
|
exit_code=exit_code,
|
|
643
|
-
warnings=
|
|
644
|
-
list(self._call_warnings) if warnings is None else list(warnings)
|
|
645
|
-
),
|
|
779
|
+
warnings=capped_warnings,
|
|
646
780
|
screenshots=list(self._call_screenshots),
|
|
647
|
-
truncated=
|
|
781
|
+
truncated=console_cut or rv_cut or warn_cut or task_cut,
|
|
648
782
|
terminal_reason=terminal_reason,
|
|
649
783
|
task_result_json=task_result_json,
|
|
650
784
|
)
|
|
@@ -680,25 +814,44 @@ class _Worker:
|
|
|
680
814
|
)
|
|
681
815
|
|
|
682
816
|
def _build_globals(self) -> dict[str, Any]:
|
|
683
|
-
"""The exec namespace: the Phase C surface
|
|
684
|
-
|
|
685
|
-
reference (persists across calls — Fork 5), and ``snapshot`` rebound to
|
|
686
|
-
the live page.
|
|
817
|
+
"""The exec namespace: the Phase C surface with every lazy name replaced
|
|
818
|
+
by this executor's live equivalent.
|
|
687
819
|
|
|
688
820
|
We reuse ``_namespace.build_globals()`` so the agent helper layer +
|
|
689
|
-
EXPORTS stay identical to the heredoc,
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
not the per-call namespace."""
|
|
821
|
+
EXPORTS stay identical to the heredoc, and hand it
|
|
822
|
+
:meth:`_bind_live_surface` as its live-surface hook so the swap lands
|
|
823
|
+
*before* the agent helper layer loads (issue #59)."""
|
|
693
824
|
from ..repl import _namespace
|
|
694
825
|
|
|
695
|
-
|
|
826
|
+
return _namespace.build_globals(self._bind_live_surface)
|
|
827
|
+
|
|
828
|
+
def _bind_live_surface(self, g: dict[str, Any]) -> None:
|
|
829
|
+
"""Replace the lazily-bound namespace entries with the live ones.
|
|
830
|
+
|
|
831
|
+
Everything ``_namespace.build_globals`` binds to its lazy
|
|
832
|
+
``PlaywrightHandle`` must be listed here. Resolving that handle on the
|
|
833
|
+
worker thread re-enters ``sync_playwright()`` inside the executor's
|
|
834
|
+
running asyncio loop and raises "Sync API inside the asyncio loop" —
|
|
835
|
+
which is what shipped for ``read_markdown`` (issue #59). The handle
|
|
836
|
+
itself is dropped: the executor owns teardown, not the per-call
|
|
837
|
+
namespace.
|
|
838
|
+
|
|
839
|
+
Called by ``build_globals`` BEFORE ``_load_agent_helpers``, so the
|
|
840
|
+
helper modules are seeded from the live surface too.
|
|
841
|
+
"""
|
|
842
|
+
from ..repl.markdown import make_read_markdown
|
|
843
|
+
|
|
696
844
|
g.pop("__bw_playwright_handle__", None)
|
|
697
845
|
g["page"] = self._page
|
|
698
846
|
g["context"] = self._context
|
|
699
847
|
g["state"] = self._state # same dict object each call → persistent
|
|
700
848
|
if self._snapshot is not None:
|
|
701
849
|
g["snapshot"] = self._snapshot
|
|
850
|
+
# The other view. Bound to the shared live-page holder (so it follows a
|
|
851
|
+
# mid-call target change exactly like `snapshot`), and handed THIS call's
|
|
852
|
+
# globals so its notes find the `_bw_warn` channel injected just below —
|
|
853
|
+
# `make_read_markdown` reads the namespace lazily for that reason.
|
|
854
|
+
g["read_markdown"] = make_read_markdown(self._live_page_holder, g)
|
|
702
855
|
# Task invocations inside inline code borrow the same resident surface;
|
|
703
856
|
# never let the public task runner allocate a second Playwright handle.
|
|
704
857
|
g["run_task"] = self._run_task_on_live_surface
|
|
@@ -710,7 +863,6 @@ class _Worker:
|
|
|
710
863
|
# `[WARNING]` line or a screenshot path back through the response.
|
|
711
864
|
g["_bw_warn"] = self._call_warnings.append
|
|
712
865
|
g["_bw_record_screenshot"] = self._record_screenshot
|
|
713
|
-
return g
|
|
714
866
|
|
|
715
867
|
def _record_screenshot(self, path: str, **meta: Any) -> str:
|
|
716
868
|
"""Register a screenshot the heredoc captured so its path surfaces in
|
|
@@ -21,6 +21,8 @@ import struct
|
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from .._text import MAX_TEXT_CHARS as _MAX_TEXT_CHARS
|
|
25
|
+
|
|
24
26
|
# Default per-call timeout (ms). Playwriter defaults to 10000ms, but real page
|
|
25
27
|
# ops (cold navigation + network settle) can legitimately take longer, so we
|
|
26
28
|
# pick a more generous default. It is deliberately bounded WELL UNDER any
|
|
@@ -28,11 +30,14 @@ from typing import Any
|
|
|
28
30
|
# so a slow-but-legitimate call never trips idle reclamation mid-flight.
|
|
29
31
|
DEFAULT_TIMEOUT_MS = 90000
|
|
30
32
|
|
|
31
|
-
# Cap on the
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
33
|
+
# Cap on EVERY text channel of the response — console, return value, warnings
|
|
34
|
+
# and the task result JSON — so a runaway print loop, or an equally ordinary
|
|
35
|
+
# expression-valued last statement, can't ship megabytes back to the agent.
|
|
36
|
+
# Re-exported from `_text` because the producers (`repl/snapshot.py`,
|
|
37
|
+
# `repl/markdown.py`) derive their own budgets from it and must not import the
|
|
38
|
+
# transport to do so; `process._finish` applies it via `_text.truncate_hard`,
|
|
39
|
+
# which prefers whole lines and marks the cut when it cannot keep one.
|
|
40
|
+
MAX_TEXT_CHARS = _MAX_TEXT_CHARS
|
|
36
41
|
TERMINAL_DEADLINE_EXCEEDED = "deadline_exceeded"
|
|
37
42
|
TERMINAL_RESET_REQUESTED = "reset_requested"
|
|
38
43
|
TERMINAL_REASONS = frozenset(
|
|
@@ -185,7 +190,9 @@ class ExecuteResponse:
|
|
|
185
190
|
- ``screenshots``: list of ``{"path": str, ...}`` blocks for any image the
|
|
186
191
|
heredoc captured — path-based (the executor and client share a
|
|
187
192
|
filesystem), so the (possibly large) bytes never ride the wire.
|
|
188
|
-
- ``truncated``: True when
|
|
193
|
+
- ``truncated``: True when ANY text channel was capped at
|
|
194
|
+
``MAX_TEXT_CHARS`` — console, return value, warnings or task result.
|
|
195
|
+
Which one it was is said in ``warnings``, the out-of-band channel.
|
|
189
196
|
- ``error``: ``errors.serialize(exc)`` (or None on success), WITH a
|
|
190
197
|
``traceback`` key for generic exceptions so a shipped heredoc surfaces
|
|
191
198
|
the same traceback the in-process path writes.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Text budgets and the two truncators that enforce them.
|
|
2
|
+
|
|
3
|
+
Zero-dependency on purpose: both the executor transport (`_executor/protocol.py`,
|
|
4
|
+
`_executor/process.py`) and the content producers (`repl/snapshot.py`,
|
|
5
|
+
`repl/markdown.py`, `cli.py`) import from here, and the producers must not drag
|
|
6
|
+
Playwright into the transport's import graph.
|
|
7
|
+
|
|
8
|
+
Two truncators, because there are two genuinely different jobs
|
|
9
|
+
--------------------------------------------------------------
|
|
10
|
+
A producer promises **line integrity**: cutting an aria snapshot mid-line yields
|
|
11
|
+
`[ref=e2` where the real token was `[ref=e291]` — a ref that is not merely
|
|
12
|
+
corrupt but *well-formed for a different live element*, so acting on it fails
|
|
13
|
+
silently. Same reason a Markdown cut mid-line yields a broken
|
|
14
|
+
`[text](http://…` link. For a producer, overflowing a soft budget by one line is
|
|
15
|
+
strictly better than emitting a partial ref, so `truncate_lines` keeps the first
|
|
16
|
+
line whole even when it alone exceeds the budget.
|
|
17
|
+
|
|
18
|
+
The transport promises a **bound**. Its whole reason to exist is that a runaway
|
|
19
|
+
`print('x' * 5_000_000)` must not ship megabytes to the agent — and that payload
|
|
20
|
+
is ONE line with no newlines, so the producer's "keep the first line whole"
|
|
21
|
+
fallback would return 5,000,014 characters for a 10,000 budget. A soft
|
|
22
|
+
truncator cannot serve as a hard cap. `truncate_hard` therefore drops whole
|
|
23
|
+
lines wherever it can and cuts mid-line only when a single line still overflows,
|
|
24
|
+
where it says so in the marker so a severed ref is at least VISIBLE rather than
|
|
25
|
+
silent.
|
|
26
|
+
|
|
27
|
+
The invariant this file exists to keep (ADR-0007 §5): no layer may truncate a
|
|
28
|
+
payload at a raw offset once a layer above it has promised line integrity —
|
|
29
|
+
except a hard bound, which must, and must announce it.
|
|
30
|
+
|
|
31
|
+
And the other half of that same ADR heading — **truncation must come with the
|
|
32
|
+
full text**. A bound exists because an agent cannot read 50,000 characters, not
|
|
33
|
+
because those characters should stop existing. So every truncating layer here
|
|
34
|
+
spills the untruncated payload to a file and reports the path out-of-band; what
|
|
35
|
+
the agent sees is shortened, what it can still fetch is not.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import itertools
|
|
41
|
+
import os
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
# The transport's hard bound on any single text channel of an executor response.
|
|
45
|
+
# Mirrors playwriter's ~10000-char truncation. This is the ceiling every other
|
|
46
|
+
# budget in the tree is derived from — see PRODUCER_BUDGET.
|
|
47
|
+
MAX_TEXT_CHARS = 10000
|
|
48
|
+
|
|
49
|
+
# What a single content producer (`snapshot()`, `read_markdown()`) may return by
|
|
50
|
+
# default. Deliberately UNDER MAX_TEXT_CHARS rather than equal to it: a heredoc
|
|
51
|
+
# normally prints other things alongside the payload, and the transport bound
|
|
52
|
+
# applies to the WHOLE console blob, not to one producer's output. The headroom
|
|
53
|
+
# is what keeps a producer's line-integrity promise from being re-cut downstream.
|
|
54
|
+
PRODUCER_BUDGET = 8000
|
|
55
|
+
|
|
56
|
+
TRUNC_MARKER = "… [truncated]"
|
|
57
|
+
# A distinct marker for the last-resort mid-line cut, so a caller (and the agent
|
|
58
|
+
# reading the output) can tell "you are missing whole lines" apart from "the last
|
|
59
|
+
# line you can see is itself incomplete, do not trust a token at its end".
|
|
60
|
+
MID_LINE_MARKER = "… [truncated mid-line]"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# How many spills of one kind one process keeps before dropping its oldest.
|
|
64
|
+
# The executor is long-lived and spills once per over-budget call, so unlike the
|
|
65
|
+
# markdown/screenshot spills this path would otherwise grow without limit for
|
|
66
|
+
# the life of the session. macOS does NOT prune /tmp — verified: files three
|
|
67
|
+
# days old survive there — so nothing else is going to do this for us.
|
|
68
|
+
SPILL_KEEP = 20
|
|
69
|
+
|
|
70
|
+
# Where spills land. A module-level name rather than a literal so the test suite
|
|
71
|
+
# can redirect it to a tmp dir — otherwise running the tests litters the
|
|
72
|
+
# developer's real /tmp, which is how the accumulation this constant limits was
|
|
73
|
+
# noticed in the first place. `/tmp` and not `tempfile.gettempdir()`: on macOS
|
|
74
|
+
# the latter is the per-user `/var/folders/…` dir, whose value comes from the
|
|
75
|
+
# TMPDIR of whichever session started the process, and the executor and the CLI
|
|
76
|
+
# reading its spill paths are two processes that need not share one.
|
|
77
|
+
SPILL_DIR = Path("/tmp")
|
|
78
|
+
|
|
79
|
+
# Monotonic within the process, and deliberately NOT "lowest free index": with
|
|
80
|
+
# pruning in play a recycled index would point an already-issued path at
|
|
81
|
+
# different content, so an agent holding an older path would silently read the
|
|
82
|
+
# wrong payload.
|
|
83
|
+
_spill_seq = itertools.count()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prune_spills(prefix: str) -> None:
|
|
87
|
+
"""Drop this process's oldest ``prefix`` spills, keeping ``SPILL_KEEP``.
|
|
88
|
+
|
|
89
|
+
Scoped to our own pid on purpose: a session must never delete another
|
|
90
|
+
session's, another worktree's, or another user's files out from under them.
|
|
91
|
+
Entirely best-effort — a spill that cannot be pruned is not a failure.
|
|
92
|
+
"""
|
|
93
|
+
mine: list[tuple[float, Path]] = []
|
|
94
|
+
with contextlib.suppress(OSError):
|
|
95
|
+
for p in SPILL_DIR.glob(f"browserwright-{prefix}-{os.getpid()}-*.txt"):
|
|
96
|
+
with contextlib.suppress(OSError):
|
|
97
|
+
mine.append((p.stat().st_mtime, p))
|
|
98
|
+
for _mtime, old in sorted(mine)[:-SPILL_KEEP]:
|
|
99
|
+
with contextlib.suppress(OSError):
|
|
100
|
+
old.unlink()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def spill_text(text: str, *, prefix: str) -> str | None:
|
|
104
|
+
"""Write the UNtruncated ``text`` to /tmp; return its path, or None.
|
|
105
|
+
|
|
106
|
+
The counterpart to the truncators: a bound shortens what the agent reads, it
|
|
107
|
+
must not destroy what the agent could still want. The path rides back
|
|
108
|
+
out-of-band (warnings / stderr) and the payload rides the disk rather than
|
|
109
|
+
the wire — the same reason `screenshots` in the executor protocol is
|
|
110
|
+
path-based, and the same mechanism `repl/markdown.py` already uses for the
|
|
111
|
+
content view.
|
|
112
|
+
|
|
113
|
+
Returns None on any OSError: failing to spill must never turn a merely long
|
|
114
|
+
result into a failed call. The caller says so in its notice instead.
|
|
115
|
+
"""
|
|
116
|
+
while True:
|
|
117
|
+
cand = (
|
|
118
|
+
SPILL_DIR
|
|
119
|
+
/ f"browserwright-{prefix}-{os.getpid()}-{next(_spill_seq)}.txt"
|
|
120
|
+
)
|
|
121
|
+
if not cand.exists():
|
|
122
|
+
break
|
|
123
|
+
try:
|
|
124
|
+
cand.write_text(text, encoding="utf-8")
|
|
125
|
+
except OSError:
|
|
126
|
+
return None
|
|
127
|
+
_prune_spills(prefix)
|
|
128
|
+
return str(cand)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _keep_whole_lines(text: str, budget: int) -> str | None:
|
|
132
|
+
"""Longest whole-line prefix of ``text`` fitting ``budget``, else None.
|
|
133
|
+
|
|
134
|
+
None means not even the first line fits — the case the two callers below
|
|
135
|
+
resolve in opposite directions.
|
|
136
|
+
"""
|
|
137
|
+
lines = text.splitlines()
|
|
138
|
+
kept: list[str] = []
|
|
139
|
+
used = 0
|
|
140
|
+
for ln in lines:
|
|
141
|
+
# +1 for the newline joining this line to the previous body.
|
|
142
|
+
add = len(ln) + (1 if kept else 0)
|
|
143
|
+
if used + add > budget:
|
|
144
|
+
break
|
|
145
|
+
kept.append(ln)
|
|
146
|
+
used += add
|
|
147
|
+
if not kept:
|
|
148
|
+
return None
|
|
149
|
+
return "\n".join(kept)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def truncate_lines(text: str, max_chars: int) -> str:
|
|
153
|
+
"""Cap ``text`` at ``max_chars`` on a LINE boundary, never mid-line.
|
|
154
|
+
|
|
155
|
+
SOFT: if even the first line overflows the budget it is still emitted whole,
|
|
156
|
+
because a partial `[ref=eN]` the agent might act on is worse than
|
|
157
|
+
overflowing a soft cap. Producers want this. The transport must not use it —
|
|
158
|
+
see :func:`truncate_hard`.
|
|
159
|
+
|
|
160
|
+
Callers apply this only when ``len(text) > max_chars``; the marker is
|
|
161
|
+
unconditional so a truncated payload always says so.
|
|
162
|
+
"""
|
|
163
|
+
marker_cost = len(TRUNC_MARKER) + 1 # + the "\n" before the marker
|
|
164
|
+
body = _keep_whole_lines(text, max_chars - marker_cost)
|
|
165
|
+
if body is None:
|
|
166
|
+
# Budget too small for even one line + marker: surface line one whole.
|
|
167
|
+
lines = text.splitlines()
|
|
168
|
+
body = lines[0] if lines else text
|
|
169
|
+
return body + "\n" + TRUNC_MARKER
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def truncate_hard(text: str, budget: int) -> str:
|
|
173
|
+
"""Cap ``text`` at ``budget`` characters — a bound that is never exceeded.
|
|
174
|
+
|
|
175
|
+
Prefers whole lines exactly like :func:`truncate_lines`, so a payload that
|
|
176
|
+
arrives with intact `[ref=eN]` tokens keeps them intact. Falls back to a
|
|
177
|
+
mid-line cut ONLY when a single line still overflows the budget, which is
|
|
178
|
+
the runaway-output case the bound exists for; that fallback is marked with
|
|
179
|
+
``MID_LINE_MARKER`` so the incomplete tail is never mistaken for a whole one.
|
|
180
|
+
|
|
181
|
+
Guarantees ``len(result) <= budget`` for any ``budget >= 0``.
|
|
182
|
+
"""
|
|
183
|
+
if len(text) <= budget:
|
|
184
|
+
return text
|
|
185
|
+
marker_cost = len(TRUNC_MARKER) + 1
|
|
186
|
+
if budget > marker_cost:
|
|
187
|
+
body = _keep_whole_lines(text, budget - marker_cost)
|
|
188
|
+
if body is not None:
|
|
189
|
+
return body + "\n" + TRUNC_MARKER
|
|
190
|
+
# A single line overflows the budget: cut it, and SAY that we cut it.
|
|
191
|
+
mid_cost = len(MID_LINE_MARKER) + 1
|
|
192
|
+
if budget > mid_cost:
|
|
193
|
+
return text[: budget - mid_cost] + "\n" + MID_LINE_MARKER
|
|
194
|
+
# Budget too small to even carry a marker — the bound still wins.
|
|
195
|
+
return text[:budget]
|