lorebase-herald 0.2.3__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.
- lorebase_herald/__init__.py +3 -0
- lorebase_herald/__main__.py +40 -0
- lorebase_herald/errors.py +173 -0
- lorebase_herald/keeper.py +64 -0
- lorebase_herald/prompts.py +45 -0
- lorebase_herald/render.py +82 -0
- lorebase_herald/server.py +661 -0
- lorebase_herald/settings.py +227 -0
- lorebase_herald/tools.py +526 -0
- lorebase_herald/tracing.py +176 -0
- lorebase_herald/views.py +910 -0
- lorebase_herald-0.2.3.dist-info/METADATA +11 -0
- lorebase_herald-0.2.3.dist-info/RECORD +15 -0
- lorebase_herald-0.2.3.dist-info/WHEEL +4 -0
- lorebase_herald-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Run Herald: `lorebase-herald` or `python -m lorebase_herald`.
|
|
2
|
+
|
|
3
|
+
The transport (stdio or Streamable HTTP) and everything else come from the `herald` settings.
|
|
4
|
+
Logs go to stderr: on stdio, stdout carries the MCP protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from lorebase_herald.keeper import KeeperClients
|
|
15
|
+
from lorebase_herald.server import serve
|
|
16
|
+
from lorebase_herald.settings import herald_settings
|
|
17
|
+
from lorebase_platform.logging import configure_logging
|
|
18
|
+
from lorebase_platform.settings import ConfigError, load_settings
|
|
19
|
+
|
|
20
|
+
SERVICE_NAME = "herald"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""Load settings, configure logging and serve until the host disconnects."""
|
|
25
|
+
try:
|
|
26
|
+
settings = load_settings()
|
|
27
|
+
herald = herald_settings(settings)
|
|
28
|
+
except ConfigError as exc:
|
|
29
|
+
sys.exit(str(exc))
|
|
30
|
+
configure_logging(settings.logging, service=SERVICE_NAME, stream=sys.stderr)
|
|
31
|
+
# httpx logs every Keeper request URL at INFO; keep it quiet so record keys stay out of the
|
|
32
|
+
# logs (on stdio they would land in the agent's stderr).
|
|
33
|
+
for name in ("httpx", "httpcore"):
|
|
34
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
35
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
36
|
+
asyncio.run(serve(herald, KeeperClients(herald.keeper)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Client errors mapped to tool errors a model can act on.
|
|
2
|
+
|
|
3
|
+
A tool error is one short text: `[code] what happened` followed by `next:` with the action
|
|
4
|
+
that resolves it. `code` is Keeper's stable problem code (design 11.2: machine-readable code
|
|
5
|
+
and a short human message), or a Herald code for failures before Keeper answers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from lorebase_client import (
|
|
18
|
+
ApiError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
IdempotencyConflictError,
|
|
21
|
+
LorebaseConnectionError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PermissionDeniedError,
|
|
24
|
+
ProtectedBranchError,
|
|
25
|
+
RateLimitedError,
|
|
26
|
+
ReviewRequiredError,
|
|
27
|
+
ServerError,
|
|
28
|
+
StaleHeadError,
|
|
29
|
+
ValidationFailedError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
HintFactory = Callable[[ApiError], str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _stale_head(error: ApiError) -> str:
|
|
36
|
+
current = error.current_head
|
|
37
|
+
return (
|
|
38
|
+
f"the branch head is now {current}; re-read what the change depends on, then retry "
|
|
39
|
+
f"with expected_head={current}"
|
|
40
|
+
if current
|
|
41
|
+
else "re-read the branch head and retry"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _review(error: ApiError) -> str:
|
|
46
|
+
proposals = error.extra.get("proposals")
|
|
47
|
+
where = f" ({proposals})" if proposals else ""
|
|
48
|
+
return (
|
|
49
|
+
"this change needs review: call again with task and intent so that Keeper runs it on "
|
|
50
|
+
"a task branch and opens the Proposal, or run it on a work branch yourself and open a "
|
|
51
|
+
f"Proposal for that branch{where}; do not retry here as it is"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _retry_later(error: ApiError) -> str:
|
|
56
|
+
after = error.retry_after if error.retry_after is not None else error.extra.get("retry_after")
|
|
57
|
+
if after is None:
|
|
58
|
+
return "retry later"
|
|
59
|
+
seconds = f"{after:g}" if isinstance(after, int | float) else after
|
|
60
|
+
return f"retry after {seconds} s"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Codes that no exception class of the client stands for.
|
|
64
|
+
HINTS_BY_CODE: Mapping[str, HintFactory] = {
|
|
65
|
+
"expressions-not-enabled": lambda _: "this Operation cannot run on this deployment",
|
|
66
|
+
"branch-not-materialized": lambda _: "read this branch at a Revision with `at`",
|
|
67
|
+
"limit-exceeded": lambda _: "narrow the request",
|
|
68
|
+
}
|
|
69
|
+
# Most specific class first.
|
|
70
|
+
HINTS_BY_TYPE: tuple[tuple[type[ApiError], HintFactory], ...] = (
|
|
71
|
+
(StaleHeadError, _stale_head),
|
|
72
|
+
(ReviewRequiredError, _review),
|
|
73
|
+
(ProtectedBranchError, _review),
|
|
74
|
+
(IdempotencyConflictError, lambda _: "use a new idempotency_key for different parameters"),
|
|
75
|
+
(ValidationFailedError, lambda _: "fix the listed members and call again"),
|
|
76
|
+
(
|
|
77
|
+
NotFoundError,
|
|
78
|
+
lambda _: "check the ref with lorebase_search or names with lorebase_schema",
|
|
79
|
+
),
|
|
80
|
+
(PermissionDeniedError, lambda _: "this agent's API key lacks the permission; do not retry"),
|
|
81
|
+
(AuthenticationError, lambda _: "the agent's API key is missing, expired or revoked"),
|
|
82
|
+
(RateLimitedError, _retry_later),
|
|
83
|
+
(ServerError, _retry_later),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hint(error: ApiError) -> HintFactory | None:
|
|
88
|
+
by_code = HINTS_BY_CODE.get(error.code or "")
|
|
89
|
+
if by_code is not None:
|
|
90
|
+
return by_code
|
|
91
|
+
return next((hint for kind, hint in HINTS_BY_TYPE if isinstance(error, kind)), None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _issues(extra: Mapping[str, Any], limit: int) -> list[str]:
|
|
95
|
+
"""Validation findings (`issues`) or request member errors (`errors`), one line each."""
|
|
96
|
+
lines: list[str] = []
|
|
97
|
+
found = extra.get("issues") or extra.get("errors") or []
|
|
98
|
+
if not isinstance(found, list):
|
|
99
|
+
return lines
|
|
100
|
+
for issue in found[:limit]:
|
|
101
|
+
if not isinstance(issue, Mapping):
|
|
102
|
+
continue
|
|
103
|
+
where = issue.get("pointer") or "/".join(str(p) for p in issue.get("path") or [])
|
|
104
|
+
code = issue.get("code")
|
|
105
|
+
subject = "/".join(
|
|
106
|
+
str(issue[k]) for k in ("kind", "key", "attribute") if issue.get(k) is not None
|
|
107
|
+
)
|
|
108
|
+
location = " ".join(part for part in (where, f"({subject})" if subject else "") if part)
|
|
109
|
+
suffix = f" [{code}]" if code else ""
|
|
110
|
+
lines.append(f"- {location or '<request>'}: {issue.get('message', '')}{suffix}")
|
|
111
|
+
total = extra.get("issue_count")
|
|
112
|
+
if isinstance(total, int) and total > len(lines):
|
|
113
|
+
lines.append(f"- ... {total - len(lines)} more")
|
|
114
|
+
elif len(found) > limit:
|
|
115
|
+
lines.append(f"- ... {len(found) - limit} more")
|
|
116
|
+
return lines
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tool_error(error: ApiError | LorebaseConnectionError, *, max_issues: int) -> ToolError:
|
|
120
|
+
"""The tool error answering a failed Keeper call."""
|
|
121
|
+
if isinstance(error, LorebaseConnectionError):
|
|
122
|
+
return ToolError(f"[keeper-unavailable] {_unreachable(error)}. next: retry later")
|
|
123
|
+
# A problem always has a code; an answer without one is not Keeper's (a proxy, say) and its
|
|
124
|
+
# body is no message for a model.
|
|
125
|
+
message = (error.detail or error.title) if error.code else error.title
|
|
126
|
+
lines = [f"[{error.code or f'http-{error.status}'}] {message or 'Error'}"]
|
|
127
|
+
lines.extend(_issues(error.extra, max_issues))
|
|
128
|
+
hint = _hint(error)
|
|
129
|
+
if hint is not None:
|
|
130
|
+
lines.append(f"next: {hint(error)}")
|
|
131
|
+
return ToolError("\n".join(lines))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unreachable(error: LorebaseConnectionError) -> str:
|
|
135
|
+
cause = error.__cause__
|
|
136
|
+
if isinstance(cause, httpx.TimeoutException):
|
|
137
|
+
return "Keeper did not answer in time"
|
|
138
|
+
return f"Keeper is unreachable ({type(cause).__name__ if cause else 'no connection'})"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _findings(error: ValidationError, limit: int) -> list[str]:
|
|
142
|
+
found = error.errors(include_url=False, include_context=False, include_input=False)
|
|
143
|
+
lines = [
|
|
144
|
+
f"- {'/'.join(str(p) for p in finding['loc']) or '<request>'}: {finding['msg']} "
|
|
145
|
+
f"[{finding['type']}]"
|
|
146
|
+
for finding in found[:limit]
|
|
147
|
+
]
|
|
148
|
+
if len(found) > limit:
|
|
149
|
+
lines.append(f"- ... {len(found) - limit} more")
|
|
150
|
+
return lines
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def invalid_request(error: ValidationError, *, max_issues: int) -> ToolError:
|
|
154
|
+
"""The tool error for a request the client's models refuse before anything is sent."""
|
|
155
|
+
lines = ["[validation-failed] the request is not valid", *_findings(error, max_issues)]
|
|
156
|
+
lines.append("next: fix the listed members and call again")
|
|
157
|
+
return ToolError("\n".join(lines))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def unexpected_answer(error: ValidationError) -> ToolError:
|
|
161
|
+
"""The tool error for an answer that does not fit the client's response models."""
|
|
162
|
+
first = error.errors(include_url=False, include_context=False, include_input=False)[0]
|
|
163
|
+
where = "/".join(str(p) for p in first["loc"])
|
|
164
|
+
return ToolError(
|
|
165
|
+
f"[keeper-response-invalid] Keeper's answer does not fit this Herald's API model "
|
|
166
|
+
f"({error.error_count()} problems, first at {where}: {first['msg']}).\n"
|
|
167
|
+
"next: use matching Herald and Keeper versions"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def invalid_input(code: str, message: str, hint: str) -> ToolError:
|
|
172
|
+
"""A tool error raised by Herald before calling Keeper."""
|
|
173
|
+
return ToolError(f"[{code}] {message}\nnext: {hint}")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Keeper access for the tools: one typed Lorebase client per request.
|
|
2
|
+
|
|
3
|
+
Herald reaches Lorebase only through Keeper (AC-20), so authorization, masking, audit and rate
|
|
4
|
+
limits are enforced in one place. Every client carries the credential of the calling agent and
|
|
5
|
+
nothing else: no client is shared between requests, so no credential, connection or cached
|
|
6
|
+
answer can cross from one caller to another.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from lorebase_client import AsyncLorebaseClient, RetryPolicy
|
|
17
|
+
from lorebase_herald.settings import KeeperConnection
|
|
18
|
+
from lorebase_herald.tracing import call_headers, stamp_request
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bearer_token(credential: str | None) -> str | None:
|
|
22
|
+
"""The token of a credential: a forwarded `Bearer ...` value or a bare API key."""
|
|
23
|
+
if credential is None:
|
|
24
|
+
return None
|
|
25
|
+
scheme, _, rest = credential.partition(" ")
|
|
26
|
+
return rest.strip() if rest and scheme.lower() == "bearer" else credential
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KeeperClients:
|
|
30
|
+
"""Opens the client of one request. `transport` replaces the network (tests)."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, connection: KeeperConnection, *, transport: httpx.AsyncBaseTransport | None = None
|
|
34
|
+
) -> None:
|
|
35
|
+
self._connection = connection
|
|
36
|
+
self._transport = transport
|
|
37
|
+
|
|
38
|
+
def _transport_for_request(self) -> httpx.AsyncBaseTransport | None:
|
|
39
|
+
if self._transport is None and not self._connection.verify_tls:
|
|
40
|
+
return httpx.AsyncHTTPTransport(verify=False)
|
|
41
|
+
return self._transport
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def open(self, credential: str | None) -> AsyncIterator[AsyncLorebaseClient]:
|
|
45
|
+
"""A client for one request, closed afterwards. With no credential, requests go out
|
|
46
|
+
anonymous: the client reads no key from the environment. It carries the trace headers of
|
|
47
|
+
the tool call being served; each of its requests gets a span of its own (see
|
|
48
|
+
`lorebase_herald.tracing`)."""
|
|
49
|
+
connection = self._connection
|
|
50
|
+
async with AsyncLorebaseClient(
|
|
51
|
+
connection.base_url,
|
|
52
|
+
api_key=bearer_token(credential),
|
|
53
|
+
api_prefix=connection.api_prefix,
|
|
54
|
+
timeout=connection.timeout_s,
|
|
55
|
+
retry=RetryPolicy(
|
|
56
|
+
max_retries=connection.max_retries, backoff_max_s=connection.retry_max_wait_s
|
|
57
|
+
),
|
|
58
|
+
headers={"User-Agent": connection.user_agent, **(call_headers.get() or {})},
|
|
59
|
+
transport=self._transport_for_request(),
|
|
60
|
+
event_hooks={"request": [stamp_request]},
|
|
61
|
+
trust_env=connection.trust_env,
|
|
62
|
+
env={},
|
|
63
|
+
) as client:
|
|
64
|
+
yield client
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Server instructions and the `lorebase_guide` prompt template (no model calls)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
INSTRUCTIONS = """\
|
|
6
|
+
Lorebase is a versioned knowledge graph: typed Records (refs `Kind/key`) linked by Relations,
|
|
7
|
+
on branches of immutable Revisions. Workflow:
|
|
8
|
+
1. Orient: lorebase_context (records or a question) or lorebase_schema.
|
|
9
|
+
2. Read narrowly: lorebase_get, lorebase_query, lorebase_search, lorebase_neighborhood;
|
|
10
|
+
lorebase_explain / lorebase_lineage for where a value came from; lorebase_history for a
|
|
11
|
+
Record's changes; lorebase_changes / lorebase_diff for history.
|
|
12
|
+
3. Write only with lorebase_execute: dry_run first (the default), then dry_run=false with
|
|
13
|
+
expected_head. Some Operations need review: pass task and intent and Keeper opens the
|
|
14
|
+
Proposal on a task branch for you.
|
|
15
|
+
Outputs carry the Revision they were read at and say when they were cut."""
|
|
16
|
+
|
|
17
|
+
GUIDE = """\
|
|
18
|
+
You are working with the Lorebase Domain {domain}.
|
|
19
|
+
|
|
20
|
+
How to use Lorebase:
|
|
21
|
+
- Records are addressed as `Kind/key` (for example `Supplier/S-1001`); use api names exactly as
|
|
22
|
+
lorebase_schema prints them.
|
|
23
|
+
- Start with lorebase_context: pass the Records the task is about, or a short question; it
|
|
24
|
+
returns those Records, their links, nearby Records and the schema of the Kinds involved,
|
|
25
|
+
within a token budget.
|
|
26
|
+
- Then read only what you need: lorebase_get for one Record, lorebase_query for filtered lists
|
|
27
|
+
(JSON DSL), lorebase_search to find Records by words, lorebase_neighborhood for links.
|
|
28
|
+
- Facts carry provenance: lorebase_explain shows who or what set each value and when.
|
|
29
|
+
- History: lorebase_changes lists Revisions since one you saw; lorebase_diff compares two.
|
|
30
|
+
- Writes go only through governed Operations with lorebase_execute. Always dry-run first
|
|
31
|
+
(dry_run is true by default), check edits and violations, then commit with dry_run=false and
|
|
32
|
+
expected_head set to the head the dry run reported. A `review-required` or
|
|
33
|
+
`protected-branch` error means the change must go through a Proposal: repeat the call with
|
|
34
|
+
`task` and `intent` and send an `idempotency_key`; Keeper opens the Proposal on a task branch.
|
|
35
|
+
- Errors start with `[code]` and end with `next:`; follow that step instead of retrying blindly.
|
|
36
|
+
- Outputs are cut at a size limit and say so; narrow the request or follow `next_cursor`.
|
|
37
|
+
{task}"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def guide(domain: str | None, task: str | None) -> str:
|
|
41
|
+
"""The guide text for a Domain and, optionally, the task at hand."""
|
|
42
|
+
return GUIDE.format(
|
|
43
|
+
domain=domain or "configured for this server",
|
|
44
|
+
task=f"\nTask: {task}" if task else "",
|
|
45
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Rendering helpers shared by the tools: compact values, list caps and size caps.
|
|
2
|
+
|
|
3
|
+
Text output is written for a model's context window: one fact per line, `Kind/key` refs,
|
|
4
|
+
no decoration. Every cut is visible: lists end with `... N more` and over-long outputs end
|
|
5
|
+
with a `[truncated ...]` marker, never a silent cut.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import TypeAdapter
|
|
16
|
+
|
|
17
|
+
from lorebase_herald.settings import OutputSettings
|
|
18
|
+
|
|
19
|
+
ELLIPSIS = "…"
|
|
20
|
+
_TIMESTAMP = TypeAdapter(datetime)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ref(kind: str, key: str) -> str:
|
|
24
|
+
"""The `Kind/key` reference of a Record."""
|
|
25
|
+
return f"{kind}/{key}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def as_json(value: Any) -> str:
|
|
29
|
+
"""Compact JSON (no spaces, UTF-8 kept)."""
|
|
30
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def timestamp(value: datetime) -> str:
|
|
34
|
+
"""A timestamp as the API writes it: RFC 3339, `Z` for UTC."""
|
|
35
|
+
return str(_TIMESTAMP.dump_python(value, mode="json"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def value_text(value: Any, max_chars: int) -> str:
|
|
39
|
+
"""A value on one line: strings as they are, other values as compact JSON."""
|
|
40
|
+
text = value if isinstance(value, str) else as_json(value)
|
|
41
|
+
text = text.replace("\n", " ")
|
|
42
|
+
return text if len(text) <= max_chars else text[: max_chars - 1] + ELLIPSIS
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def values_text(values: Mapping[str, Any], max_chars: int, separator: str = "; ") -> str:
|
|
46
|
+
"""`name=value` pairs on one line."""
|
|
47
|
+
return separator.join(f"{k}={value_text(v, max_chars)}" for k, v in values.items())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def titled(reference: str, title: str | None) -> str:
|
|
51
|
+
return f'{reference} "{title}"' if title else reference
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def capped[T](items: Sequence[T], limit: int) -> tuple[list[T], int]:
|
|
55
|
+
"""The first `limit` items and how many were left out."""
|
|
56
|
+
return list(items[:limit]), max(0, len(items) - limit)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def more_line(omitted: int, hint: str = "") -> list[str]:
|
|
60
|
+
"""`... N more` when items were left out."""
|
|
61
|
+
if omitted <= 0:
|
|
62
|
+
return []
|
|
63
|
+
return [f"... {omitted} more{f' ({hint})' if hint else ''}"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def finish(text: str, output: OutputSettings) -> str:
|
|
67
|
+
"""Cut an output longer than `max_chars` at a line break, with a marker saying so."""
|
|
68
|
+
if len(text) <= output.max_chars:
|
|
69
|
+
return text
|
|
70
|
+
budget = output.max_chars - 120
|
|
71
|
+
cut = text.rfind("\n", 0, budget)
|
|
72
|
+
cut = budget if cut <= 0 else cut
|
|
73
|
+
omitted = len(text) - cut
|
|
74
|
+
return (
|
|
75
|
+
text[:cut] + f"\n[truncated: {omitted} more chars; narrow the request, lower `limit` "
|
|
76
|
+
"or follow `next_cursor`]"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render(view: Mapping[str, Any], text: str, fmt: str, output: OutputSettings) -> str:
|
|
81
|
+
"""The final tool output: compact JSON of `view` or `text`, within the size cap."""
|
|
82
|
+
return finish(as_json(view) if fmt == "json" else text, output)
|