computer-agent-py 0.1.0__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.
@@ -0,0 +1,442 @@
1
+ """``AgentRegistrySink`` — write Mongo docs that AgentOS reads.
2
+
3
+ Mirrors the TS ``@open-gitagent/agent-registry-mongo`` collections:
4
+
5
+ * ``agent_registry`` — one doc per agent name, idempotent upsert on each
6
+ ``session_started``. First write sets ``registeredAt``; every subsequent
7
+ write refreshes ``updatedAt`` + ``lastSeen``.
8
+ * ``agent_logs`` — one doc per run (``session_ended``), with truncated
9
+ ``query`` / ``reply``, token + cost rollups.
10
+ * ``sessions`` — one doc per session with an ``entries[]`` array of
11
+ ``{type, text}`` chat-bubble messages. Mirrors the harness-owned shape so
12
+ the AgentOS SPA ``Chat`` tab can render Python-driven sessions out of the
13
+ box. ``user_message`` and ``assistant_message`` events are appended;
14
+ tool_use / tool_result / usage_snapshot / policy_decision are skipped
15
+ (they have no home in the ``{type, text}`` schema and are instead
16
+ captured by ``MongoMessageSink`` in ``agent_messages``). Opt out with
17
+ ``AgentRegistrySink(write_sessions=False)``.
18
+
19
+ Truncation limits match TS exactly (``QUERY_MAX=8000``, ``REPLY_MAX=16000``)
20
+ so docs written from either runtime have the same shape. Uses only
21
+ DocumentDB-compatible operators (``$set``, ``$setOnInsert``, ``$push``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import logging
28
+ import os
29
+ import socket
30
+ import uuid
31
+ from datetime import datetime, timezone
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ try:
35
+ from motor.motor_asyncio import AsyncIOMotorClient
36
+ except ImportError as _exc: # pragma: no cover - guarded by lazy export
37
+ raise ImportError(
38
+ "AgentRegistrySink requires the [agentos] extra. "
39
+ "Install with: pip install 'computer-agent-py[agentos]'"
40
+ ) from _exc
41
+
42
+ if TYPE_CHECKING:
43
+ from ..event import TelemetryEvent
44
+
45
+ logger = logging.getLogger("computeragent.telemetry")
46
+
47
+
48
+ QUERY_MAX = 8_000
49
+ REPLY_MAX = 16_000
50
+
51
+
52
+ class AgentRegistrySink:
53
+ """Write ``agent_registry`` + ``agent_logs`` docs.
54
+
55
+ Parameters mirror the TS package's options. ``mongo_url`` accepts standard
56
+ MongoDB URIs (``mongodb://...`` or ``mongodb+srv://...``).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ mongo_url: str | None = None,
63
+ database: str | None = None,
64
+ registry_collection: str = "agent_registry",
65
+ logs_collection: str = "agent_logs",
66
+ sessions_collection: str = "sessions",
67
+ threads_collection: str = "slack_threads",
68
+ client: Any | None = None,
69
+ source: str = "library",
70
+ registered_by: str | None = None,
71
+ write_sessions: bool = True,
72
+ ) -> None:
73
+ self._mongo_url = mongo_url or os.environ.get("AGENTOS_MONGO_URL")
74
+ self._database = database or os.environ.get("AGENTOS_MONGO_DB", "agentos")
75
+ if not self._mongo_url and client is None:
76
+ raise ValueError(
77
+ "AgentRegistrySink requires mongo_url= (or AGENTOS_MONGO_URL env) "
78
+ "or a client= argument"
79
+ )
80
+ self._client: Any = client or AsyncIOMotorClient(self._mongo_url)
81
+ self._db = self._client[self._database]
82
+ self._registry = self._db[registry_collection]
83
+ self._logs = self._db[logs_collection]
84
+ self._sessions = self._db[sessions_collection]
85
+ # `slack_threads` is the TS aggregation table — agents.ts derives
86
+ # sessionCount + lastActivity from it. One row per session.
87
+ self._threads = self._db[threads_collection]
88
+ self._source = source
89
+ self._registered_by = registered_by or socket.gethostname()
90
+ self._write_sessions = write_sessions
91
+
92
+ # session_id → started timestamp + prompt (so session_ended can fill query/duration).
93
+ self._session_meta: dict[str, dict[str, Any]] = {}
94
+ # Sessions we've already opened in Mongo this process — avoids redundant upserts.
95
+ self._sessions_opened: set[str] = set()
96
+
97
+ # ── lifecycle ----------------------------------------------------------
98
+
99
+ async def emit(self, event: TelemetryEvent) -> None:
100
+ if event.kind == "session_started":
101
+ await self._upsert_registry(event)
102
+ self._session_meta[event.session_id] = {
103
+ "started_at": event.timestamp,
104
+ "agent_name": event.agent_name,
105
+ "prompt": event.payload.get("prompt") or "",
106
+ "model": event.payload.get("model"),
107
+ }
108
+ if self._write_sessions:
109
+ # Track whether _open_session actually created the doc this
110
+ # call (first time we see this session id in-process) so we
111
+ # only seed the first user entry once per session.
112
+ first_open = event.session_id not in self._sessions_opened
113
+ await self._open_session(event)
114
+ await self._open_thread(event)
115
+ if first_open:
116
+ # Seed the first user entry from the prompt the agent was
117
+ # constructed with — matches what a human reading the
118
+ # SPA Chat tab expects to see as turn 1.
119
+ prompt_text = str(event.payload.get("prompt") or "")
120
+ if prompt_text:
121
+ await self._append_to_session(
122
+ event.session_id, {"type": "user", "text": prompt_text}
123
+ )
124
+ elif event.kind == "session_ended":
125
+ await self._insert_log(event)
126
+ if self._write_sessions:
127
+ await self._close_session(event)
128
+ await self._close_thread(event)
129
+ self._session_meta.pop(event.session_id, None)
130
+ self._sessions_opened.discard(event.session_id)
131
+ elif self._write_sessions and event.kind == "assistant_message":
132
+ text = str(event.payload.get("text") or "")
133
+ if text:
134
+ await self._append_to_session(event.session_id, {"type": "assistant", "text": text})
135
+ elif self._write_sessions and event.kind == "user_message":
136
+ text = str(event.payload.get("text") or "")
137
+ if text:
138
+ await self._append_to_session(event.session_id, {"type": "user", "text": text})
139
+
140
+ async def flush(self, timeout: float = 5.0) -> None:
141
+ # motor flushes on each operation; nothing to do.
142
+ return
143
+
144
+ async def aclose(self) -> None:
145
+ try:
146
+ self._client.close()
147
+ except Exception: # noqa: BLE001
148
+ logger.debug("motor client close failed", exc_info=True)
149
+
150
+ # ── writes -------------------------------------------------------------
151
+
152
+ async def _upsert_registry(self, event: TelemetryEvent) -> None:
153
+ name = event.agent_name or _agent_name_from_event(event)
154
+ now = _utcnow()
155
+ await self._registry.update_one(
156
+ {"_id": name},
157
+ {
158
+ "$setOnInsert": {
159
+ "_id": name,
160
+ "registeredAt": now,
161
+ },
162
+ "$set": {
163
+ "harness": "claude-agent-sdk",
164
+ "source": _inline_source_for(name, event.payload),
165
+ "model": event.payload.get("model"),
166
+ "registeredBy": self._registered_by,
167
+ "updatedAt": now,
168
+ "lastSeen": now,
169
+ },
170
+ },
171
+ upsert=True,
172
+ )
173
+
174
+ async def _insert_log(self, event: TelemetryEvent) -> None:
175
+ meta = self._session_meta.get(event.session_id, {})
176
+ usage = event.payload.get("usage") or {}
177
+ agent_name = event.agent_name or meta.get("agent_name") or _agent_name_from_event(event)
178
+ doc = {
179
+ "_id": "log_" + uuid.uuid4().hex,
180
+ "ts": _utcnow(),
181
+ "source": self._source,
182
+ # TS agentos-server (agent-log-store.ts) keys per-agent counts on
183
+ # the field `bot` — a holdover from when only Slack bots wrote
184
+ # rows. Write both so the existing UI's per-agent logCount
185
+ # aggregation lights up for Python-driven runs.
186
+ "bot": agent_name,
187
+ "agentName": agent_name,
188
+ "requester": self._registered_by or None,
189
+ "channel": None,
190
+ "threadTs": None,
191
+ "sessionId": event.session_id,
192
+ "query": _truncate(meta.get("prompt", ""), QUERY_MAX),
193
+ "reply": _truncate(str(event.payload.get("result", "") or ""), REPLY_MAX),
194
+ "ok": not event.payload.get("is_error", False),
195
+ }
196
+ if event.payload.get("is_error"):
197
+ doc["error"] = str(
198
+ event.payload.get("error_message") or event.payload.get("subtype") or ""
199
+ )
200
+ duration_ms = event.payload.get("duration_ms")
201
+ if duration_ms is not None:
202
+ doc["durationMs"] = int(duration_ms)
203
+ for src_key, doc_key in (
204
+ ("input_tokens", "inputTokens"),
205
+ ("output_tokens", "outputTokens"),
206
+ ):
207
+ v = usage.get(src_key)
208
+ if isinstance(v, int):
209
+ doc[doc_key] = v
210
+ cost = event.payload.get("total_cost_usd")
211
+ if cost is not None:
212
+ try:
213
+ doc["costUsd"] = float(cost)
214
+ except (TypeError, ValueError):
215
+ doc["costUsd"] = None
216
+ await self._logs.insert_one(doc)
217
+
218
+ # ── sessions (Chat tab) writes ────────────────────────────────────────
219
+
220
+ async def _open_session(self, event: TelemetryEvent) -> None:
221
+ """Idempotent upsert of the per-session doc. The AgentOS SPA's Chat
222
+ tab reads ``sessions.entries[]`` (normalized by ``sessions.ts``).
223
+
224
+ Uses ``$setOnInsert`` so re-runs that reuse a session id don't reset
225
+ the ``createdAt`` field, while ``$set`` keeps ``updatedAt`` fresh.
226
+ """
227
+ if event.session_id in self._sessions_opened:
228
+ return
229
+ agent_name = event.agent_name or self._session_meta.get(event.session_id, {}).get(
230
+ "agent_name"
231
+ )
232
+ now = _utcnow()
233
+ await self._sessions.update_one(
234
+ {"_id": event.session_id},
235
+ {
236
+ "$setOnInsert": {
237
+ "_id": event.session_id,
238
+ "createdAt": now,
239
+ "entries": [],
240
+ },
241
+ "$set": {
242
+ "agentName": agent_name,
243
+ "source": self._source,
244
+ "model": (
245
+ event.payload.get("model")
246
+ or self._session_meta.get(event.session_id, {}).get("model")
247
+ ),
248
+ "updatedAt": now,
249
+ },
250
+ },
251
+ upsert=True,
252
+ )
253
+ self._sessions_opened.add(event.session_id)
254
+
255
+ async def _append_to_session(self, session_id: str, entry: dict[str, Any]) -> None:
256
+ # Cap individual entry text to REPLY_MAX so a single huge message
257
+ # can't push the doc over Mongo's 16MB ceiling.
258
+ text = entry.get("text")
259
+ if isinstance(text, str):
260
+ entry = {**entry, "text": _truncate(text, REPLY_MAX)}
261
+ await self._sessions.update_one(
262
+ {"_id": session_id},
263
+ {
264
+ "$push": {"entries": entry},
265
+ "$set": {"updatedAt": _utcnow()},
266
+ },
267
+ upsert=True,
268
+ )
269
+
270
+ async def _open_thread(self, event: TelemetryEvent) -> None:
271
+ """Idempotent upsert of a ``slack_threads`` row.
272
+
273
+ ``agentos-server/src/routes/agents.ts`` derives per-agent
274
+ ``sessionCount`` + ``lastActivity`` from this collection (a holdover
275
+ from when only Slack bots wrote rows). One row per session.
276
+ """
277
+ agent_name = event.agent_name or self._session_meta.get(event.session_id, {}).get(
278
+ "agent_name"
279
+ )
280
+ if not agent_name:
281
+ return
282
+ now = _utcnow()
283
+ await self._threads.update_one(
284
+ {"_id": f"thread_{self._source}_{event.session_id}"},
285
+ {
286
+ "$setOnInsert": {
287
+ "_id": f"thread_{self._source}_{event.session_id}",
288
+ "createdAt": now,
289
+ },
290
+ "$set": {
291
+ "bot": agent_name,
292
+ "channel": self._source,
293
+ "threadTs": "",
294
+ "sessionId": event.session_id,
295
+ "sandboxId": None,
296
+ "snapshotId": None,
297
+ "lastMessageAt": now,
298
+ },
299
+ },
300
+ upsert=True,
301
+ )
302
+
303
+ async def _close_thread(self, event: TelemetryEvent) -> None:
304
+ """Stamp ``lastMessageAt`` on session end so the agent's
305
+ ``lastActivity`` reflects the latest run."""
306
+ await self._threads.update_one(
307
+ {"_id": f"thread_{self._source}_{event.session_id}"},
308
+ {"$set": {"lastMessageAt": _utcnow()}},
309
+ )
310
+
311
+ async def _close_session(self, event: TelemetryEvent) -> None:
312
+ """Stamp end-of-session metadata. Doesn't append to ``entries``."""
313
+ now = _utcnow()
314
+ update: dict[str, Any] = {
315
+ "endedAt": now,
316
+ "updatedAt": now,
317
+ "ok": not event.payload.get("is_error", False),
318
+ }
319
+ duration_ms = event.payload.get("duration_ms")
320
+ if duration_ms is not None:
321
+ with contextlib.suppress(TypeError, ValueError):
322
+ update["durationMs"] = int(duration_ms)
323
+ cost = event.payload.get("total_cost_usd")
324
+ if cost is not None:
325
+ with contextlib.suppress(TypeError, ValueError):
326
+ update["costUsd"] = float(cost)
327
+ await self._sessions.update_one(
328
+ {"_id": event.session_id},
329
+ {"$set": update},
330
+ upsert=True,
331
+ )
332
+
333
+
334
+ # ── helpers ────────────────────────────────────────────────────────────────
335
+
336
+
337
+ def _utcnow() -> datetime:
338
+ return datetime.now(timezone.utc)
339
+
340
+
341
+ def _truncate(s: str, n: int) -> str:
342
+ if not isinstance(s, str):
343
+ s = str(s)
344
+ return s if len(s) <= n else s[:n]
345
+
346
+
347
+ def _agent_name_from_event(event: TelemetryEvent) -> str:
348
+ """Fallback agent name when none was supplied — first 32 chars of a hash."""
349
+ import hashlib
350
+
351
+ prompt = event.payload.get("prompt", "") if isinstance(event.payload, dict) else ""
352
+ model = event.payload.get("model", "") if isinstance(event.payload, dict) else ""
353
+ h = hashlib.sha256(f"{model}|{prompt}".encode()).hexdigest()[:12]
354
+ return f"anonymous-{h}"
355
+
356
+
357
+ DEFAULT_AGENT_INSTRUCTIONS = (
358
+ "You are a helpful assistant. Answer the user's question concisely and accurately."
359
+ )
360
+
361
+
362
+ def _inline_source_for(name: str, options_payload: dict[str, Any]) -> dict[str, Any]:
363
+ """Build a complete inline ``IdentitySource`` the harness can clone into a
364
+ sandbox workdir for live chat.
365
+
366
+ Captures the agent's ``ClaudeAgentOptions`` (system_prompt, model,
367
+ allowed_tools, max_turns, permission_mode) as ``agent.yaml`` plus a
368
+ ``CLAUDE.md`` so the harness's inline loader can spin up a fresh sandbox
369
+ when the AgentOS SPA's "New Chat" button fires — matching the way
370
+ hosted (git-sourced) agents are already supported.
371
+
372
+ MCP server configs and the Python-side ``cwd`` are deliberately omitted —
373
+ they don't translate across processes/filesystems. Library-mode agents
374
+ can use built-in tools (Read, Glob, Grep) in live chat; custom MCP
375
+ servers stay Python-only.
376
+ """
377
+ model = options_payload.get("model")
378
+ system_prompt = options_payload.get("system_prompt") or DEFAULT_AGENT_INSTRUCTIONS
379
+ allowed_tools = options_payload.get("allowed_tools") or ["Read", "Glob", "Grep"]
380
+ max_turns = options_payload.get("max_turns")
381
+ permission_mode = options_payload.get("permission_mode") or "bypassPermissions"
382
+
383
+ agent_yaml = _build_agent_yaml(
384
+ name=name,
385
+ model=model,
386
+ allowed_tools=allowed_tools,
387
+ max_turns=max_turns,
388
+ permission_mode=permission_mode,
389
+ )
390
+
391
+ return {
392
+ "type": "inline",
393
+ "manifest": {"name": name, "version": "0.1.0"},
394
+ "model": model,
395
+ "files": {
396
+ "agent.yaml": agent_yaml,
397
+ "CLAUDE.md": system_prompt,
398
+ },
399
+ }
400
+
401
+
402
+ def _build_agent_yaml(
403
+ *,
404
+ name: str,
405
+ model: str | None,
406
+ allowed_tools: list[str],
407
+ max_turns: int | None,
408
+ permission_mode: str,
409
+ ) -> str:
410
+ """Render the agent.yaml the harness's inline loader expects.
411
+
412
+ Minimal viable shape — name + version + model + a runtime block with the
413
+ allowed-tools and turn limit. Matches the pattern NordAssist's worker
414
+ uses in `_build_identity()` for its fallback inline source.
415
+ """
416
+ safe_name = _yaml_str(name)
417
+ lines = [
418
+ 'spec_version: "0.1.0"',
419
+ f"name: {safe_name}",
420
+ "version: 0.1.0",
421
+ ]
422
+ if model:
423
+ lines += [
424
+ "model:",
425
+ f" preferred: {_yaml_str(model)}",
426
+ ]
427
+ lines += ["runtime:"]
428
+ if max_turns is not None:
429
+ lines.append(f" max_turns: {int(max_turns)}")
430
+ lines.append(f" permission_mode: {_yaml_str(permission_mode)}")
431
+ if allowed_tools:
432
+ lines.append(" allowed_tools:")
433
+ for t in allowed_tools:
434
+ lines.append(f" - {_yaml_str(t)}")
435
+ return "\n".join(lines) + "\n"
436
+
437
+
438
+ def _yaml_str(s: str) -> str:
439
+ """Single-line scalar safe for YAML. Quotes everything to avoid the
440
+ headache of indicator characters (``:``, ``-``, ``*``, ``#``, …) in
441
+ agent names / system prompts."""
442
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
@@ -0,0 +1,136 @@
1
+ """``MongoMessageSink`` — per-message archive in Mongo.
2
+
3
+ Writes one document per non-rollup telemetry event into the ``agent_messages``
4
+ collection (parallel to ``agent_registry`` + ``agent_logs`` written by
5
+ :class:`AgentRegistrySink`). Sits in the ``[agentos]`` extra alongside
6
+ ``motor``, so installing AgentOS support enables both sinks.
7
+
8
+ Each document carries:
9
+
10
+ * ``sessionId`` / ``agentName`` — primary indexes for transcript queries
11
+ * ``kind`` — the :class:`EventKind` (``user_message``, ``assistant_message``,
12
+ ``tool_use``, ``tool_result``, ``usage_snapshot``, ``policy_decision``,
13
+ ``system_message``)
14
+ * ``payload`` — the post-middleware payload (PII-redacted by default; that's
15
+ the standard pipeline ordering — middleware runs before sinks)
16
+ * ``ts`` — emission time
17
+ * ``latencyMs`` — only present on ``policy_decision`` today
18
+
19
+ ``session_started`` and ``session_ended`` are skipped: those rollup events
20
+ already land in ``agent_logs`` via :class:`AgentRegistrySink`. Writing them
21
+ here too would just duplicate the registry contract.
22
+
23
+ Use case: per-session transcript replay in the AgentOS frontend, RAG ingestion
24
+ over historical agent runs, audit / forensics — all of which need the full
25
+ message stream rather than the session-rollup view in ``agent_logs``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import os
32
+ import socket
33
+ import uuid
34
+ from datetime import datetime, timezone
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ try:
38
+ from motor.motor_asyncio import AsyncIOMotorClient
39
+ except ImportError as _exc: # pragma: no cover - guarded by lazy export
40
+ raise ImportError(
41
+ "MongoMessageSink requires the [agentos] extra. "
42
+ "Install with: pip install 'computer-agent-py[agentos]'"
43
+ ) from _exc
44
+
45
+ if TYPE_CHECKING:
46
+ from ..event import TelemetryEvent
47
+
48
+ logger = logging.getLogger("computeragent.telemetry")
49
+
50
+ # Per-field truncation caps. Mongo's hard ceiling is 16 MB per doc; these
51
+ # defaults keep us well clear while still letting most real prompts /
52
+ # responses land verbatim.
53
+ DEFAULT_MAX_FIELD_CHARS = 32_000
54
+
55
+ # Kinds that the AgentRegistrySink already covers via agent_logs.
56
+ _ROLLUP_KINDS = {"session_started", "session_ended"}
57
+
58
+
59
+ class MongoMessageSink:
60
+ """Per-message Mongo archive. Pairs with :class:`AgentRegistrySink`."""
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ mongo_url: str | None = None,
66
+ database: str | None = None,
67
+ collection: str = "agent_messages",
68
+ client: Any | None = None,
69
+ max_field_chars: int = DEFAULT_MAX_FIELD_CHARS,
70
+ source: str = "library",
71
+ host: str | None = None,
72
+ ) -> None:
73
+ self._mongo_url = mongo_url or os.environ.get("AGENTOS_MONGO_URL")
74
+ self._database = database or os.environ.get("AGENTOS_MONGO_DB", "agentos")
75
+ if not self._mongo_url and client is None:
76
+ raise ValueError(
77
+ "MongoMessageSink requires mongo_url= (or AGENTOS_MONGO_URL env) "
78
+ "or a client= argument"
79
+ )
80
+ self._client: Any = client or AsyncIOMotorClient(self._mongo_url)
81
+ self._coll = self._client[self._database][collection]
82
+ self._max_field_chars = max_field_chars
83
+ self._source = source
84
+ self._host = host or socket.gethostname()
85
+
86
+ async def emit(self, event: TelemetryEvent) -> None:
87
+ if event.kind in _ROLLUP_KINDS:
88
+ return # AgentRegistrySink owns these
89
+ doc = {
90
+ "_id": "msg_" + uuid.uuid4().hex,
91
+ "ts": event.timestamp or _utcnow(),
92
+ "source": self._source,
93
+ "host": self._host,
94
+ "sessionId": event.session_id,
95
+ "agentName": event.agent_name,
96
+ "kind": event.kind,
97
+ "payload": _truncate(event.payload, self._max_field_chars),
98
+ }
99
+ # Surface latency at the top level for the few event kinds that carry
100
+ # it — makes per-message latency dashboards trivial in Mongo / Atlas.
101
+ latency = (event.payload or {}).get("latency_ms")
102
+ if isinstance(latency, (int, float)):
103
+ doc["latencyMs"] = float(latency)
104
+ await self._coll.insert_one(doc)
105
+
106
+ async def flush(self, timeout: float = 5.0) -> None:
107
+ # motor flushes per operation; nothing to do.
108
+ return
109
+
110
+ async def aclose(self) -> None:
111
+ try:
112
+ self._client.close()
113
+ except Exception: # noqa: BLE001
114
+ logger.debug("motor client close failed", exc_info=True)
115
+
116
+
117
+ # ── helpers ────────────────────────────────────────────────────────────────
118
+
119
+
120
+ def _utcnow() -> datetime:
121
+ return datetime.now(timezone.utc)
122
+
123
+
124
+ def _truncate(value: object, limit: int) -> object:
125
+ """Walk a payload, truncating any string longer than `limit` chars.
126
+
127
+ Mirrors GuardrailFilter's truncation shape — string + a footer noting how
128
+ many chars were dropped, so consumers see what was elided.
129
+ """
130
+ if isinstance(value, dict):
131
+ return {k: _truncate(v, limit) for k, v in value.items()}
132
+ if isinstance(value, list):
133
+ return [_truncate(v, limit) for v in value]
134
+ if isinstance(value, str) and len(value) > limit:
135
+ return value[:limit] + f"…(+{len(value) - limit} chars)"
136
+ return value