bazaar-contrib-server-agent 0.3.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. bazaar_contrib_server_agent/__init__.py +146 -0
  2. bazaar_contrib_server_agent/fleet.py +217 -0
  3. bazaar_contrib_server_agent/models.py +172 -0
  4. bazaar_contrib_server_agent/pages.py +1009 -0
  5. bazaar_contrib_server_agent/py.typed +0 -0
  6. bazaar_contrib_server_agent/resources/locales/en.toml +185 -0
  7. bazaar_contrib_server_agent/resources/locales/zh-CN.toml +185 -0
  8. bazaar_contrib_server_agent/resources/static/agents.css +417 -0
  9. bazaar_contrib_server_agent/resources/static/agents.js +39 -0
  10. bazaar_contrib_server_agent/resources/templates/agents/activity.html +63 -0
  11. bazaar_contrib_server_agent/resources/templates/agents/config.html +11 -0
  12. bazaar_contrib_server_agent/resources/templates/agents/detail.html +45 -0
  13. bazaar_contrib_server_agent/resources/templates/agents/entries.html +29 -0
  14. bazaar_contrib_server_agent/resources/templates/agents/fields.html +44 -0
  15. bazaar_contrib_server_agent/resources/templates/agents/file.html +25 -0
  16. bazaar_contrib_server_agent/resources/templates/agents/form.html +58 -0
  17. bazaar_contrib_server_agent/resources/templates/agents/icons/arrow-left.svg +1 -0
  18. bazaar_contrib_server_agent/resources/templates/agents/icons/bot.svg +1 -0
  19. bazaar_contrib_server_agent/resources/templates/agents/icons/check.svg +1 -0
  20. bazaar_contrib_server_agent/resources/templates/agents/icons/chevron-down.svg +1 -0
  21. bazaar_contrib_server_agent/resources/templates/agents/icons/chevron-right.svg +1 -0
  22. bazaar_contrib_server_agent/resources/templates/agents/icons/copy.svg +1 -0
  23. bazaar_contrib_server_agent/resources/templates/agents/icons/ellipsis.svg +1 -0
  24. bazaar_contrib_server_agent/resources/templates/agents/icons/file-text.svg +1 -0
  25. bazaar_contrib_server_agent/resources/templates/agents/icons/file.svg +1 -0
  26. bazaar_contrib_server_agent/resources/templates/agents/icons/folder.svg +1 -0
  27. bazaar_contrib_server_agent/resources/templates/agents/icons/grip-vertical.svg +1 -0
  28. bazaar_contrib_server_agent/resources/templates/agents/icons/key-round.svg +1 -0
  29. bazaar_contrib_server_agent/resources/templates/agents/icons/monitor.svg +1 -0
  30. bazaar_contrib_server_agent/resources/templates/agents/icons/pencil.svg +1 -0
  31. bazaar_contrib_server_agent/resources/templates/agents/icons/plus.svg +1 -0
  32. bazaar_contrib_server_agent/resources/templates/agents/icons/puzzle.svg +1 -0
  33. bazaar_contrib_server_agent/resources/templates/agents/icons/refresh-cw.svg +1 -0
  34. bazaar_contrib_server_agent/resources/templates/agents/icons/search.svg +1 -0
  35. bazaar_contrib_server_agent/resources/templates/agents/icons/settings.svg +1 -0
  36. bazaar_contrib_server_agent/resources/templates/agents/icons/share-2.svg +1 -0
  37. bazaar_contrib_server_agent/resources/templates/agents/icons/trash-2.svg +1 -0
  38. bazaar_contrib_server_agent/resources/templates/agents/icons/x.svg +1 -0
  39. bazaar_contrib_server_agent/resources/templates/agents/item.html +19 -0
  40. bazaar_contrib_server_agent/resources/templates/agents/list.html +25 -0
  41. bazaar_contrib_server_agent/resources/templates/agents/logos/THIRD_PARTY.md +9 -0
  42. bazaar_contrib_server_agent/resources/templates/agents/logos/claudecode.svg +1 -0
  43. bazaar_contrib_server_agent/resources/templates/agents/logos/codex.svg +1 -0
  44. bazaar_contrib_server_agent/resources/templates/agents/logos/lark.svg +1 -0
  45. bazaar_contrib_server_agent/resources/templates/agents/logos/telegram.svg +1 -0
  46. bazaar_contrib_server_agent/resources/templates/agents/logos/wecom.svg +1 -0
  47. bazaar_contrib_server_agent/resources/templates/agents/macros.html +62 -0
  48. bazaar_contrib_server_agent/resources/templates/agents/menu.html +5 -0
  49. bazaar_contrib_server_agent/resources/templates/agents/new.html +9 -0
  50. bazaar_contrib_server_agent/resources/templates/agents/remove.html +17 -0
  51. bazaar_contrib_server_agent/resources/templates/agents/rows.html +20 -0
  52. bazaar_contrib_server_agent/resources/templates/agents/runtimes.html +22 -0
  53. bazaar_contrib_server_agent/resources/templates/agents/share.html +8 -0
  54. bazaar_contrib_server_agent/resources/templates/agents/side.html +17 -0
  55. bazaar_contrib_server_agent/resources/templates/agents/skills.html +52 -0
  56. bazaar_contrib_server_agent/resources/templates/agents/table.html +14 -0
  57. bazaar_contrib_server_agent/resources/templates/agents/wizard.html +145 -0
  58. bazaar_contrib_server_agent/resources/templates/agents/working.html +22 -0
  59. bazaar_contrib_server_agent/resources/templates/agents/workspace.html +14 -0
  60. bazaar_contrib_server_agent/schema.py +23 -0
  61. bazaar_contrib_server_agent/store.py +323 -0
  62. bazaar_contrib_server_agent-0.3.0rc1.dist-info/METADATA +35 -0
  63. bazaar_contrib_server_agent-0.3.0rc1.dist-info/RECORD +66 -0
  64. bazaar_contrib_server_agent-0.3.0rc1.dist-info/WHEEL +4 -0
  65. bazaar_contrib_server_agent-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. bazaar_contrib_server_agent-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,146 @@
