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.
- {nuagent-0.2.0 → nuagent-0.2.2}/PKG-INFO +6 -4
- {nuagent-0.2.0 → nuagent-0.2.2}/pyproject.toml +12 -5
- nuagent-0.2.2/src/nuagent/__init__.py +33 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/__init__.py +14 -3
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/loop.py +27 -1
- nuagent-0.2.2/src/nuagent/planes/__init__.py +30 -0
- nuagent-0.2.2/src/nuagent/planes/chat.py +421 -0
- nuagent-0.2.2/src/nuagent/planes/engine.py +242 -0
- nuagent-0.2.2/src/nuagent/planes/ext.py +43 -0
- nuagent-0.2.2/src/nuagent/planes/settings.py +195 -0
- nuagent-0.2.2/src/nuagent/planes/state.py +86 -0
- nuagent-0.2.2/src/nuagent/planes/store.py +64 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/__init__.py +26 -6
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/surface.py +18 -1
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/protocol/protocol.md +1 -1
- nuagent-0.2.0/src/nuagent/__init__.py +0 -9
- {nuagent-0.2.0 → nuagent-0.2.2}/.gitignore +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/README.md +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/core/program.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/__init__.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/__init__.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/cc.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/backends/llm.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/__init__.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/kv.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/presets/store/mem.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/catalogue.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/prompt/section.py +0 -0
- {nuagent-0.2.0 → nuagent-0.2.2}/src/nuagent/protocol/__init__.py +0 -0
- {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.
|
|
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.
|
|
10
|
-
Requires-Dist: nuskills>=0.
|
|
11
|
-
Requires-Dist: nustd[kv]>=0.6.
|
|
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.
|
|
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.
|
|
15
|
-
"nucore>=0.6.
|
|
16
|
-
"nustd[kv]>=0.6.
|
|
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.
|
|
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__ = [
|
|
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
|
+
)
|