nuagent 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {nuagent-0.2.0 → nuagent-0.2.2}/PKG-INFO +6 -4
  2. {nuagent-0.2.0 → nuagent-0.2.2}/pyproject.toml +12 -5
  3. nuagent-0.2.2/src/nuagent/__init__.py +33 -0
  4. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/__init__.py +14 -3
  5. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/loop.py +27 -1
  6. nuagent-0.2.2/src/nuagent/planes/__init__.py +30 -0
  7. nuagent-0.2.2/src/nuagent/planes/chat.py +421 -0
  8. nuagent-0.2.2/src/nuagent/planes/engine.py +242 -0
  9. nuagent-0.2.2/src/nuagent/planes/ext.py +43 -0
  10. nuagent-0.2.2/src/nuagent/planes/settings.py +195 -0
  11. nuagent-0.2.2/src/nuagent/planes/state.py +86 -0
  12. nuagent-0.2.2/src/nuagent/planes/store.py +64 -0
  13. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/__init__.py +26 -6
  14. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/surface.py +18 -1
  15. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/protocol/protocol.md +1 -1
  16. nuagent-0.2.0/src/nuagent/__init__.py +0 -9
  17. {nuagent-0.2.0 → nuagent-0.2.2}/.gitignore +0 -0
  18. {nuagent-0.2.0 → nuagent-0.2.2}/README.md +0 -0
  19. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/program.py +0 -0
  20. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/__init__.py +0 -0
  21. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/__init__.py +0 -0
  22. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/cc.py +0 -0
  23. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/llm.py +0 -0
  24. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/__init__.py +0 -0
  25. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/kv.py +0 -0
  26. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/mem.py +0 -0
  27. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/catalogue.py +0 -0
  28. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/section.py +0 -0
  29. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/protocol/__init__.py +0 -0
  30. {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/protocol/observation.py +0 -0
@@ -1,18 +1,20 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nuagent
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: nuagent: the model answers in Nu, Nu runs the answer.
5
5
  Project-URL: Repository, https://github.com/nustackdev/nuverse
6
6
  Author-email: Gor Arakelyan <gorarkln@gmail.com>
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.10
9
- Requires-Dist: nucore>=0.6.4
10
- Requires-Dist: nuskills>=0.1.0
11
- Requires-Dist: nustd[kv]>=0.6.4
9
+ Requires-Dist: nucore>=0.6.5
10
+ Requires-Dist: nuskills>=0.2.0
11
+ Requires-Dist: nustd[kv]>=0.6.5
12
12
  Provides-Extra: cc
13
13
  Requires-Dist: nustd[cc]; extra == 'cc'
14
14
  Provides-Extra: llm
15
15
  Requires-Dist: nustd[llm]; extra == 'llm'
16
+ Provides-Extra: space
17
+ Requires-Dist: nuspace>=0.5.4; extra == 'space'
16
18
  Description-Content-Type: text/markdown
17
19
 
18
20
  # nuagent
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nuagent"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "nuagent: the model answers in Nu, Nu runs the answer."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  authors = [{ name = "Gor Arakelyan", email = "gorarkln@gmail.com" }]
12
12
  requires-python = ">=3.10"
13
13
  dependencies = [
14
- # The core needs only nu. nuspace hosting comes later as separate packages.
15
- "nucore>=0.6.4",
16
- "nustd[kv]>=0.6.4",
14
+ # The core needs only nu. The Chat plane for nuspace is the ``space`` extra.
15
+ "nucore>=0.6.5",
16
+ "nustd[kv]>=0.6.5",
17
17
  # The generic Nu prose the opening prompt is assembled from.
18
- "nuskills>=0.1.0",
18
+ "nuskills>=0.2.0",
19
19
  ]
20
20
 
21
21
  [project.optional-dependencies]
@@ -27,9 +27,16 @@ cc = [
27
27
  llm = [
28
28
  "nustd[llm]",
29
29
  ]
30
+ # The Chat plane, a nuspace extension: a conversation with an agent in a space.
31
+ space = [
32
+ "nuspace>=0.5.4",
33
+ ]
30
34
 
31
35
  [project.urls]
32
36
  Repository = "https://github.com/nustackdev/nuverse"
33
37
 
38
+ [project.entry-points."nuspace.extensions"]
39
+ nuagent = "nuagent:extension"
40
+
34
41
  [tool.hatch.build.targets.wheel]
35
42
  packages = ["src/nuagent"]
@@ -0,0 +1,33 @@
1
+ """nuagent: the model answers in Nu, Nu runs the answer.
2
+
3
+ - :mod:`.core`: the loop, one turn after another until the model ends the run.
4
+ - :mod:`.protocol`: what the model is told about replying in Nu.
5
+ - :mod:`.prompt`: the opening prompt, the protocol plus nuskills, the catalogue and the world.
6
+ - :mod:`.presets`: what you swap: the store and the backend.
7
+ - :mod:`.planes`: a Chat plane for nuspace, found through the ``nuspace.extensions``
8
+ entry point, which names :func:`extension` (``nuagent[space]``).
9
+
10
+ Nothing here imports nuspace: :func:`extension` imports it when nuspace asks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from nuspace import Extension
20
+
21
+
22
+ __all__ = ["__version__", "extension"]
23
+
24
+ __version__ = "0.2.2"
25
+
26
+
27
+ def extension() -> Extension:
28
+ """The Chat plane, as a nuspace extension."""
29
+ from nuspace import Extension
30
+
31
+ from .planes import PLANE
32
+
33
+ return Extension(planes=(PLANE,))
@@ -1,6 +1,6 @@
1
1
  """The loop: prompt, reply, extract, load, eval, observe, next prompt.
2
2
 
3
- - :mod:`.loop`: :func:`agent`, :func:`turn`, :func:`seed`, the mem
3
+ - :mod:`.loop`: :func:`agent`, :func:`turn`, :func:`seed`, :func:`follow`, the mem
4
4
  :class:`Run` the model ends the run through, and the :class:`Store` and
5
5
  :class:`Backend` seams.
6
6
  - :mod:`.program`: :func:`extract` the program from a reply, :func:`attempt`
@@ -9,8 +9,19 @@
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from .loop import Backend, Run, Store, agent, seed, turn
12
+ from .loop import Backend, Run, Store, agent, follow, seed, turn
13
13
  from .program import FENCE, attempt, extract
14
14
 
15
15
 
16
- __all__ = ["FENCE", "Backend", "Run", "Store", "agent", "attempt", "extract", "seed", "turn"]
16
+ __all__ = [
17
+ "FENCE",
18
+ "Backend",
19
+ "Run",
20
+ "Store",
21
+ "agent",
22
+ "attempt",
23
+ "extract",
24
+ "follow",
25
+ "seed",
26
+ "turn",
27
+ ]
@@ -19,6 +19,11 @@ no predicate over the world: a predicate tests what a program did, not
19
19
  whether the task is done. Limits like those are layers around the loop,
20
20
  never inside it.
21
21
 
22
+ **A run can be carried on.** Once the model ended it, :func:`follow` appends
23
+ one more message and sets ``done`` back, and the same turns run again over
24
+ the whole conversation. What decides that a run is carried on (a chat, a
25
+ person) is the caller's.
26
+
22
27
  **The core knows no storage and no backend.** A :class:`Store` brings the
23
28
  session Shape (``messages``, ``reply``, ``program``, ``outcome``), the run
24
29
  Shape, and two brackets: ``bracket`` around the loop's own short session
@@ -47,7 +52,7 @@ if TYPE_CHECKING:
47
52
  from nu.lang import Nu, StrArg
48
53
 
49
54
 
50
- __all__ = ["Backend", "Run", "Store", "agent", "seed", "turn"]
55
+ __all__ = ["Backend", "Run", "Store", "agent", "follow", "seed", "turn"]
51
56
 
52
57
 
53
58
  class Run(nu.Shape):
@@ -135,6 +140,27 @@ def seed(store: Store, prompt: StrArg) -> Nu:
135
140
  )
136
141
 
137
142
 
143
+ def follow(store: Store, message: StrArg) -> Nu:
144
+ """Carry an ended run on: the message appended to the conversation, and the run not done.
145
+
146
+ Args:
147
+ store: the store the ended run kept its session and world in.
148
+ message: what comes next, as the caller composed it. Sent as one more
149
+ ``user`` message, after everything the conversation already holds.
150
+
151
+ Returns:
152
+ A Command. Put where :func:`seed` would go, ahead of the turns.
153
+
154
+ Notes:
155
+ - The conversation is kept as it is and grows by the one message, so a
156
+ stateful backend sends it alone and a stateless one sends it all.
157
+ """
158
+ said = nu.Dict.of(role="user", content=message)
159
+ return store.bracket(store.session.messages.append(said)) >> store.rewrite(
160
+ store.run.done.set(False)
161
+ )
162
+
163
+
138
164
  def turn(store: Store, ask: Callable[[Nu], Nu], *, state: Nu | None = None) -> Nu:
139
165
  """One turn: ask, extract, attempt, observe.
140
166
 
@@ -0,0 +1,30 @@
1
+ """nuagent in a space: planes a person talks to an agent through.
2
+
3
+ Imports nuspace, so nothing in :mod:`nuagent` imports this: nuspace finds
4
+ it through the ``nuspace.extensions`` entry point (:func:`nuagent.extension`).
5
+
6
+ One conversation is two planes. The Chat page (:mod:`.chat`) is what a person
7
+ sees, run while a tab has it open: its cells are the conversation, a cell per
8
+ message and a cell per answer, the agent adding its answers itself
9
+ (:func:`answer`). Its engine (:mod:`.engine`), a headless
10
+ child, is what works: a reaction on its inbox runs it, it handles every
11
+ message past its cursor through the nuagent loop, and returns. Everything the
12
+ conversation is lives in the engine's plane state (:mod:`.state`), which the
13
+ loop reaches through :class:`~.store.SpaceStore`. The agent's backend is set
14
+ once for the space, in a Settings cell (:mod:`.settings`).
15
+
16
+ - :mod:`.ext`: the Chat plane spec, :data:`PLANE`.
17
+ - :mod:`.chat`, :mod:`.engine`, :mod:`.settings`: the cells' code, and the
18
+ terms that drive a chat (:func:`~.chat.tell`, :func:`~.chat.stop`).
19
+ - :func:`answer`: what the agent's program calls to answer the person, a
20
+ cell on the page.
21
+ - :mod:`.state`, :mod:`.store`: what a conversation keeps, and the loop's store over it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .chat import answer
27
+ from .ext import PLANE
28
+
29
+
30
+ __all__ = ["PLANE", "answer"]
@@ -0,0 +1,421 @@
1
+ """The Chat page: a conversation with an agent, where the page's cells are the conversation.
2
+
3
+ The page keeps nothing of its own: every cell on it reads the engine's plane
4
+ state (:class:`~.state.Chat`), and they run while a tab has the page open.
5
+ The page starts as one cell, the composer, and the conversation grows above
6
+ it, a cell at a time:
7
+
8
+ - ``composer``: a box, Send and Stop, and the status by them (thinking,
9
+ running, stopped or error, nothing while idle). Its first run makes sure
10
+ the engine, its reaction and the agent settings are there (:func:`ensure`).
11
+ - ``said``: one per message the person sends (:func:`tell`). What they said,
12
+ and under it the trace of the agent's work on it: each turn's program,
13
+ what came back and how it went, folded.
14
+ - ``answer``: what the agent adds itself (:func:`answer`), from its own
15
+ program: any cell, a few lines of Markdown or a live table. It may change
16
+ or remove the ones it added before.
17
+
18
+ The terms the cells use are here too, for anything else that drives a chat:
19
+ :func:`ensure`, :func:`tell`, :func:`say`, :func:`stop`, and :func:`answer`
20
+ for the agent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING
26
+
27
+ import nu
28
+ import nu.prog
29
+ import nustd.ui
30
+ from nu.lang import ScalarQuery
31
+ from nuagent.protocol import CONSTRUCT, NO_CODE, OK, RUNTIME
32
+ from nuspace import ops
33
+ from nuspace.ops.utils import MintId
34
+ from nuspace.shapes import Reroot, Space
35
+ from nuspace.system.devices.web.shell import Shell
36
+ from nuspace.system.devices.web.utils import CellRoot
37
+ from nuspace.system.services import enable_react
38
+ from nuspace.system.utils import redraws, snap
39
+
40
+ from . import settings
41
+ from .engine import IMPORTS, MADE_BY, SOURCE, engine_cell, engine_of, inbox_change
42
+ from .ext import said_prog
43
+ from .state import ERROR, IDLE, RUNNING, STOPPED, THINKING, Chat
44
+
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Callable
48
+
49
+ from nu.lang.runtime import Runtime
50
+
51
+
52
+ __all__ = [
53
+ "ANSWER_BY",
54
+ "SAID_BY",
55
+ "Builds",
56
+ "answer",
57
+ "composer",
58
+ "ensure",
59
+ "said",
60
+ "say",
61
+ "stop",
62
+ "tell",
63
+ ]
64
+
65
+
66
+ #: What a cell the agent adds records as ``made_by``.
67
+ ANSWER_BY = "nuagent.answer"
68
+
69
+ #: What a cell a message adds records as ``made_by``.
70
+ SAID_BY = "nuagent.said"
71
+
72
+ #: The composer cell's name: messages and answers go on the page above it.
73
+ BOX = "composer"
74
+
75
+ #: The pill's tone per status shown.
76
+ TONES = {IDLE: "neutral", THINKING: "info", RUNNING: "warn", STOPPED: "neutral", ERROR: "danger"}
77
+
78
+ #: The badge's variant per outcome kind.
79
+ VARIANTS = {OK: "ok", NO_CODE: "neutral", CONSTRUCT: "warn", RUNTIME: "danger"}
80
+
81
+ #: The fold's title per outcome kind: how the turn's program went, in words.
82
+ TITLES = {
83
+ OK: "Program ran",
84
+ NO_CODE: "No program",
85
+ CONSTRUCT: "Program did not build",
86
+ RUNTIME: "Program failed",
87
+ }
88
+
89
+
90
+ def ensure(page: nu.StrArg) -> nu.Nu:
91
+ """The page's engine, its cell, its reaction and the agent settings, each made when missing.
92
+
93
+ Idempotent, and safe from two tabs at once: the engine and its cell
94
+ have fixed ids, and the reaction is :func:`enable_react`'s, which is
95
+ idempotent itself.
96
+
97
+ Args:
98
+ page: The chat page.
99
+ """
100
+ engine = engine_of(page)
101
+ cell = engine_cell(engine)
102
+ plane = ops.add_plane(
103
+ engine, backend="async", name="engine", parent=page, ui=False, made_by=MADE_BY
104
+ )
105
+ made = nu.IfDo(snap(ops.plane_exists(engine).not_()), plane) >> nu.IfDo(
106
+ snap(ops.cell_exists(cell).not_()),
107
+ ops.add_cell(engine, SOURCE, cell_id=cell, name="engine"),
108
+ )
109
+ reaction = enable_react(inbox_change(engine), engine, imports=IMPORTS)
110
+ return made >> settings.ensure() >> reaction
111
+
112
+
113
+ def say(engine: nu.StrArg, text: nu.StrArg) -> nu.Nu:
114
+ """``text`` into the engine's inbox: its reaction runs it. One commit."""
115
+ return ops.atomic_state(ops.plane_state(engine, Chat.inbox.append(text)))
116
+
117
+
118
+ def _above(page: nu.StrArg) -> nu.Int:
119
+ """Where a new cell goes on the page: the composer's place, the end when it has none. Bare read."""
120
+ cells = ops.cells(page)
121
+ box = ops.cell_named(page, BOX)
122
+ return nu.Int(nu.If(cells.contains(box), cells.index(box), cells.len()))
123
+
124
+
125
+ def tell(page: nu.StrArg, text: nu.StrArg) -> nu.Nu:
126
+ """The person's message: a said cell on the page above the composer, then ``text`` into the inbox.
127
+
128
+ Two commits, the cell first, so it is on the page before anything the
129
+ agent adds for it. The cell knows its message by its place in the
130
+ inbox, baked into its prog (:func:`~.ext.said_prog`), and reads the text
131
+ from there: the inbox stays the one place it is kept. Two tabs sending
132
+ at the same moment can both read the same place.
133
+ """
134
+ engine = engine_of(page)
135
+
136
+ def placed(at: nu.ObjectRef) -> nu.Nu:
137
+ cell = ops.add_cell(
138
+ page, said_prog(nu.Int(at)), name="said", index=_above(page), made_by=SAID_BY
139
+ )
140
+ return cell >> say(engine, text)
141
+
142
+ return nu.let(snap(ops.plane_state(engine, Chat.inbox.len())), placed)
143
+
144
+
145
+ def stop(engine: nu.StrArg) -> nu.Nu:
146
+ """Stop the engine: every message sent so far counted handled, then its live runs stopped.
147
+
148
+ The cursor first, so a run between two messages finds nothing more and
149
+ ends on its own, then each live run interrupted (killed after the grace
150
+ when a call will not let go). What it said so far stays.
151
+ """
152
+ handled = Chat.cursor.set(Chat.inbox.len()) >> Chat.status.set(STOPPED)
153
+ live = snap(nu.list(Space.kernel.planes_running[engine].runs))
154
+ stopping = nu.ForEachDo(live, lambda run: ops.plane_stop(nu.Str(run)))
155
+ return ops.atomic_state(ops.plane_state(engine, handled)) >> stopping
156
+
157
+
158
+ # --- answer ---------------------------------------------------------------------
159
+
160
+
161
+ # In process, as nuspace builds a prog to see whether it draws.
162
+ _BRACE = nu.prog.PyBrace()
163
+
164
+
165
+ def _stands(built: object, plane: str, cell: str) -> bool:
166
+ """True when what construction gave back is a term the page would run. Raises when not.
167
+
168
+ Rewritten the way the kernel rewrites a page cell (rerooted, then its ui
169
+ under the cell's node on the viewer), then compiled and validated, as
170
+ the kernel's ``Eval`` does before it drives it.
171
+ """
172
+ if not isinstance(built, nu.Nu):
173
+ msg = f"the answer does not build: {built}"
174
+ raise ValueError(msg)
175
+ term = CellRoot(Shell.viewer, cell)(Reroot(plane, cell)(built))
176
+ try:
177
+ nu.validate(nu.compile(term))
178
+ except Exception as err:
179
+ msg = f"the answer builds but would not run: {err}"
180
+ raise ValueError(msg) from err
181
+ return True
182
+
183
+
184
+ def _paged(plane: object) -> str:
185
+ """The page, or a raise when there is none: :func:`answer` ran outside a chat's engine."""
186
+ if not plane:
187
+ msg = "answer has no page to add to: it runs from a chat's engine"
188
+ raise ValueError(msg)
189
+ return str(plane)
190
+
191
+
192
+ class Builds(ScalarQuery):
193
+ """True when ``prog`` builds as cell ``cell_id`` of ``plane_id`` would, and validates. Raises when not.
194
+
195
+ Construction runs the snippet's module code in this process, offered the
196
+ ``plane`` and ``cell`` a run offers, as the kernel would on the page.
197
+ What fails raises ``ValueError``, with the construction's diagnostic or
198
+ the validation's message.
199
+ """
200
+
201
+ def __init__(self, prog: nu.StrArg, plane_id: nu.StrArg, cell_id: nu.StrArg) -> None:
202
+ super().__init__(prog, plane_id, cell_id)
203
+
204
+ def _compile(self, nid: int, children: tuple[Callable, ...]) -> Callable:
205
+ prog, plane, cell = children
206
+
207
+ def thunk(rt: Runtime) -> object:
208
+ p, c = _paged(plane(rt)), str(cell(rt))
209
+ built = _BRACE.construct(str(prog(rt)), scope={"plane": p, "cell": c})
210
+ return _stands(built, p, c)
211
+
212
+ return thunk
213
+
214
+ def _acompile(self, nid: int, children: tuple[Callable, ...]) -> Callable:
215
+ prog, plane, cell = children
216
+
217
+ async def athunk(rt: Runtime) -> object:
218
+ p, c = _paged(await plane(rt)), str(await cell(rt))
219
+ built = await _BRACE.aconstruct(str(await prog(rt)), scope={"plane": p, "cell": c})
220
+ return _stands(built, p, c)
221
+
222
+ return athunk
223
+
224
+
225
+ def answer(source: nu.StrArg, *, into: nu.Ref | None = None) -> nu.Nu:
226
+ """Add a cell to the chat page, above the composer: the agent's answer to the person.
227
+
228
+ Called from the agent's program, which runs as the engine's cell: the
229
+ page is the engine's parent. The source is built first, the way the page
230
+ will build it, and one that does not build raises (``ValueError``),
231
+ nothing added. Then it is written as one commit, ``made_by``
232
+ :data:`ANSWER_BY`, and runs on the page like any cell while a tab has it
233
+ open.
234
+
235
+ Args:
236
+ source: The cell's source: a module with ``out()``, as any cell.
237
+ into: Set to the new cell's id, for a program that edits it after.
238
+ """
239
+ page = snap(ops.parent(ops.Here.plane))
240
+
241
+ def placed(on: nu.ObjectRef) -> nu.Nu:
242
+ def built(minted: nu.ObjectRef) -> nu.Nu:
243
+ cid = nu.Str(minted)
244
+ add = ops.add_cell(
245
+ nu.Str(on),
246
+ source,
247
+ cell_id=cid,
248
+ name="answer",
249
+ index=_above(nu.Str(on)),
250
+ made_by=ANSWER_BY,
251
+ into=into,
252
+ )
253
+ return nu.IfDo(Builds(source, nu.Str(on), cid), add)
254
+
255
+ return nu.let(MintId("c"), built)
256
+
257
+ return nu.let(page, placed)
258
+
259
+
260
+ # --- said --------------------------------------------------------------------
261
+
262
+
263
+ class Bubble(nustd.ui.Box):
264
+ text = nustd.ui.MarkdownRef.slot()
265
+
266
+
267
+ class Said(nustd.ui.Row):
268
+ bubble = Bubble.slot(padding="sm", border="none", background="muted")
269
+
270
+
271
+ class Ran(nustd.ui.Collapsible):
272
+ kind = nustd.ui.BadgeRef.slot()
273
+ program = nustd.ui.CodeRef.slot(language="python", max_height=360)
274
+ observation = nustd.ui.CodeRef.slot(wrap=True, max_height=240)
275
+
276
+
277
+ class Trace(nustd.ui.Collapsible):
278
+ steps = nustd.ui.ColumnRef.slot(Ran, gap=2)
279
+
280
+
281
+ class Message(nustd.ui.Column):
282
+ said = Said.slot(justify="end")
283
+ trace = Trace.slot(title="working...")
284
+
285
+
286
+ def _step(pair: nu.Nu) -> nu.Nu:
287
+ """One turn, keyed by its place among all turns."""
288
+ at, kept = nu.Tuple(pair)[0], nu.Dict(nu.Tuple(pair)[1])
289
+ return nu.Dict.of(
290
+ key="t" + nu.str(at),
291
+ title=nu.Dict(TITLES).get_item(kept["kind"], "Program"),
292
+ program=kept["program"],
293
+ observation=kept["observation"],
294
+ kind=kept["kind"],
295
+ variant=nu.Dict(VARIANTS).get_item(kept["kind"], "neutral"),
296
+ )
297
+
298
+
299
+ def _trace(at: nu.IntArg) -> nu.Nu:
300
+ """Message ``at`` as plain data: its text, its turns, and whether it is handled. Bare, on the engine's state.
301
+
302
+ The text is ``""`` until it lands: the cell is on the page before the
303
+ message is in the inbox.
304
+ """
305
+ mine = Chat.turns.iter().enumerate().filter(lambda p: nu.Dict(nu.Tuple(p)[1])["at"] == at)
306
+ return nu.Dict.of(
307
+ text=nu.If(Chat.inbox.len() > at, Chat.inbox[at], ""),
308
+ steps=mine.map(_step).to_list(),
309
+ done=Chat.cursor.fallback(0) > at,
310
+ )
311
+
312
+
313
+ def _title(steps: nu.List, done: nu.Nu) -> nu.Str:
314
+ """The trace's title: ``3 steps · ok`` once handled, ``working...`` until then."""
315
+ n = steps.len()
316
+ count = nu.str(n) + nu.Str(nu.If(n == 1, " step", " steps"))
317
+ last = nu.If(n > 0, nu.Dict(steps.last_elem()).get_item("kind", ""), "")
318
+ working = nu.If(n == 0, "working...", count + " · working...")
319
+ handled = nu.If(n == 0, "no steps", count + " · " + nu.Str(last))
320
+ return nu.Str(nu.If(done, handled, working))
321
+
322
+
323
+ def said(page: nu.StrArg, at: nu.IntArg) -> nu.Nu:
324
+ """The said cell: message ``at``, and the trace of the agent's work on it, drawn again as it grows."""
325
+ engine = engine_of(page)
326
+ trace = Message.trace
327
+
328
+ def turn(t: nu.Nu) -> nu.Nu:
329
+ s = nu.Dict(t)
330
+ ran = trace.steps[s["key"]]
331
+ return (
332
+ ran.set(title=s["title"])
333
+ >> ran.kind.set(s["kind"], variant=s["variant"])
334
+ >> ran.program.set(s["program"])
335
+ >> ran.observation.set(s["observation"])
336
+ )
337
+
338
+ def draw(seen: nu.Nu) -> nu.Nu:
339
+ s = nu.Dict(seen)
340
+ steps = nu.List(s["steps"])
341
+ text = Message.said.bubble.text.set(s["text"])
342
+ return text >> trace.set(title=_title(steps, s["done"])) >> nu.ForEachDo(steps, turn)
343
+
344
+ moves = [Chat.inbox.on_change(), Chat.turns.on_change(), Chat.cursor.on_change()]
345
+ return redraws(
346
+ [ops.plane_state(engine, m) for m in moves], ops.plane_state(engine, _trace(at)), draw
347
+ )
348
+
349
+
350
+ # --- status, drawn by the composer ----------------------------------------------------------------------
351
+
352
+
353
+ def _shown() -> nu.Nu:
354
+ """The status as the page shows it. Bare, on the engine's state.
355
+
356
+ Thinking reads as running while the latest reply's program runs: the
357
+ conversation ends on the model's message until the observation lands.
358
+ """
359
+ kept = Chat.status.fallback(IDLE)
360
+ last = nu.If(
361
+ Chat.messages.len() > 0, nu.Dict(Chat.messages.last_elem()).get_item("role", ""), ""
362
+ )
363
+ running = (kept == THINKING).and_(nu.Str(last) == "assistant")
364
+ shown = nu.Str(nu.If(running, RUNNING, kept))
365
+ return nu.Dict.of(
366
+ status=shown,
367
+ tone=nu.Dict(TONES).get_item(shown, "neutral"),
368
+ error=nu.If(kept == ERROR, Chat.error.fallback(""), ""),
369
+ )
370
+
371
+
372
+ # --- composer --------------------------------------------------------------------
373
+
374
+
375
+ class Actions(nustd.ui.Row):
376
+ pill = nustd.ui.StatusPillRef.slot()
377
+ send = nustd.ui.ButtonRef.slot(label="Send", icon="send")
378
+ stop = nustd.ui.ButtonRef.slot(label="Stop", variant="secondary", icon="square")
379
+
380
+
381
+ class Composer(nustd.ui.Column):
382
+ error = nustd.ui.AlertRef.slot(variant="danger")
383
+ text = nustd.ui.TextAreaRef.slot(placeholder="Message the agent", rows=3, auto_resize=True)
384
+ actions = Actions.slot(gap=2, justify="end", align="center")
385
+
386
+
387
+ def composer(page: nu.StrArg) -> nu.Nu:
388
+ """The composer cell: the engine made sure of, then Send and Stop, and the status by them.
389
+
390
+ Send puts the message on the page and in the inbox (:func:`tell`). The
391
+ status pill shows while the engine works and is gone while idle, Stop
392
+ is live only while it thinks, and a failure shows above the box.
393
+ """
394
+ engine = engine_of(page)
395
+ box, actions = Composer.text, Composer.actions
396
+
397
+ def sent(typed: nu.Nu) -> nu.Nu:
398
+ text = nu.Str(typed)
399
+ return nu.IfDo(text != "", tell(page, text) >> box.set(""))
400
+
401
+ def draw(seen: nu.Nu) -> nu.Nu:
402
+ s = nu.Dict(seen)
403
+ shown = nu.Str(s["status"])
404
+ pill = nu.IfDo(shown == IDLE, actions.pill.erase(), actions.pill.set(shown, tone=s["tone"]))
405
+ failed = Composer.error.set("The last message failed", body=s["error"])
406
+ error = nu.IfDo(nu.Str(s["error"]) != "", failed, Composer.error.erase())
407
+ idle = (shown != THINKING).and_(shown != RUNNING)
408
+ return pill >> error >> actions.stop.set_disabled(idle)
409
+
410
+ moves = [Chat.status.on_change(), Chat.messages.on_change(), Chat.inbox.on_change()]
411
+ on = [ops.plane_state(engine, m) for m in moves]
412
+ placed = box.set("") >> actions.send.set("Send") >> actions.stop.set("Stop", disabled=True)
413
+ return (
414
+ ensure(page)
415
+ >> placed
416
+ >> nu.ParallelAsync(
417
+ redraws(on, ops.plane_state(engine, _shown()), draw, every=5.0),
418
+ nu.ReactForever(actions.send.on_click(), nu.let(nu.Str(box).strip(), sent)),
419
+ nu.ReactForever(actions.stop.on_click(), stop(engine)),
420
+ )
421
+ )