1
+ """The agents of the Bazaar Compute server: every agent on its computers,
2
+ their pages, and who may see and change them."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from contextlib import asynccontextmanager
7
+ lazy import asyncio
8
+ lazy from collections.abc import AsyncIterator
9
+ lazy from importlib.resources import files
10
+ lazy from pathlib import Path
11
+
12
+ from bazaar_contrib_server import (
13
+ SERVER,
14
+ PermissionItem,
15
+ SidebarItem,
16
+ SidebarPanel,
17
+ Topic,
18
+ )
19
+ from bazaar_contrib_server_access import ACCESS, Shared
20
+ from bazaar_contrib_server_computer import (
21
+ COMPUTER_REMOVED,
22
+ COMPUTERS,
23
+ REPORTED,
24
+ ComputerRemoved,
25
+ Reported,
26
+ )
27
+ from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
28
+ from bazaar_core import Context, Contrib, ServiceKey
29
+
30
+ lazy from .fleet import Fleet
31
+ lazy from .pages import CREATE, DELETE, UPDATE, VIEW, Views
32
+ lazy from .schema import MODULE
33
+ lazy from .store import Store
34
+
35
+ __all__ = ["agent"]
36
+
37
+ # the computers' agents, for what the computers report and remove
38
+ _FLEET = ServiceKey["Fleet"]("server.agents.fleet")
39
+
40
+
41
+ @asynccontextmanager
42
+ async def _agent(ctx: Context) -> AsyncIterator[None]:
43
+ server = ctx.accept(SERVER)
44
+ access = ctx.accept(ACCESS)
45
+ computers = ctx.accept(COMPUTERS)
46
+ resources = Path(str(files(__name__))) / "resources"
47
+ icon = await asyncio.to_thread(
48
+ (resources / "templates" / "agents" / "icons" / "bot.svg").read_text,
49
+ encoding="utf-8",
50
+ )
51
+ async with (
52
+ ctx.accept(SERVER_SCHEMA).register(MODULE),
53
+ asyncio.TaskGroup() as tasks,
54
+ ):
55
+ store = Store(ctx.accept(SERVER_DB), access)
56
+ fleet = Fleet(computers, store, tasks)
57
+ pages = await server.pages("agents", __name__)
58
+ views = Views(server, pages, access, computers, store, fleet)
59
+ async with (
60
+ server.module(
61
+ "agents",
62
+ ui=pages.application(views.routes),
63
+ static=resources / "static",
64
+ locales=resources / "locales",
65
+ ),
66
+ server.permission(
67
+ PermissionItem(point=VIEW, description="agents.permission.view")
68
+ ),
69
+ server.permission(
70
+ PermissionItem(point=CREATE, description="agents.permission.create")
71
+ ),
72
+ server.permission(
73
+ PermissionItem(point=UPDATE, description="agents.permission.update")
74
+ ),
75
+ server.permission(
76
+ PermissionItem(point=DELETE, description="agents.permission.delete")
77
+ ),
78
+ server.sidebar(
79
+ SidebarItem(
80
+ text="agents.title",
81
+ icon=icon,
82
+ path="/agents",
83
+ group="server.sidebar.workbench",
84
+ permission=VIEW,
85
+ order=20,
86
+ badge="agents.count",
87
+ )
88
+ ),
89
+ server.panel(
90
+ SidebarPanel(
91
+ key="agents-working",
92
+ src="/ui/agents/working",
93
+ group="server.sidebar.workbench",
94
+ permission=VIEW,
95
+ order=21,
96
+ )
97
+ ),
98
+ server.topic(
99
+ Topic(name="agents.count", fetch=views.counted, permission=VIEW)
100
+ ),
101
+ server.topic(
102
+ Topic(name="agents.list", fetch=views.listed, permission=VIEW)
103
+ ),
104
+ server.topic(
105
+ Topic(name="agents.working", fetch=views.worked, permission=VIEW)
106
+ ),
107
+ server.topic(
108
+ Topic(
109
+ name="agents.agent",
110
+ fetch=views.followed,
111
+ fields=("agent",),
112
+ permission=VIEW,
113
+ )
114
+ ),
115
+ access.shared(
116
+ Shared(
117
+ kind="agent",
118
+ permission=VIEW,
119
+ points=(VIEW, UPDATE, DELETE),
120
+ name=views.name,
121
+ )
122
+ ),
123
+ # who may create agents on a computer is shared with it, as 0.2 did
124
+ access.points("computer", (CREATE,)),
125
+ ):
126
+ ctx.offer(_FLEET, fleet)
127
+ try:
128
+ yield
129
+ finally:
130
+ fleet.close()
131
+
132
+
133
+ def _reported(ctx: Context, value: Reported) -> None:
134
+ ctx.accept(_FLEET).reported(value.computer_id, value.event.event_name)
135
+
136
+
137
+ async def _removed(ctx: Context, value: ComputerRemoved) -> None:
138
+ await ctx.accept(_FLEET).removed(value.computer_id)
139
+
140
+
141
+ agent = Contrib(
142
+ accepts=[SERVER, ACCESS, COMPUTERS, SERVER_DB, SERVER_SCHEMA],
143
+ offers=[_FLEET],
144
+ lifespan=_agent,
145
+ listen={REPORTED: _reported, COMPUTER_REMOVED: _removed},
146
+ )
@@ -0,0 +1,217 @@
1
+ """What each computer's node says about its agents: the list from
2
+ agent-configuration, kept in the store, and their state from
3
+ agent-activity; asked while a page shows the computer once what was said
4
+ is a minute old, the state again as soon as the computer reports a turn or
5
+ a connection, once more when it reports one while an answer is on its way,
6
+ and what a runtime reports kept a minute for each agent and kind."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ lazy import asyncio
12
+ lazy import logging
13
+ lazy from time import time_ns
14
+
15
+ from pydantic import TypeAdapter, ValidationError
16
+ lazy from bazaar_contrib_server_computer import Computers
17
+ lazy from bazaar_contrib_sidecar import RemoteError
18
+
19
+ from .models import Activity, Inspection, Listing, Providers
20
+ lazy from .store import Store
21
+
22
+ # what was said is shown for a minute before the node is asked again
23
+ FRESH_MS = 60_000
24
+ # what a computer reports that changes the state of one of its agents
25
+ CHANGES = frozenset(
26
+ {
27
+ "harness.started",
28
+ "harness.completed",
29
+ "harness.failed",
30
+ "harness.cancelled",
31
+ "channel.connection.connected",
32
+ "channel.connection.failed",
33
+ }
34
+ )
35
+
36
+ _ACTIVITY = TypeAdapter(dict[str, Activity])
37
+
38
+
39
+ def _now() -> int:
40
+ return time_ns() // 1_000_000
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Inspected:
45
+ """What a runtime reported, and when it was asked."""
46
+
47
+ inspection: Inspection
48
+ at_ms: int
49
+
50
+
51
+ class Fleet:
52
+ def __init__(
53
+ self, computers: Computers, store: Store, tasks: asyncio.TaskGroup
54
+ ) -> None:
55
+ self._computers = computers
56
+ self._store = store
57
+ self._tasks = tasks
58
+ self._listed: dict[str, tuple[int, str | None]] = {}
59
+ self._activity: dict[str, tuple[int, dict[str, Activity]]] = {}
60
+ self._inspected: dict[tuple[str, str | None, str], Inspected] = {}
61
+ self._providers: dict[str, tuple[int, Providers]] = {}
62
+ self._asking: dict[tuple[str, str], asyncio.Task[None]] = {}
63
+ # computers that reported a change while their state was being asked
64
+ self._again: set[str] = set()
65
+
66
+ def look(self, computer_id: str, online: bool) -> None:
67
+ """A page shows the computer's agents: one online is asked again
68
+ about what is a minute old."""
69
+ if not online:
70
+ return
71
+ now = _now()
72
+ listed = self._listed.get(computer_id)
73
+ if listed is None or now - listed[0] >= FRESH_MS:
74
+ self._ask(computer_id, "list")
75
+ known = self._activity.get(computer_id)
76
+ if known is None or now - known[0] >= FRESH_MS:
77
+ self._ask(computer_id, "activity")
78
+
79
+ def reported(self, computer_id: str, event_name: str) -> None:
80
+ """A change the computer reports: its state is asked again, and asked
81
+ once more after an answer on its way, which may have been taken
82
+ before the change."""
83
+ if event_name not in CHANGES:
84
+ return
85
+ if (computer_id, "activity") in self._asking:
86
+ self._again.add(computer_id)
87
+ else:
88
+ self._ask(computer_id, "activity")
89
+
90
+ def revision(self, computer_id: str) -> str | None:
91
+ """The revision of the configuration the computer last gave its list
92
+ from, which changes as any of its agents is changed."""
93
+ listed = self._listed.get(computer_id)
94
+ return None if listed is None else listed[1]
95
+
96
+ def activity(self, computer_id: str) -> dict[str, Activity]:
97
+ """The state of the computer's agents as it last said, when it did."""
98
+ known = self._activity.get(computer_id)
99
+ return {} if known is None else known[1]
100
+
101
+ def working(self) -> list[tuple[str, str, Activity]]:
102
+ """The agents at work as their computers last said: their computer,
103
+ their id and how they are."""
104
+ return [
105
+ (computer_id, agent_id, activity)
106
+ for computer_id, (_, agents) in self._activity.items()
107
+ for agent_id, activity in agents.items()
108
+ if activity.working is not None
109
+ ]
110
+
111
+ def said(self) -> dict[str, str]:
112
+ """How each agent is as its computer last said, today's usage with
113
+ it, put shortly enough to tell when it changes."""
114
+ return {
115
+ agent_id: "/".join(
116
+ (
117
+ activity.state,
118
+ "" if activity.working is None else str(activity.working.since_ms),
119
+ *(channel.state for channel in activity.channels),
120
+ *(runtime.state for runtime in activity.runtimes),
121
+ activity.usage.model_dump_json(),
122
+ )
123
+ )
124
+ for _, agents in self._activity.values()
125
+ for agent_id, activity in agents.items()
126
+ }
127
+
128
+ def _ask(self, computer_id: str, what: str) -> None:
129
+ if (computer_id, what) not in self._asking:
130
+ self._asking[computer_id, what] = self._tasks.create_task(
131
+ self._answer(computer_id, what)
132
+ )
133
+
134
+ async def _answer(self, computer_id: str, what: str) -> None:
135
+ try:
136
+ if what == "list":
137
+ await self.listing(computer_id)
138
+ else:
139
+ again = True
140
+ while again:
141
+ self._again.discard(computer_id)
142
+ value = await self._computers.call(
143
+ computer_id, "agent-activity", "agents"
144
+ )
145
+ self._activity[computer_id] = (
146
+ _now(),
147
+ _ACTIVITY.validate_python(value),
148
+ )
149
+ again = computer_id in self._again
150
+ except RemoteError, TimeoutError, ValidationError:
151
+ # a node that does not answer, or answers otherwise, keeps what
152
+ # it said before
153
+ logging.getLogger(__name__).info("agents.unanswered", exc_info=True)
154
+ finally:
155
+ del self._asking[computer_id, what]
156
+
157
+ async def listing(self, computer_id: str) -> Listing:
158
+ """The computer's agents as it gives them now, kept for when it is away."""
159
+ asked = _now()
160
+ listing = Listing.model_validate(
161
+ await self._computers.call(computer_id, "agent-configuration", "list")
162
+ )
163
+ now = _now()
164
+ await self._store.replace(computer_id, listing.agents, asked, now)
165
+ self._listed[computer_id] = (now, listing.revision)
166
+ return listing
167
+
168
+ async def inspect(
169
+ self, computer_id: str, kind: str, agent_id: str | None = None
170
+ ) -> Inspected:
171
+ """What the runtime of the kind reports in the agent's workspace, or
172
+ in an empty one, asked again once a minute old."""
173
+ key = (computer_id, agent_id, kind)
174
+ known = self._inspected.get(key)
175
+ if known is not None and _now() - known.at_ms < FRESH_MS:
176
+ return known
177
+ inspected = Inspected(
178
+ inspection=Inspection.model_validate(
179
+ await self._computers.call(
180
+ computer_id, "agent-configuration", "inspect", kind, agent_id
181
+ )
182
+ ),
183
+ at_ms=_now(),
184
+ )
185
+ self._inspected[key] = inspected
186
+ return inspected
187
+
188
+ async def providers(self, computer_id: str) -> Providers:
189
+ """The channels and runtimes the computer can run, with the schemas
190
+ their forms are drawn from, asked again once a minute old."""
191
+ known = self._providers.get(computer_id)
192
+ if known is not None and _now() - known[0] < FRESH_MS:
193
+ return known[1]
194
+ providers = Providers.model_validate(
195
+ await self._computers.call(computer_id, "agent-configuration", "providers")
196
+ )
197
+ self._providers[computer_id] = (_now(), providers)
198
+ return providers
199
+
200
+ async def removed(self, computer_id: str) -> None:
201
+ """The computer is gone, and its agents with it."""
202
+ now = _now()
203
+ await self._store.replace(computer_id, (), now, now)
204
+ self.forget(computer_id)
205
+
206
+ def forget(self, computer_id: str) -> None:
207
+ self._listed.pop(computer_id, None)
208
+ self._activity.pop(computer_id, None)
209
+ self._providers.pop(computer_id, None)
210
+ self._again.discard(computer_id)
211
+ for key in [key for key in self._inspected if key[0] == computer_id]:
212
+ del self._inspected[key]
213
+
214
+ def close(self) -> None:
215
+ """Stop asking: the server is going down."""
216
+ for task in self._asking.values():
217
+ task.cancel()
@@ -0,0 +1,172 @@
1
+ """What a computer's node answers about its agents, as agent-configuration
2
+ and agent-activity give it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, JsonValue
9
+
10
+
11
+ class _Answer(BaseModel):
12
+ model_config = ConfigDict(frozen=True)
13
+
14
+
15
+ class Provider(_Answer):
16
+ """A channel or runtime of an agent: its values, and the names of the
17
+ credentials it has, never their values."""
18
+
19
+ kind: str
20
+ connection: str
21
+ values: dict[str, JsonValue]
22
+ secrets: tuple[str, ...]
23
+
24
+
25
+ class Sandbox(_Answer):
26
+ policy: Literal["workspace-write", "danger-full-access"] = "workspace-write"
27
+
28
+
29
+ class SessionConfig(_Answer):
30
+ sandbox: Sandbox = Sandbox()
31
+
32
+
33
+ class Session(_Answer):
34
+ config: SessionConfig = SessionConfig()
35
+
36
+
37
+ class AgentView(_Answer):
38
+ id: str
39
+ name: str
40
+ mode: Literal["session", "dangerous_individual"]
41
+ session: Session | None
42
+ idle_timeout: float
43
+ channel: tuple[Provider, ...]
44
+ harness: tuple[Provider, ...]
45
+
46
+
47
+ class Listing(_Answer):
48
+ revision: str | None
49
+ agents: tuple[AgentView, ...]
50
+
51
+
52
+ class Schema(_Answer):
53
+ kind: str
54
+ json_schema: dict[str, JsonValue]
55
+
56
+
57
+ class Providers(_Answer):
58
+ channel: tuple[Schema, ...]
59
+ harness: tuple[Schema, ...]
60
+
61
+
62
+ class Usage(_Answer):
63
+ input: int = 0
64
+ output: int = 0
65
+ cached: int = 0
66
+ cost: float | None = None
67
+
68
+
69
+ class Working(_Answer):
70
+ since_ms: int
71
+ conversation: str | None
72
+
73
+
74
+ class Identity(_Answer):
75
+ id: str
76
+ name: str | None = None
77
+
78
+
79
+ class ChannelStatus(_Answer):
80
+ kind: str
81
+ connection: str
82
+ state: str
83
+ identity: Identity | None
84
+ connected_at_ms: int | None
85
+ received_at_ms: int | None
86
+ failures: int
87
+ error: str | None
88
+
89
+
90
+ class RuntimeStatus(_Answer):
91
+ kind: str
92
+ connection: str
93
+ state: str
94
+ in_use: bool
95
+ banned_until_ms: int | None
96
+
97
+
98
+ class Activity(_Answer):
99
+ state: str
100
+ reason: str | None
101
+ usage: Usage
102
+ event: str | None
103
+ event_at_ms: int | None
104
+ started_at_ms: int | None
105
+ working: Working | None
106
+ channels: tuple[ChannelStatus, ...]
107
+ runtimes: tuple[RuntimeStatus, ...]
108
+
109
+
110
+ class Model(_Answer):
111
+ id: str
112
+ name: str
113
+ efforts: tuple[str, ...] = ()
114
+ default_effort: str | None = None
115
+ default: bool = False
116
+
117
+
118
+ class Skill(_Answer):
119
+ name: str
120
+ description: str
121
+ source: str
122
+
123
+
124
+ class Tool(_Answer):
125
+ name: str
126
+ description: str | None = None
127
+
128
+
129
+ class McpFailed(_Answer):
130
+ """A runtime could not connect to the server, and why."""
131
+
132
+ reason: str
133
+
134
+
135
+ class McpServer(_Answer):
136
+ """An MCP server as a runtime sees it, in the common states; what the
137
+ runtime does not say is left out."""
138
+
139
+ name: str
140
+ connection: Literal["connected", "connecting", "disabled"] | McpFailed | None = None
141
+ auth: Literal["none", "signed_in", "signed_out"] | None = None
142
+ tools: tuple[Tool, ...] = ()
143
+
144
+
145
+ class Inspection(_Answer):
146
+ available: bool
147
+ version: str | None = None
148
+ models: tuple[Model, ...] = ()
149
+ skills: tuple[Skill, ...] = ()
150
+ mcp_servers: tuple[McpServer, ...] = ()
151
+ error: str | None = None
152
+
153
+
154
+ class Entry(_Answer):
155
+ name: str
156
+ directory: bool
157
+ size: int
158
+ modified_at_ms: int
159
+
160
+
161
+ class Directory(_Answer):
162
+ root: str
163
+ path: str
164
+ total: int
165
+ entries: tuple[Entry, ...]
166
+
167
+
168
+ class File(_Answer):
169
+ path: str
170
+ size: int
171
+ modified_at_ms: int
172
+ content: str | None