zettcode-engine 0.3.4__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 (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,269 @@
1
+ """What the reader can change about how the agent runs: model, reasoning effort, shell permission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+
9
+ from zettcode.config import NO_MODEL_REFUSAL, ModelConfig, _read_models
10
+ from zettcode.config_edit import (
11
+ ConfigEditError,
12
+ add_model,
13
+ file_entries,
14
+ remove_model,
15
+ replace_model,
16
+ set_self_compaction,
17
+ write_text_atomically,
18
+ )
19
+ from zettcode.engine.permission import PERMISSION_LEVELS, Permission
20
+ from zettcode.paths import config_path
21
+
22
+ from .base import AgentBase
23
+
24
+
25
+ class SettingsMixin(AgentBase):
26
+ """Read and change the model, the reasoning effort, and how much of the shell may run unasked."""
27
+
28
+ @property
29
+ def models(self) -> tuple[ModelConfig, ...]:
30
+ """Return the models available for selection."""
31
+ return self.runtime.config.models
32
+
33
+ @property
34
+ def active_model(self) -> ModelConfig | None:
35
+ """Return the model selected for the next turn, or ``None`` when this machine has none configured."""
36
+ return self.runtime.active_model
37
+
38
+ def find_model(self, name: str) -> ModelConfig | None:
39
+ """Return the configured model a recorded name stands for, if exactly one does."""
40
+ return self.runtime.find_model(name)
41
+
42
+ def use_model(self, name: str | ModelConfig) -> ModelConfig:
43
+ """Select a configured model for subsequent turns."""
44
+ return self.runtime.use_model(name)
45
+
46
+ # -- the models in the config file --------------------------------------------------
47
+ @property
48
+ def config_file(self) -> Path:
49
+ """Return the file the models are read from and written to."""
50
+ return config_path()
51
+
52
+ def _read_config_file(self, *, allow_missing: bool = False) -> str:
53
+ """Return the config file's text, or raise :class:`ConfigEditError` saying why not.
54
+
55
+ Args:
56
+ allow_missing: Answer an empty file when there is none. Only what sets a machine up asks for that —
57
+ the first model, and the list that shows there is nothing to list — because a fresh machine has
58
+ no config file at all. Every other edit treats a missing file as the reader's file that vanished
59
+ and says so, rather than writing a new one that would hold none of what theirs did.
60
+ """
61
+ path = self.config_file
62
+ try:
63
+ return path.read_text(encoding="utf-8")
64
+ except FileNotFoundError as error:
65
+ if allow_missing:
66
+ return ""
67
+ raise ConfigEditError(f"{path} does not exist") from error
68
+ except (OSError, UnicodeDecodeError) as error:
69
+ raise ConfigEditError(f"{path} cannot be read: {error}") from error
70
+
71
+ def file_models(self) -> tuple[tuple[ModelConfig, Mapping[str, object]], ...]:
72
+ """Return each model in the file with the keys the file writes for it.
73
+
74
+ The file is the source of truth for editing, not the models the session started with: a
75
+ reader may have changed it by hand since. The keys are what is *written* — a token left to
76
+ an environment variable is absent — which is what a rewrite has to start from.
77
+
78
+ Raises:
79
+ ConfigEditError: When the file cannot be read, is not valid, or lays its models out in
80
+ a way the editor cannot safely change.
81
+ """
82
+ text = self._read_config_file(allow_missing=not self.models)
83
+ path = self.config_file
84
+ entries = file_entries(text, where=path)
85
+ try:
86
+ # A file that lists none is a list with nothing in it, not a refusal: `/config` is how the first
87
+ # one is written, so the panel it opens must be able to show an empty file.
88
+ models = _read_models(entries, path, required=False)
89
+ except ValueError as error:
90
+ raise ConfigEditError(str(error)) from error
91
+ return tuple(zip(models, entries, strict=True))
92
+
93
+ def _commit_models(self, text: str) -> ModelConfig:
94
+ """Write the edited text, then make the running session use what it now says.
95
+
96
+ The file is written first and the session is changed second, so a write that fails leaves
97
+ the session as it was, and what the session holds is always something the file holds too.
98
+ """
99
+ path = self.config_file
100
+ try:
101
+ write_text_atomically(path, text)
102
+ except OSError as error:
103
+ raise ConfigEditError(f"{path} could not be written: {error}") from error
104
+ models = _read_models(file_entries(text, where=path), path)
105
+ return self.runtime.replace_models(models)
106
+
107
+ def add_configured_model(self, entry: Mapping[str, object]) -> ModelConfig:
108
+ """Add one ``[[models]]`` entry to the file and to this session; return the new model.
109
+
110
+ The model in use does not change: adding is not choosing. The new one appears in ``/model``. The exception
111
+ is the first model a machine has — there is nothing to add it beside — so that one becomes the model in
112
+ use, and it is chosen here rather than by the file's order: another process may have written an entry
113
+ since this session started, and ``/config`` announces the entry it just wrote, not that one.
114
+
115
+ Args:
116
+ entry: The keys of the new model as the file writes them.
117
+
118
+ Raises:
119
+ ConfigEditError: When the entry is not a model that can be called, or the file cannot
120
+ be edited. Nothing is written then.
121
+ """
122
+ text = add_model(self._read_config_file(allow_missing=not self.models), entry, where=self.config_file)
123
+ first = self.runtime.active_model is None
124
+ self._commit_models(text)
125
+ model = self.runtime.config.models[-1]
126
+ return self.runtime.use_model(model) if first else model
127
+
128
+ def update_configured_model(self, index: int, entry: Mapping[str, object]) -> ModelConfig:
129
+ """Rewrite model number ``index`` (from zero) of the file as ``entry``; return the result."""
130
+ text = replace_model(self._read_config_file(), index, entry, where=self.config_file)
131
+ self._commit_models(text)
132
+ return self.runtime.config.models[index]
133
+
134
+ def change_compact_percent(self, percent: float) -> tuple[ModelConfig, str | None]:
135
+ """Compact the model in use at ``percent`` of its window from the next request, and write it to the config file.
136
+
137
+ The running session is changed whatever becomes of the file, since the reader asked for it now: a file
138
+ that cannot be edited (no file, a layout the editor refuses) leaves the change for this run only, and the
139
+ reason is returned beside the model so it can be said.
140
+
141
+ Args:
142
+ percent: Share of the window, more than 0 and at most 100.
143
+
144
+ Returns:
145
+ The model now in use, and why the file was not changed (``None`` when it was).
146
+
147
+ Raises:
148
+ ValueError: ``percent`` is not between 0 and 100, or no model is configured to compact.
149
+ """
150
+ if not 0 < percent <= 100:
151
+ raise ValueError("The compression share must be more than 0 and at most 100.")
152
+ if self.runtime.active_model is None:
153
+ raise ValueError(NO_MODEL_REFUSAL)
154
+ active = self.runtime.required_model
155
+ value = int(percent) if float(percent).is_integer() else percent
156
+ try:
157
+ for index, (model, entry) in enumerate(self.file_models()):
158
+ if model == active:
159
+ return self.update_configured_model(index, {**entry, "compact_percent": value}), None
160
+ reason = "the model in use is not in the config file"
161
+ except ConfigEditError as error:
162
+ reason = str(error)
163
+ edited = replace(active, compact_percent=float(percent))
164
+ models = tuple(edited if model is active else model for model in self.models)
165
+ return self.runtime.replace_models(models), reason
166
+
167
+ def remove_configured_model(self, index: int) -> ModelConfig:
168
+ """Remove model number ``index`` (from zero) from the file and this session.
169
+
170
+ Returns:
171
+ The model in use afterwards: the one before unless it was the one removed.
172
+
173
+ Raises:
174
+ ConfigEditError: When it is the only model, or the file cannot be edited.
175
+ """
176
+ text = remove_model(self._read_config_file(), index, where=self.config_file)
177
+ return self._commit_models(text)
178
+
179
+ @property
180
+ def self_compaction(self) -> bool:
181
+ """Return whether the model may request compaction in this session right now."""
182
+ return self.runtime.self_compacting(self.session_id)
183
+
184
+ @property
185
+ def self_compaction_choice(self) -> bool | None:
186
+ """Return this session's own choice, or ``None`` when it follows the default."""
187
+ return self.runtime.self_compaction_choice(self.session_id)
188
+
189
+ @property
190
+ def self_compaction_default(self) -> bool:
191
+ """Return what a session that has made no choice does: ``[compaction] self`` in the config."""
192
+ return self.runtime.config.self_compaction
193
+
194
+ def use_self_compaction(self, choice: bool | None) -> None:
195
+ """Set this session's own choice, or give it back with ``None`` so it follows the default.
196
+
197
+ Raises:
198
+ SessionLocked: Another process has the session open; the choice is part of the session.
199
+ ConfigEditError: The choice could not be saved. Nothing is reported as saved then.
200
+ """
201
+ self.require_session()
202
+ try:
203
+ self.runtime.choose_self_compaction(self.session_id, choice)
204
+ except OSError as error:
205
+ raise ConfigEditError(f"Self-compaction setting was not saved: {error}") from error
206
+
207
+ def remember_self_compaction(self, enabled: bool) -> Path:
208
+ """Write ``[compaction] self = enabled`` into the config file: the default for every session.
209
+
210
+ The file is written first and the running configuration changed second, so a write that fails
211
+ leaves both as they were. A session with a choice of its own keeps it; one without follows this at
212
+ once. A config file that does not exist is not created for it, as for the theme.
213
+
214
+ Returns:
215
+ The file written.
216
+
217
+ Raises:
218
+ ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot
219
+ be written.
220
+ """
221
+ text = set_self_compaction(self._read_config_file(), enabled, where=self.config_file)
222
+ try:
223
+ write_text_atomically(self.config_file, text)
224
+ except OSError as error:
225
+ raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
226
+ self.runtime.config = replace(self.runtime.config, self_compaction=enabled)
227
+ return self.config_file
228
+
229
+ @property
230
+ def effort(self) -> str:
231
+ """Return the reasoning effort the next request will ask for."""
232
+ return self.runtime.effort.value
233
+
234
+ @property
235
+ def efforts(self) -> tuple[str, ...]:
236
+ """Return the reasoning levels the active model takes, cheapest first; none when no model is configured."""
237
+ model = self.runtime.active_model
238
+ return () if model is None else tuple(level.value for level in model.efforts)
239
+
240
+ def use_effort(self, name: str) -> str:
241
+ """Select a reasoning level for later requests and return its value."""
242
+ return self.runtime.use_effort(name).value
243
+
244
+ def approve_all_shell_commands(self) -> None:
245
+ """Approve every shell command for the rest of this run, without asking."""
246
+ self.runtime.approve_all_shell_commands()
247
+
248
+ @property
249
+ def permission(self) -> Permission:
250
+ """Return how much of the shell may run without asking."""
251
+ return self.runtime.permission
252
+
253
+ @property
254
+ def permissions(self) -> tuple[str, ...]:
255
+ """Return the permission levels, strictest first."""
256
+ return tuple(level.value for level in PERMISSION_LEVELS)
257
+
258
+ def use_permission(self, name: str | Permission, *, remember: bool = True) -> Permission:
259
+ """Set how much of the shell may run without asking, and by default remember it for this session."""
260
+ return self.runtime.use_permission(name, remember=remember)
261
+
262
+ @property
263
+ def harness(self) -> bool:
264
+ """Return whether the just-in-time harness is on for the next request."""
265
+ return self.runtime.harness.on
266
+
267
+ def use_harness(self, on: bool) -> bool:
268
+ """Turn the just-in-time harness on or off for later requests, for the rest of this run."""
269
+ return self.runtime.use_harness(on)
@@ -0,0 +1,308 @@
1
+ """Running a turn: building the message, streaming the agent's events, and steering one in flight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator, AsyncIterator, Sequence
6
+ from contextlib import aclosing
7
+ from dataclasses import replace
8
+ from typing import cast
9
+
10
+ from zett_agent.agent import AgentRunConfig
11
+ from zett_agent.dispatcher import AgentEventDispatcher
12
+ from zett_agent.events import AgentEvent
13
+ from zett_agent.exceptions import AgentResumeError
14
+ from zett_agent.extensions.external import ExternalEvent
15
+ from zett_agent.extensions.steering import STEERING_MESSAGE_EVENT_NAME
16
+ from zett_agent.messages import ImageBytesSource, ImageContent, TextContent, UserMessage
17
+ from zett_agent.model import CacheMode
18
+ from zett_agent.resume import ResumePoint
19
+
20
+ from zettcode.engine.dialogue.side import ORIGIN, READER, SIDE, WATCHED, question, watched_by
21
+ from zettcode.prompt import MentionPart, PromptPart, carries_image, model_text_part, plain_text
22
+ from zettcode.relay import Relayed
23
+
24
+ from .base import AgentBase
25
+
26
+
27
+ def cache_mode_of(value: CacheMode | str | None, *, side: bool) -> CacheMode:
28
+ """Return the cache mode one run carries, checking a word the caller wrote.
29
+
30
+ A turn of the conversation may extend the provider's cached prefix: the next turn reads it, which is what
31
+ caching is for. A side question is not replayed, so its own content is not worth writing into the cache; it
32
+ starts at ``READ``, which still reuses the conversation's warm prefix and marks only the stable parts.
33
+
34
+ Raises:
35
+ ValueError: ``value`` is not one of ``off``, ``read`` and ``read_write``.
36
+ """
37
+ if value is None:
38
+ return CacheMode.READ if side else CacheMode.READ_WRITE
39
+ try:
40
+ return CacheMode(value)
41
+ except ValueError:
42
+ raise ValueError(f"cache must be off, read or read_write, not {value!r}") from None
43
+
44
+
45
+ class TurnsMixin(AgentBase):
46
+ """Turn the reader's parts into a request, stream what comes back, and steer it."""
47
+
48
+ def set_event_dispatcher(self, dispatcher: AgentEventDispatcher) -> None:
49
+ """Send streamed agent events to the application's transcript projector."""
50
+ self.runtime.set_event_dispatcher(dispatcher)
51
+
52
+ async def stream(
53
+ self,
54
+ parts: Sequence[PromptPart],
55
+ *,
56
+ hint: str | None = None,
57
+ side: bool = False,
58
+ origin: str = READER,
59
+ watched: bool | None = None,
60
+ relayed: Relayed | None = None,
61
+ cache: CacheMode | str | None = None,
62
+ ) -> AsyncIterator[AgentEvent]:
63
+ """Run one turn with the selected session, model, and reasoning effort.
64
+
65
+ Args:
66
+ parts: The user turn in the order it was written: text runs and the
67
+ images the composer interleaved with them. A turn without
68
+ images is simply one text part.
69
+ hint: Text appended after the turn: the instructions a ``@``
70
+ reference contributes, then anything the caller added (the SDK
71
+ merges them). The text the user typed then rides along as the
72
+ message's ``prompt`` attribute, so the stored message is what
73
+ was typed and a restored session shows it.
74
+ side: Ask without letting the exchange join the conversation; see
75
+ :mod:`zettcode.engine.dialogue.side`.
76
+ relayed: The turn is a message another session sent. It is what the model reads
77
+ (with where it came from), and ``parts`` and ``hint`` are not used.
78
+ cache: How this request may use the provider's prompt cache: ``"read_write"`` (the default) extends
79
+ it, ``"read"`` reuses a warm prefix without marking this request's new content for caching, and
80
+ ``"off"`` sends no cache directive at all. A side question starts at ``"read"`` unless told
81
+ otherwise, because its answer is never replayed.
82
+ origin: Who asked a side question — ``"reader"`` for the reader's ``/btw``, or the name of the plugin
83
+ asking for itself. It is stored with the exchange, and only a reader's own is restored into a transcript
84
+ by default.
85
+ watched: Whether a reopened transcript restores this exchange, overriding what ``origin`` implies. It does
86
+ not make a front end draw it now. Ignored when ``side`` is false.
87
+
88
+ Raises:
89
+ SessionLocked: Another process has the session open. Checked before anything else, and a side
90
+ question is no exception: the store records it even though it never replays it.
91
+ """
92
+ self.require_session()
93
+ await self.runtime.start()
94
+ client = self.runtime.started
95
+ used = self.runtime.required_model.shown_name
96
+ session_id = self.session_id
97
+ try:
98
+ # Closed here, not left to the garbage collector: a consumer that stops early (a `break`, `aclose()`)
99
+ # throws GeneratorExit in at the `yield`, and the library's stream must unwind then, or the agent is
100
+ # left in its "generating" phase and refuses the next request.
101
+ async with aclosing(
102
+ cast(
103
+ "AsyncGenerator[AgentEvent]",
104
+ client.stream(
105
+ self.request(parts, hint=hint, side=side, origin=origin, watched=watched)
106
+ if relayed is None
107
+ else relayed.message(),
108
+ config=AgentRunConfig(
109
+ session_id=session_id,
110
+ cache_mode=cache_mode_of(cache, side=side),
111
+ ),
112
+ model=self.runtime.provider,
113
+ reasoning_effort=self.runtime.effort,
114
+ ),
115
+ )
116
+ ) as events:
117
+ async for event in events:
118
+ yield event
119
+ finally:
120
+ if not side:
121
+ self._remember_model(session_id, used)
122
+
123
+ async def resume_point(self) -> ResumePoint | None:
124
+ """Report where the active session was interrupted, or ``None`` when there is nothing to pick up.
125
+
126
+ Nothing is written and no model is called. A session another process has open is refused, as a turn is.
127
+
128
+ Raises:
129
+ SessionLocked: Another process has the session open.
130
+ """
131
+ self.require_session()
132
+ await self.runtime.start()
133
+ return await self.runtime.started.resume_point(
134
+ config=AgentRunConfig(session_id=self.session_id), model=self.runtime.provider
135
+ )
136
+
137
+ async def resume_blocker(self) -> str | None:
138
+ """Say why the active session cannot be picked up, in the library's own words; ``None`` when it can.
139
+
140
+ :meth:`resume_point` answers only that there is none. The reason is one the reader can act on — a finished
141
+ request, a refusal that would be declined again, a context window that is full — and the library says it when
142
+ a resume is asked for, so it is asked for here and stopped before it starts.
143
+ """
144
+ self.require_session()
145
+ await self.runtime.start()
146
+ stream = self.runtime.started.stream_resume(
147
+ config=AgentRunConfig(session_id=self.session_id), model=self.runtime.provider
148
+ )
149
+ try:
150
+ async for _event in stream:
151
+ return None # it can be resumed after all; the first event is as far as this looks
152
+ except AgentResumeError as error:
153
+ return str(error)
154
+ finally:
155
+ await stream.aclose() # type: ignore[attr-defined]
156
+ return None
157
+
158
+ async def stream_resume(self) -> AsyncIterator[AgentEvent]:
159
+ """Pick the interrupted request up where it stopped, with the model and effort the next turn would use.
160
+
161
+ The library takes none of these from the session, so the same ones a turn would carry are given.
162
+
163
+ Raises:
164
+ SessionLocked: Another process has the session open.
165
+ AgentResumeError: There is nothing to pick up; :meth:`resume_point` says whether there is.
166
+ """
167
+ self.require_session()
168
+ await self.runtime.start()
169
+ client = self.runtime.started
170
+ used = self.runtime.required_model.shown_name
171
+ session_id = self.session_id
172
+ try:
173
+ async with aclosing(
174
+ cast(
175
+ "AsyncGenerator[AgentEvent]",
176
+ client.stream_resume(
177
+ config=AgentRunConfig(session_id=session_id),
178
+ model=self.runtime.provider,
179
+ reasoning_effort=self.runtime.effort,
180
+ ),
181
+ )
182
+ ) as events:
183
+ async for event in events:
184
+ yield event
185
+ finally:
186
+ self._remember_model(session_id, used)
187
+
188
+ def _remember_model(self, session_id: str, model: str) -> None:
189
+ """Record the model a turn used in its session, so resuming the session starts on it.
190
+
191
+ Done when the turn ends, however it ends — a stopped or failed turn used the model too —
192
+ and not before it, because a session's folder does not exist until its first message is
193
+ written. A side question is not part of the conversation and records nothing. Metadata is
194
+ never worth failing a conversation for, so a store that cannot be written or read here is
195
+ passed over; the next turn tries again.
196
+ """
197
+ try:
198
+ self.runtime.persistence.set_model(session_id, model)
199
+ except (OSError, ValueError):
200
+ pass
201
+
202
+ @staticmethod
203
+ def request(
204
+ parts: Sequence[PromptPart],
205
+ *,
206
+ hint: str | None = None,
207
+ side: bool = False,
208
+ origin: str = READER,
209
+ watched: bool | None = None,
210
+ ) -> str | UserMessage:
211
+ """Return the user turn those ordered parts make up.
212
+
213
+ Text and images keep the order they were written in inside one
214
+ ``UserMessage``, so a picture is read where its writer placed it rather
215
+ than after the whole prompt. A turn with no image stays a plain string.
216
+ ``hint`` is appended last — as a trailing part when an image is in the
217
+ turn — and the typed text then rides along as the message's ``prompt``
218
+ attribute, so storage and a restored session keep it.
219
+
220
+ Args:
221
+ parts: The turn in the order it was written.
222
+ hint: Instructions the model reads after the turn: what a ``@``
223
+ reference contributed, then what the caller added.
224
+ side: Mark the turn as a side question, which the store records but
225
+ never replays.
226
+ origin: Who asked it: ``"reader"``, or the name of the plugin asking for itself. The store keeps it beside
227
+ the side mark.
228
+ watched: Whether a reopened transcript restores it, overriding what ``origin`` implies. Left out, a
229
+ reader's question is restored and a plugin's is not; it does not make a front end draw it now.
230
+ """
231
+ message = TurnsMixin.turn(parts, hint=hint)
232
+ if not side:
233
+ return message
234
+ if isinstance(message, UserMessage):
235
+ return replace(
236
+ message,
237
+ attributes={
238
+ **message.attributes,
239
+ SIDE: True,
240
+ ORIGIN: origin,
241
+ WATCHED: watched_by(origin, watched),
242
+ },
243
+ include_in_messages=False,
244
+ )
245
+ return question(message, origin=origin, watched=watched)
246
+
247
+ @staticmethod
248
+ def turn(parts: Sequence[PromptPart], *, hint: str | None = None) -> str | UserMessage:
249
+ """Return the message those parts make up, before any side marking."""
250
+ if not carries_image(parts):
251
+ text = plain_text(parts)
252
+ if hint is None:
253
+ return text
254
+ said = "".join(model_text_part(part) for part in parts)
255
+ return UserMessage(content=f"{said}\n\n{hint}", attributes={"prompt": text})
256
+ content: list[TextContent | ImageContent] = []
257
+ for part in parts:
258
+ if isinstance(part, (str, MentionPart)):
259
+ text = model_text_part(part)
260
+ if text:
261
+ content.append(TextContent(text))
262
+ continue
263
+ data, media_type = part
264
+ content.append(ImageContent(source=ImageBytesSource(data=data, media_type=media_type)))
265
+ if hint is not None:
266
+ content.append(TextContent(hint))
267
+ message = UserMessage(content=content)
268
+ if hint is not None:
269
+ message.attributes["prompt"] = plain_text(parts)
270
+ return message
271
+
272
+ def tasks(self) -> tuple[tuple[str, str], ...]:
273
+ """Return the current session's plan as display-ready status/content pairs."""
274
+ todos = self.runtime.todos
275
+ progress = todos.todos(self.session_id) if todos is not None else None
276
+ return tuple((item.status.value, item.content) for item in progress.todos) if progress else ()
277
+
278
+ def steer(self, message: str | UserMessage) -> bool:
279
+ """Queue an urgent user message for the request that is running.
280
+
281
+ The agent adopts it at the next model or tool boundary and drops the
282
+ rest of the current task, so the shell offers this only while a turn is
283
+ in flight. Returns whether a running request accepted the message, which
284
+ is what tells the shell it may show the message as queued.
285
+
286
+ Args:
287
+ message: What the user typed, as text, or as a message when it carries
288
+ pictures (see :meth:`turn`): zett-agent's steering inbox takes either.
289
+ An empty one is refused without asking the agent, and a request that
290
+ is not running refuses it here.
291
+ """
292
+ if isinstance(message, str):
293
+ if not message.strip():
294
+ return False
295
+ payload: dict[str, object] = {"content": message}
296
+ else:
297
+ if not message.text.strip() and not message.parts:
298
+ return False
299
+ payload = {"message": message}
300
+ try:
301
+ agent = self.runtime.started.agent
302
+ except RuntimeError:
303
+ return False
304
+ accepted = agent.emit_external_event(
305
+ ExternalEvent(name=STEERING_MESSAGE_EVENT_NAME, payload=payload),
306
+ config=AgentRunConfig(session_id=self.session_id),
307
+ )
308
+ return bool(accepted)