delos-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
delos_cli/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""High-level wrapper around the backend's ``/{app}/agent/*`` routes.
|
|
2
|
+
|
|
3
|
+
:class:`AgentSession` drives one user turn end-to-end: streams completions,
|
|
4
|
+
detects unresolved tool calls (= client-side tools), resolves them via
|
|
5
|
+
registered handlers, and re-issues continuation requests until the agent
|
|
6
|
+
run is fully complete.
|
|
7
|
+
|
|
8
|
+
A :class:`ToolRegistry` is the single extension point — apps register:
|
|
9
|
+
|
|
10
|
+
* **Renderers** — how a server-side tool's events show up in the terminal.
|
|
11
|
+
* **Handlers** — how a client-side tool collects input from the user.
|
|
12
|
+
|
|
13
|
+
Both have sensible defaults so apps work out of the box.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .session import AgentSession
|
|
17
|
+
from .tools import (
|
|
18
|
+
ToolHandler,
|
|
19
|
+
ToolRegistry,
|
|
20
|
+
ToolRenderer,
|
|
21
|
+
default_handler,
|
|
22
|
+
default_renderer,
|
|
23
|
+
)
|
|
24
|
+
from .transport import AgentTransport
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentSession",
|
|
28
|
+
"AgentTransport",
|
|
29
|
+
"ToolHandler",
|
|
30
|
+
"ToolRegistry",
|
|
31
|
+
"ToolRenderer",
|
|
32
|
+
"default_handler",
|
|
33
|
+
"default_renderer",
|
|
34
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""AgentSession — drives one user turn end-to-end.
|
|
2
|
+
|
|
3
|
+
A turn may span multiple SSE streams: each client-side tool call ends
|
|
4
|
+
one stream and starts another with ``tool_continuation=true``. From the
|
|
5
|
+
caller's POV, :meth:`AgentSession.send_turn` is a single async iterator
|
|
6
|
+
of UI events — all the multi-stream stitching is internal.
|
|
7
|
+
|
|
8
|
+
A :class:`ToolRegistry` resolves both how server-tool events render and
|
|
9
|
+
how client-tool calls collect input from the user.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
|
|
19
|
+
from delos_cli.ctx import Ctx
|
|
20
|
+
|
|
21
|
+
from .tools import ToolRegistry
|
|
22
|
+
from .transport import AgentTransport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentSession:
|
|
26
|
+
"""One session per ``(transport, chat_id)`` pair."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
transport: AgentTransport,
|
|
31
|
+
chat_id: str,
|
|
32
|
+
registry: ToolRegistry,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Bind to a chat row. Multiple turns share the same session."""
|
|
35
|
+
self._transport = transport
|
|
36
|
+
self._chat_id = chat_id
|
|
37
|
+
self._registry = registry
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def chat_id(self) -> str:
|
|
41
|
+
"""The chat row id this session is bound to."""
|
|
42
|
+
return self._chat_id
|
|
43
|
+
|
|
44
|
+
async def send_turn(
|
|
45
|
+
self,
|
|
46
|
+
body: dict[str, Any],
|
|
47
|
+
ctx: Ctx,
|
|
48
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
49
|
+
"""Send one user turn and yield every UI event the agent emits.
|
|
50
|
+
|
|
51
|
+
Streams the initial ``/completions`` request. When the run
|
|
52
|
+
terminates with unresolved tool calls, treats them as client-side,
|
|
53
|
+
invokes the registered handler, posts the result via
|
|
54
|
+
``/tool-result``, and re-streams a continuation request with
|
|
55
|
+
``tool_continuation=true`` and ``messages=[]``. Loops until no
|
|
56
|
+
pending calls remain or the user explicitly stopped.
|
|
57
|
+
"""
|
|
58
|
+
current_body = dict(body)
|
|
59
|
+
while True:
|
|
60
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
61
|
+
stopped = False
|
|
62
|
+
async for event in self._transport.stream_completions(current_body):
|
|
63
|
+
etype = event.get("type", "")
|
|
64
|
+
if etype == "tool-input-available":
|
|
65
|
+
tcid = event.get("toolCallId", "")
|
|
66
|
+
if tcid:
|
|
67
|
+
pending[tcid] = {
|
|
68
|
+
"name": event.get("toolName", ""),
|
|
69
|
+
"input": event.get("input", {}) or {},
|
|
70
|
+
}
|
|
71
|
+
elif etype == "tool-output-available":
|
|
72
|
+
pending.pop(event.get("toolCallId", ""), None)
|
|
73
|
+
elif etype == "data-stopped":
|
|
74
|
+
stopped = True
|
|
75
|
+
yield event
|
|
76
|
+
|
|
77
|
+
if stopped or not pending:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
for tcid, meta in pending.items():
|
|
81
|
+
handler = self._registry.handler_for(meta["name"])
|
|
82
|
+
result = await handler(meta["input"], ctx)
|
|
83
|
+
await self._transport.submit_tool_result(
|
|
84
|
+
self._chat_id, tcid, meta["name"], result,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
current_body = {**body, "messages": [], "tool_continuation": True}
|
|
88
|
+
|
|
89
|
+
async def stop(self) -> None:
|
|
90
|
+
"""Set the backend stop signal. Idempotent."""
|
|
91
|
+
await self._transport.stop(self._chat_id)
|
|
92
|
+
|
|
93
|
+
async def clear_chat(self) -> None:
|
|
94
|
+
"""Delete the chat row on the backend."""
|
|
95
|
+
await self._transport.clear_chat(self._chat_id)
|
|
96
|
+
|
|
97
|
+
async def queue_message(self, content: str) -> str:
|
|
98
|
+
"""Add a message to the FIFO queue. Returns the new message id."""
|
|
99
|
+
return await self._transport.queue_message(self._chat_id, content)
|
|
100
|
+
|
|
101
|
+
async def list_queue(self) -> list[dict[str, Any]]:
|
|
102
|
+
"""Return the current queued-messages list."""
|
|
103
|
+
return await self._transport.list_queue(self._chat_id)
|
|
104
|
+
|
|
105
|
+
async def edit_queued(self, message_id: str, content: str) -> None:
|
|
106
|
+
"""Edit a queued message in place."""
|
|
107
|
+
await self._transport.edit_queued(self._chat_id, message_id, content)
|
|
108
|
+
|
|
109
|
+
async def remove_queued(self, message_id: str) -> None:
|
|
110
|
+
"""Remove a queued message."""
|
|
111
|
+
await self._transport.remove_queued(self._chat_id, message_id)
|
delos_cli/agent/tools.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tool registry, default renderers, and default handlers.
|
|
2
|
+
|
|
3
|
+
There are two extension points per tool:
|
|
4
|
+
|
|
5
|
+
- **Renderer** — called when the agent emits ``tool-input-available``,
|
|
6
|
+
``tool-output-available``, or ``data-tool-output-delta`` for that tool.
|
|
7
|
+
Returns a Rich renderable (``Panel``, ``Text``, …) or ``None`` to skip.
|
|
8
|
+
Used for server-side tools whose execution happens in the backend.
|
|
9
|
+
|
|
10
|
+
- **Handler** — called when the agent emits ``tool-input-available`` and
|
|
11
|
+
the run terminates without a corresponding ``tool-output-available``
|
|
12
|
+
(= the tool was registered ``client=True`` server-side). Returns the
|
|
13
|
+
result string the next agent turn should see.
|
|
14
|
+
|
|
15
|
+
Defaults:
|
|
16
|
+
|
|
17
|
+
- :func:`default_renderer` — a one-line ``→ tool_name`` indicator on
|
|
18
|
+
``tool-input-available``; nothing on output / delta events. Custom
|
|
19
|
+
renderers can opt into args, output, or streaming deltas.
|
|
20
|
+
- :func:`default_handler` — prints the input dict, asks for a free-form
|
|
21
|
+
text answer via a one-shot prompt_toolkit prompt.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from prompt_toolkit import PromptSession
|
|
31
|
+
from prompt_toolkit.formatted_text import HTML
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from rich.console import RenderableType
|
|
37
|
+
|
|
38
|
+
from delos_cli.ctx import Ctx
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class ToolRenderer(Protocol):
|
|
43
|
+
"""Render one tool-related event.
|
|
44
|
+
|
|
45
|
+
Return a Rich renderable to print above the live region, or ``None``
|
|
46
|
+
to skip rendering. The same renderer instance receives every event
|
|
47
|
+
for its tool name across the whole REPL session, so it may keep state
|
|
48
|
+
(e.g. dedupe by ``toolCallId``) if needed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __call__(self, event: dict[str, Any]) -> RenderableType | None:
|
|
52
|
+
"""Render ``event`` and return a Rich renderable, or ``None`` to skip."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class ToolHandler(Protocol):
|
|
58
|
+
"""Resolve a client-side tool call.
|
|
59
|
+
|
|
60
|
+
Receives the tool's ``input`` dict (whatever the agent passed) and the
|
|
61
|
+
REPL :class:`~delos_cli.ctx.Ctx` for console / HTTP access. Returns
|
|
62
|
+
the result string the next agent turn should see.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
async def __call__(
|
|
66
|
+
self, tool_input: dict[str, Any], ctx: Ctx,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Collect input from the user and return the result string."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_renderer(event: dict[str, Any]) -> RenderableType | None:
|
|
73
|
+
"""Show a single ``→ tool_name`` line on ``tool-input-available``; ignore the rest.
|
|
74
|
+
|
|
75
|
+
Args are intentionally hidden — most tool calls are noisy enough as JSON
|
|
76
|
+
that they hurt readability for the default case. Custom renderers can
|
|
77
|
+
opt into args, output (``tool-output-available``), and streaming
|
|
78
|
+
deltas (``data-tool-output-delta``).
|
|
79
|
+
"""
|
|
80
|
+
if event.get("type") != "tool-input-available":
|
|
81
|
+
return None
|
|
82
|
+
name = event.get("toolName", "?")
|
|
83
|
+
line = Text()
|
|
84
|
+
line.append("→ ", style="bold cyan")
|
|
85
|
+
line.append(name, style="cyan")
|
|
86
|
+
return line
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def default_handler(tool_input: dict[str, Any], ctx: Ctx) -> str:
|
|
90
|
+
"""Print the input dict and prompt for a single-line free-form answer.
|
|
91
|
+
|
|
92
|
+
A one-shot :class:`PromptSession` is spun up so the user gets the same
|
|
93
|
+
keybindings as the main REPL (Ctrl+R search, history hints, …) without
|
|
94
|
+
polluting the chat history file with these tool answers.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
pretty = json.dumps(tool_input, indent=2, ensure_ascii=False)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
pretty = str(tool_input)
|
|
100
|
+
ctx.console.print(
|
|
101
|
+
Panel(
|
|
102
|
+
Text(pretty, style="dim"),
|
|
103
|
+
title="? client tool input",
|
|
104
|
+
title_align="left",
|
|
105
|
+
border_style="yellow",
|
|
106
|
+
expand=False,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
sess: PromptSession[str] = PromptSession()
|
|
110
|
+
answer = await sess.prompt_async(HTML("<ansiyellow>tool result › </ansiyellow>"))
|
|
111
|
+
return answer.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class ToolRegistry:
|
|
116
|
+
"""Per-tool overrides keyed by tool name.
|
|
117
|
+
|
|
118
|
+
Lookups fall back to :func:`default_renderer` / :func:`default_handler`
|
|
119
|
+
when a tool isn't registered.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
renderers: dict[str, ToolRenderer] = field(default_factory=dict)
|
|
123
|
+
handlers: dict[str, ToolHandler] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
def renderer_for(self, name: str) -> ToolRenderer:
|
|
126
|
+
"""Return the registered renderer for ``name``, or the default."""
|
|
127
|
+
return self.renderers.get(name, default_renderer)
|
|
128
|
+
|
|
129
|
+
def handler_for(self, name: str) -> ToolHandler:
|
|
130
|
+
"""Return the registered handler for ``name``, or the default."""
|
|
131
|
+
return self.handlers.get(name, default_handler)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP wrappers for the ``/{app}/agent/*`` routes.
|
|
2
|
+
|
|
3
|
+
One :class:`AgentTransport` instance is bound to ``(app, org)``; the
|
|
4
|
+
``chat_id`` is passed per call. Methods map 1:1 to the routes the backend
|
|
5
|
+
exposes via ``create_agent_router``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
|
|
16
|
+
from delos_cli.transport.client import AuthedClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentTransport:
|
|
20
|
+
"""One transport instance per ``(app, org)`` pair."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
http: AuthedClient,
|
|
25
|
+
api_prefix: str,
|
|
26
|
+
org_uuid: str,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Bind to an app's agent router.
|
|
29
|
+
|
|
30
|
+
``api_prefix`` is the app slug (``"chat"``, ``"code"``, …) — it is
|
|
31
|
+
what the backend uses to mount each ``create_agent_router`` instance.
|
|
32
|
+
"""
|
|
33
|
+
self._http = http
|
|
34
|
+
self._prefix = api_prefix
|
|
35
|
+
self._org = org_uuid
|
|
36
|
+
|
|
37
|
+
def _path(self, suffix: str, chat_id: str | None = None) -> str:
|
|
38
|
+
base = f"/{self._prefix}/agent/{suffix}/{self._org}"
|
|
39
|
+
return f"{base}/{chat_id}" if chat_id is not None else base
|
|
40
|
+
|
|
41
|
+
async def stream_completions(
|
|
42
|
+
self, body: dict[str, Any],
|
|
43
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
44
|
+
"""``POST /{app}/agent/completions/{org}`` → parsed SSE events."""
|
|
45
|
+
async for event in self._http.sse_post(self._path("completions"), body):
|
|
46
|
+
yield event
|
|
47
|
+
|
|
48
|
+
async def stop(self, chat_id: str) -> None:
|
|
49
|
+
"""``POST /stop/{org}/{chat_id}`` — set the Redis stop signal."""
|
|
50
|
+
await self._http.json_post(self._path("stop", chat_id), {})
|
|
51
|
+
|
|
52
|
+
async def submit_tool_result(
|
|
53
|
+
self,
|
|
54
|
+
chat_id: str,
|
|
55
|
+
tool_call_id: str,
|
|
56
|
+
tool_name: str,
|
|
57
|
+
content: str,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""``POST /tool-result/{org}/{chat_id}`` — persist a client-tool result."""
|
|
60
|
+
await self._http.json_post(
|
|
61
|
+
self._path("tool-result", chat_id),
|
|
62
|
+
{
|
|
63
|
+
"tool_call_id": tool_call_id,
|
|
64
|
+
"tool_name": tool_name,
|
|
65
|
+
"content": content,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def clear_chat(self, chat_id: str) -> None:
|
|
70
|
+
"""``POST /clear-chat/{org}/{chat_id}`` — delete the chat row."""
|
|
71
|
+
await self._http.json_post(self._path("clear-chat", chat_id), {})
|
|
72
|
+
|
|
73
|
+
async def queue_message(self, chat_id: str, content: str) -> str:
|
|
74
|
+
"""``POST /queue/{org}/{chat_id}`` — append to the FIFO queue.
|
|
75
|
+
|
|
76
|
+
Generates a fresh UUID client-side (mirrors the frontend) and returns
|
|
77
|
+
it so the caller can later edit / remove the queued entry.
|
|
78
|
+
"""
|
|
79
|
+
message_id = str(uuid.uuid4())
|
|
80
|
+
await self._http.json_post(
|
|
81
|
+
self._path("queue", chat_id),
|
|
82
|
+
{"message_id": message_id, "content": content},
|
|
83
|
+
)
|
|
84
|
+
return message_id
|
|
85
|
+
|
|
86
|
+
async def list_queue(self, chat_id: str) -> list[dict[str, Any]]:
|
|
87
|
+
"""``GET /queue/{org}/{chat_id}`` — current queue snapshot."""
|
|
88
|
+
data = await self._http.json_get(self._path("queue", chat_id))
|
|
89
|
+
queue = data.get("queue") or []
|
|
90
|
+
return list(queue)
|
|
91
|
+
|
|
92
|
+
async def edit_queued(
|
|
93
|
+
self, chat_id: str, message_id: str, content: str,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""``PUT /queue/{org}/{chat_id}/{msg_id}``."""
|
|
96
|
+
path = f"{self._path('queue', chat_id)}/{message_id}"
|
|
97
|
+
await self._http.json_request("PUT", path, {"content": content})
|
|
98
|
+
|
|
99
|
+
async def remove_queued(self, chat_id: str, message_id: str) -> None:
|
|
100
|
+
"""``DELETE /queue/{org}/{chat_id}/{msg_id}``."""
|
|
101
|
+
path = f"{self._path('queue', chat_id)}/{message_id}"
|
|
102
|
+
await self._http.json_request("DELETE", path, None)
|
delos_cli/apps/base.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""App + Renderer protocols.
|
|
2
|
+
|
|
3
|
+
Each app (chat, scribe, code, …) implements ``App``. The loop is
|
|
4
|
+
app-agnostic: it only knows how to send a turn, get a renderer, and
|
|
5
|
+
ask for a toolbar fragment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
|
|
16
|
+
from delos_cli.commands.base import CommandSpec
|
|
17
|
+
from delos_cli.ctx import Ctx
|
|
18
|
+
from delos_cli.ui.output import OutputBuffer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Renderer(Protocol):
|
|
23
|
+
"""Consumes streamed events and pushes them into the REPL's :class:`OutputBuffer`.
|
|
24
|
+
|
|
25
|
+
The renderer no longer prints to a console directly — the
|
|
26
|
+
:class:`~prompt_toolkit.application.Application` owns the actual
|
|
27
|
+
terminal drawing. Renderers only translate v6 events into block /
|
|
28
|
+
live updates on the buffer.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
assistant_text: str
|
|
32
|
+
|
|
33
|
+
def apply(self, event: dict[str, Any]) -> None:
|
|
34
|
+
"""Handle one streamed event."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
"""Finalize any in-flight live block — call at the end of a turn."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class App(ABC):
|
|
43
|
+
"""Interface one backend agent needs to implement to plug into the REPL.
|
|
44
|
+
|
|
45
|
+
Subclasses own their own state (model choice, selected tools, …).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
#: Short user-facing name (``"chat"``, ``"scribe"``, …).
|
|
49
|
+
name: str
|
|
50
|
+
|
|
51
|
+
#: Slash commands specific to this app — merged with the global registry.
|
|
52
|
+
commands: dict[str, CommandSpec]
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def on_enter(self, ctx: Ctx) -> None:
|
|
56
|
+
"""Hook called once, on REPL startup.
|
|
57
|
+
|
|
58
|
+
Prefetch lists (e.g. available models) or rehydrate state before
|
|
59
|
+
the loop starts reading input. Implement as ``pass`` if nothing
|
|
60
|
+
is needed.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def send(
|
|
65
|
+
self,
|
|
66
|
+
ctx: Ctx,
|
|
67
|
+
messages: list[dict[str, str]],
|
|
68
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
69
|
+
"""Stream one turn's events. The loop will ``async for`` over this."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def make_renderer(self, output: OutputBuffer) -> Renderer:
|
|
73
|
+
"""Build a fresh renderer bound to ``output`` for this turn."""
|
|
74
|
+
|
|
75
|
+
def toolbar_fragment(self, ctx: Ctx) -> str:
|
|
76
|
+
"""Optional HTML fragment the bottom toolbar appends after the shared bits.
|
|
77
|
+
|
|
78
|
+
Default: empty. Override to show app-specific state (selected model,
|
|
79
|
+
open document, …).
|
|
80
|
+
"""
|
|
81
|
+
_ = self, ctx
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
async def cancel(self, ctx: Ctx) -> None:
|
|
85
|
+
"""Hook called on Ctrl+C during a turn or when ``/stop`` is invoked.
|
|
86
|
+
|
|
87
|
+
Default is a no-op. Apps that drive a backend agent should override
|
|
88
|
+
this to send the corresponding stop signal so the run aborts on the
|
|
89
|
+
server, not just locally.
|
|
90
|
+
"""
|
|
91
|
+
_ = self, ctx
|
|
92
|
+
|
|
93
|
+
async def queue_message(self, ctx: Ctx, content: str) -> bool:
|
|
94
|
+
"""Queue a plain message while a turn is already in flight.
|
|
95
|
+
|
|
96
|
+
Default returns ``False`` (caller should fall back to refusing /
|
|
97
|
+
ignoring). Apps that wrap a real agent should append to its
|
|
98
|
+
backend FIFO queue and return ``True`` on success.
|
|
99
|
+
"""
|
|
100
|
+
_ = self, ctx, content
|
|
101
|
+
return False
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""ChatApp — the delos-cli agent.
|
|
2
|
+
|
|
3
|
+
Wraps an :class:`AgentSession` against the ``/cli/agent/*`` backend
|
|
4
|
+
endpoints + ``app_cli`` / ``app_cli_messages`` Supabase tables. The
|
|
5
|
+
chat row is INSERT'd at picker time (cf. :func:`main._run_repl`), so by
|
|
6
|
+
the time we get here the agent always has a row to append messages to.
|
|
7
|
+
|
|
8
|
+
Owns the per-app :class:`ToolRegistry` (so users can plug in custom
|
|
9
|
+
renderers / handlers per tool). The :class:`V6Renderer` returned from
|
|
10
|
+
:meth:`make_renderer` is wired to the same registry.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from delos_cli.agent import AgentSession, AgentTransport, ToolRegistry
|
|
22
|
+
from delos_cli.apps.base import App
|
|
23
|
+
from delos_cli.tools import (
|
|
24
|
+
handle_edit_content,
|
|
25
|
+
handle_run_shell,
|
|
26
|
+
handle_write_content,
|
|
27
|
+
render_edit_content,
|
|
28
|
+
render_run_shell,
|
|
29
|
+
render_write_content,
|
|
30
|
+
)
|
|
31
|
+
from delos_cli.transport.client import TransportError
|
|
32
|
+
from delos_cli.transport.models import fetch_models
|
|
33
|
+
|
|
34
|
+
from .commands import CHAT_COMMANDS
|
|
35
|
+
from .render import V6Renderer
|
|
36
|
+
from .replay import replay_messages
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import AsyncIterator
|
|
40
|
+
|
|
41
|
+
from delos_cli.commands.base import CommandSpec
|
|
42
|
+
from delos_cli.ctx import Ctx
|
|
43
|
+
from delos_cli.ui.output import OutputBuffer
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_tool_registry() -> ToolRegistry:
|
|
47
|
+
"""Pre-wired registry: client tools the backend can call out of the box."""
|
|
48
|
+
reg = ToolRegistry()
|
|
49
|
+
reg.handlers["run_shell"] = handle_run_shell
|
|
50
|
+
reg.renderers["run_shell"] = render_run_shell
|
|
51
|
+
reg.handlers["write_content"] = handle_write_content
|
|
52
|
+
reg.renderers["write_content"] = render_write_content
|
|
53
|
+
reg.handlers["edit_content"] = handle_edit_content
|
|
54
|
+
reg.renderers["edit_content"] = render_edit_content
|
|
55
|
+
return reg
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ChatApp(App):
|
|
60
|
+
"""The CLI chat agent — talks to ``/cli/agent/...``."""
|
|
61
|
+
|
|
62
|
+
name: str = "cli"
|
|
63
|
+
available_models: list[str] = field(default_factory=list)
|
|
64
|
+
commands: dict[str, CommandSpec] = field(default_factory=lambda: dict(CHAT_COMMANDS))
|
|
65
|
+
#: Per-tool renderers + handlers. Built-in entries cover the
|
|
66
|
+
#: client-side tools the backend exposes (``run_shell`` for now).
|
|
67
|
+
#: Override / extend by mutating ``tools.handlers`` and
|
|
68
|
+
#: ``tools.renderers`` after construction.
|
|
69
|
+
tools: ToolRegistry = field(default_factory=_default_tool_registry)
|
|
70
|
+
#: OpenAI-style messages staged for replay on the next ``on_enter``.
|
|
71
|
+
#: Set by ``main._run_repl`` after the picker resolves a chat_id, so
|
|
72
|
+
#: history is rendered the moment the REPL takes over the screen.
|
|
73
|
+
pending_replay: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
_current_session: AgentSession | None = field(default=None, init=False, repr=False)
|
|
75
|
+
|
|
76
|
+
async def on_enter(self, ctx: Ctx) -> None:
|
|
77
|
+
"""Prefetch model list, then replay any staged conversation history."""
|
|
78
|
+
try:
|
|
79
|
+
self.available_models = await fetch_models(ctx.http)
|
|
80
|
+
except TransportError as e:
|
|
81
|
+
sink = ctx.output if ctx.output is not None else ctx.console
|
|
82
|
+
sink.print(Text(f"(could not fetch models: {e})", style="yellow"))
|
|
83
|
+
|
|
84
|
+
if self.pending_replay and ctx.output is not None:
|
|
85
|
+
replay_messages(self.pending_replay, self, ctx.output)
|
|
86
|
+
# Always drain — even a failed replay shouldn't replay again next time.
|
|
87
|
+
self.pending_replay = []
|
|
88
|
+
|
|
89
|
+
async def send(
|
|
90
|
+
self,
|
|
91
|
+
ctx: Ctx,
|
|
92
|
+
messages: list[dict[str, str]],
|
|
93
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
94
|
+
"""Stream one turn through :class:`AgentSession` against ``/cli/agent/...``.
|
|
95
|
+
|
|
96
|
+
The ``app_cli`` row is created at picker time (or by ``/clear``),
|
|
97
|
+
so the backend always finds the chat when this runs — no
|
|
98
|
+
``ensure_chat_row`` dance.
|
|
99
|
+
"""
|
|
100
|
+
transport = AgentTransport(ctx.http, "cli", ctx.cfg.org_uuid)
|
|
101
|
+
session = AgentSession(transport, ctx.state.conv_id, self.tools)
|
|
102
|
+
|
|
103
|
+
# Backend reads model + everything else from ``app_cli``; we only
|
|
104
|
+
# send what's strictly per-turn.
|
|
105
|
+
body: dict[str, Any] = {
|
|
106
|
+
"chat_id": ctx.state.conv_id,
|
|
107
|
+
"messages": messages,
|
|
108
|
+
"language": "en",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
self._current_session = session
|
|
112
|
+
try:
|
|
113
|
+
async for event in session.send_turn(body, ctx):
|
|
114
|
+
yield event
|
|
115
|
+
finally:
|
|
116
|
+
self._current_session = None
|
|
117
|
+
|
|
118
|
+
def make_renderer(self, output: OutputBuffer) -> V6Renderer:
|
|
119
|
+
"""Build a fresh :class:`V6Renderer` wired to this app's tool registry."""
|
|
120
|
+
return V6Renderer(output, self.tools)
|
|
121
|
+
|
|
122
|
+
async def cancel(self, ctx: Ctx) -> None:
|
|
123
|
+
"""Send the backend stop signal for the in-flight turn, if any.
|
|
124
|
+
|
|
125
|
+
No-op when no turn is active — :meth:`send` clears the session ref
|
|
126
|
+
as soon as the iterator finishes.
|
|
127
|
+
"""
|
|
128
|
+
_ = ctx
|
|
129
|
+
if self._current_session is None:
|
|
130
|
+
return
|
|
131
|
+
# Best-effort — the loop has already shown an interrupt message.
|
|
132
|
+
with contextlib.suppress(TransportError):
|
|
133
|
+
await self._current_session.stop()
|
|
134
|
+
|
|
135
|
+
async def queue_message(self, ctx: Ctx, content: str) -> bool:
|
|
136
|
+
"""Append a message to the backend's FIFO queue while a turn is running."""
|
|
137
|
+
_ = ctx
|
|
138
|
+
if self._current_session is None:
|
|
139
|
+
return False
|
|
140
|
+
try:
|
|
141
|
+
await self._current_session.queue_message(content)
|
|
142
|
+
except TransportError:
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def toolbar_fragment(self, ctx: Ctx) -> str:
|
|
147
|
+
"""Empty for now — model is server-side, no per-app state worth surfacing."""
|
|
148
|
+
_ = self, ctx
|
|
149
|
+
return ""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Chat-specific slash commands.
|
|
2
|
+
|
|
3
|
+
Currently empty — the four global commands (``/help``, ``/clear``,
|
|
4
|
+
``/stop``, ``/resume``, ``/quit``) cover everything we want exposed to
|
|
5
|
+
end users. Add a new ``CommandSpec`` here and merge it into
|
|
6
|
+
:data:`CHAT_COMMANDS` to wire app-specific actions back in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from delos_cli.commands.base import CommandSpec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CHAT_COMMANDS: dict[str, CommandSpec] = {}
|