File size: 17,417 Bytes
0e02b97
 
d0252db
114361d
ec335c4
a8ee91f
7a7b1d3
 
0e02b97
ccb1848
0e02b97
86c5368
 
 
 
 
 
7a7b1d3
 
 
 
 
 
 
 
 
86c5368
0e02b97
 
a8ee91f
66bc790
0e02b97
114361d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e02b97
 
 
 
27b075e
 
 
 
 
 
f7c8c98
 
 
27b075e
bedb8e2
0e02b97
 
bf45c7d
27b075e
 
 
66bc790
f7c8c98
 
 
 
27b075e
114361d
 
b8c6bee
 
 
 
114361d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e02b97
 
66bc790
2bae1d8
0e02b97
 
bedb8e2
2bae1d8
66bc790
 
bedb8e2
 
 
7a7b1d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66bc790
7a7b1d3
27b075e
 
 
10ac258
 
 
27b075e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49e2d72
 
 
 
 
 
 
 
 
 
 
7196e74
 
49e2d72
 
258e41b
 
 
 
 
 
 
 
 
 
 
 
 
ec335c4
7a7b1d3
ec335c4
 
 
49e2d72
ec335c4
 
 
100e82d
b8c6bee
 
 
ec335c4
0e02b97
10ac258
 
 
f7c8c98
10ac258
 
 
0e02b97
 
10ac258
0e02b97
f7c8c98
4c7374d
0e02b97
 
f7c8c98
 
 
 
 
 
d0252db
0e02b97
 
 
bedb8e2
0e02b97
d0252db
fc0265f
 
 
 
 
 
008a8e2
 
f7c8c98
 
008a8e2
 
e6ecb98
 
a8ee91f
e6ecb98
a8ee91f
 
 
 
 
 
008a8e2
a8ee91f
f7c8c98
008a8e2
9ecde57
f7c8c98
 
258e41b
 
e6ecb98
258e41b
 
9ecde57
 
a8ee91f
 
d0252db
 
 
 
a8ee91f
 
 
008a8e2
7bd7366
a8ee91f
 
 
 
930ff68
a8ee91f
258e41b
a8ee91f
e6ecb98
 
a8ee91f
e6ecb98
a8ee91f
 
 
 
 
d0252db
 
 
a8ee91f
 
d0252db
a8ee91f
d0252db
a8ee91f
 
 
 
d0252db
a8ee91f
258e41b
e6ecb98
 
a8ee91f
e6ecb98
a8ee91f
 
 
 
 
d0252db
 
 
a8ee91f
 
d0252db
a8ee91f
d0252db
a8ee91f
 
0e02b97
d0252db
 
 
b8c6bee
d0252db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49e2d72
 
 
d0252db
b8c6bee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7c8c98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0252db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258e41b
d0252db
 
f7c8c98
 
b8c6bee
d0252db
 
 
 
 
 
 
 
49e2d72
 
 
d0252db
 
 
49e2d72
 
 
d0252db
 
 
 
 
 
49e2d72
 
 
d0252db
 
258e41b
f7c8c98
 
b8c6bee
d0252db
 
 
 
 
 
 
 
49e2d72
 
 
d0252db
 
 
49e2d72
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
from __future__ import annotations

from typing import List, AsyncIterator
from dataclasses import dataclass, field
import json
import asyncio
import shutil
from pathlib import Path

from ollama import AsyncClient, ChatResponse, Message

from .config import (
    MAX_TOOL_CALL_DEPTH,
    MODEL_NAME,
    NUM_CTX,
    OLLAMA_HOST,
    SYSTEM_PROMPT,
    UPLOAD_DIR,
)
from .db import (
    Conversation,
    Message as DBMessage,
    User,
    _db,
    init_db,
    add_document,
)
from .log import get_logger
from .schema import Msg
from .tools import execute_terminal, execute_terminal_async, set_vm
from .vm import VMRegistry


@dataclass
class _SessionData:
    """Shared state for each conversation session."""

    lock: asyncio.Lock = field(default_factory=asyncio.Lock)
    state: str = "idle"
    tool_task: asyncio.Task | None = None


_SESSION_DATA: dict[int, _SessionData] = {}


def _get_session_data(conv_id: int) -> _SessionData:
    data = _SESSION_DATA.get(conv_id)
    if data is None:
        data = _SessionData()
        _SESSION_DATA[conv_id] = data
    return data


_LOG = get_logger(__name__)


class ChatSession:
    def __init__(
        self,
        user: str = "default",
        session: str = "default",
        host: str = OLLAMA_HOST,
        model: str = MODEL_NAME,
        *,
        system_prompt: str = SYSTEM_PROMPT,
        tools: list[callable] | None = None,
    ) -> None:
        init_db()
        self._client = AsyncClient(host=host)
        self._model = model
        self._user, _ = User.get_or_create(username=user)
        self._conversation, _ = Conversation.get_or_create(
            user=self._user, session_name=session
        )
        self._vm = None
        self._system_prompt = system_prompt
        self._tools = tools or [execute_terminal]
        self._tool_funcs = {func.__name__: func for func in self._tools}
        self._current_tool_name: str | None = None
        self._messages: List[Msg] = self._load_history()
        self._data = _get_session_data(self._conversation.id)
        self._lock = self._data.lock
        self._prompt_queue: asyncio.Queue[
            tuple[str, asyncio.Queue[str | None]]
        ] = asyncio.Queue()
        self._worker: asyncio.Task | None = None

    # Shared state properties -------------------------------------------------

    @property
    def _state(self) -> str:
        return self._data.state

    @_state.setter
    def _state(self, value: str) -> None:
        self._data.state = value

    @property
    def _tool_task(self) -> asyncio.Task | None:
        return self._data.tool_task

    @_tool_task.setter
    def _tool_task(self, task: asyncio.Task | None) -> None:
        self._data.tool_task = task

    async def __aenter__(self) -> "ChatSession":
        self._vm = VMRegistry.acquire(self._user.username)
        set_vm(self._vm)
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        set_vm(None)
        if self._vm:
            VMRegistry.release(self._user.username)
        if not _db.is_closed():
            _db.close()

    def upload_document(self, file_path: str) -> str:
        """Save a document for later access inside the VM.

        The file is copied into ``UPLOAD_DIR`` and recorded in the database. The
        returned path is the location inside the VM (prefixed with ``/data``).
        """

        src = Path(file_path)
        if not src.exists():
            raise FileNotFoundError(file_path)

        dest = Path(UPLOAD_DIR) / self._user.username
        dest.mkdir(parents=True, exist_ok=True)
        target = dest / src.name
        shutil.copy(src, target)
        add_document(self._user.username, str(target), src.name)
        return f"/data/{src.name}"

    def _load_history(self) -> List[Msg]:
        messages: List[Msg] = []
        for msg in self._conversation.messages.order_by(DBMessage.created_at):
            if msg.role == "system":
                # Skip persisted system prompts from older versions
                continue
            if msg.role == "assistant":
                try:
                    calls = json.loads(msg.content)
                except json.JSONDecodeError:
                    messages.append({"role": "assistant", "content": msg.content})
                else:
                    messages.append(
                        {
                            "role": "assistant",
                            "tool_calls": [Message.ToolCall(**c) for c in calls],
                        }
                    )
            elif msg.role == "user":
                messages.append({"role": "user", "content": msg.content})
            else:
                messages.append({"role": "tool", "content": msg.content})
        return messages

    # ------------------------------------------------------------------
    @staticmethod
    def _serialize_tool_calls(calls: List[Message.ToolCall]) -> str:
        """Convert tool calls to a JSON string for storage or output."""

        return json.dumps([c.model_dump() for c in calls])

    @staticmethod
    def _format_output(message: Message) -> str:
        """Return tool calls as JSON or message content if present."""

        # if message.tool_calls:
        #     return ChatSession._serialize_tool_calls(message.tool_calls)
        return message.content or ""

    @staticmethod
    def _remove_tool_placeholder(messages: List[Msg]) -> None:
        """Remove the pending placeholder tool message if present."""

        for i in range(len(messages) - 1, -1, -1):
            msg = messages[i]
            if (
                msg.get("role") == "tool"
                and msg.get("content") == "Awaiting tool response..."
            ):
                messages.pop(i)
                break

    @staticmethod
    def _store_assistant_message(conversation: Conversation, message: Message) -> None:
        """Persist assistant messages, storing tool calls when present."""

        if message.tool_calls:
            content = ChatSession._serialize_tool_calls(message.tool_calls)
        else:
            content = message.content or ""

        if content.strip():
            DBMessage.create(
                conversation=conversation, role="assistant", content=content
            )

    async def ask(self, messages: List[Msg], *, think: bool = True) -> ChatResponse:
        """Send a chat request, automatically prepending the system prompt."""

        if not messages or messages[0].get("role") != "system":
            payload = [{"role": "system", "content": self._system_prompt}, *messages]
        else:
            payload = messages

        return await self._client.chat(
            self._model,
            messages=payload,
            think=think,
            tools=self._tools,
            options={"num_ctx": NUM_CTX},
        )

    async def _run_tool_async(self, func, **kwargs) -> str:
        if asyncio.iscoroutinefunction(func):
            return await func(**kwargs)
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, lambda: func(**kwargs))

    async def _handle_tool_calls_stream(
        self,
        messages: List[Msg],
        response: ChatResponse,
        conversation: Conversation,
        depth: int = 0,
    ) -> AsyncIterator[ChatResponse]:
        if not response.message.tool_calls:
            if response.message.content:
                yield response
            async with self._lock:
                self._state = "idle"
            return
        while depth < MAX_TOOL_CALL_DEPTH and response.message.tool_calls:
            for call in response.message.tool_calls:
                func = self._tool_funcs.get(call.function.name)
                if not func:
                    _LOG.warning("Unsupported tool call: %s", call.function.name)
                    result = f"Unsupported tool: {call.function.name}"
                    name = (
                        "junior" if call.function.name == "send_to_junior" else call.function.name
                    )
                    messages.append({"role": "tool", "name": name, "content": result})
                    DBMessage.create(
                        conversation=conversation,
                        role="tool",
                        content=result,
                    )
                    continue

                exec_task = asyncio.create_task(
                    self._run_tool_async(func, **call.function.arguments)
                )

                self._current_tool_name = call.function.name

                placeholder = {
                    "role": "tool",
                    "name": "junior" if call.function.name == "send_to_junior" else call.function.name,
                    "content": "Awaiting tool response...",
                }
                messages.append(placeholder)

                follow_task = asyncio.create_task(self.ask(messages, think=True))

                async with self._lock:
                    self._state = "awaiting_tool"
                    self._tool_task = exec_task

                done, _ = await asyncio.wait(
                    {exec_task, follow_task},
                    return_when=asyncio.FIRST_COMPLETED,
                )

                if exec_task in done:
                    follow_task.cancel()
                    try:
                        await follow_task
                    except asyncio.CancelledError:
                        pass
                    self._remove_tool_placeholder(messages)
                    result = await exec_task
                    name = (
                        "junior" if call.function.name == "send_to_junior" else call.function.name
                    )
                    messages.append({"role": "tool", "name": name, "content": result})
                    DBMessage.create(
                        conversation=conversation,
                        role="tool",
                        content=result,
                    )
                    async with self._lock:
                        self._state = "generating"
                        self._tool_task = None
                    nxt = await self.ask(messages, think=True)
                    self._store_assistant_message(conversation, nxt.message)
                    messages.append(nxt.message.model_dump())
                    response = nxt
                    yield nxt
                else:
                    followup = await follow_task
                    self._store_assistant_message(conversation, followup.message)
                    messages.append(followup.message.model_dump())
                    yield followup
                    result = await exec_task
                    self._remove_tool_placeholder(messages)
                    name = (
                        "junior" if call.function.name == "send_to_junior" else call.function.name
                    )
                    messages.append({"role": "tool", "name": name, "content": result})
                    DBMessage.create(
                        conversation=conversation,
                        role="tool",
                        content=result,
                    )
                    async with self._lock:
                        self._state = "generating"
                        self._tool_task = None
                    nxt = await self.ask(messages, think=True)
                    self._store_assistant_message(conversation, nxt.message)
                    messages.append(nxt.message.model_dump())
                    response = nxt
                    yield nxt

                depth += 1

        async with self._lock:
            self._state = "idle"

    async def _generate_stream(self, prompt: str) -> AsyncIterator[str]:
        async with self._lock:
            if self._state == "awaiting_tool" and self._tool_task:
                async for part in self._chat_during_tool(prompt):
                    yield part
                return
            self._state = "generating"

        DBMessage.create(conversation=self._conversation, role="user", content=prompt)
        self._messages.append({"role": "user", "content": prompt})

        response = await self.ask(self._messages)
        self._messages.append(response.message.model_dump())
        self._store_assistant_message(self._conversation, response.message)

        async for resp in self._handle_tool_calls_stream(
            self._messages, response, self._conversation
        ):
            text = self._format_output(resp.message)
            if text:
                yield text

    async def _process_prompt_queue(self) -> None:
        try:
            while not self._prompt_queue.empty():
                prompt, result_q = await self._prompt_queue.get()
                try:
                    async for part in self._generate_stream(prompt):
                        await result_q.put(part)
                except Exception as exc:  # pragma: no cover - unforeseen errors
                    _LOG.exception("Error processing prompt: %s", exc)
                    await result_q.put(f"Error: {exc}")
                finally:
                    await result_q.put(None)
        finally:
            self._worker = None

    async def chat_stream(self, prompt: str) -> AsyncIterator[str]:
        result_q: asyncio.Queue[str | None] = asyncio.Queue()
        await self._prompt_queue.put((prompt, result_q))
        if not self._worker or self._worker.done():
            self._worker = asyncio.create_task(self._process_prompt_queue())

        while True:
            part = await result_q.get()
            if part is None:
                break
            yield part

    async def continue_stream(self) -> AsyncIterator[str]:
        async with self._lock:
            if self._state != "idle":
                return
            self._state = "generating"

        response = await self.ask(self._messages)
        self._messages.append(response.message.model_dump())
        self._store_assistant_message(self._conversation, response.message)

        async for resp in self._handle_tool_calls_stream(
            self._messages, response, self._conversation
        ):
            text = self._format_output(resp.message)
            if text:
                yield text

    async def _chat_during_tool(self, prompt: str) -> AsyncIterator[str]:
        DBMessage.create(conversation=self._conversation, role="user", content=prompt)
        self._messages.append({"role": "user", "content": prompt})

        user_task = asyncio.create_task(self.ask(self._messages))
        exec_task = self._tool_task

        done, _ = await asyncio.wait(
            {exec_task, user_task},
            return_when=asyncio.FIRST_COMPLETED,
        )

        if exec_task in done:
            user_task.cancel()
            try:
                await user_task
            except asyncio.CancelledError:
                pass
            self._remove_tool_placeholder(self._messages)
            result = await exec_task
            self._tool_task = None
            name = self._current_tool_name or "tool"
            self._current_tool_name = None
            self._messages.append({"role": "tool", "name": name, "content": result})
            DBMessage.create(
                conversation=self._conversation, role="tool", content=result
            )
            async with self._lock:
                self._state = "generating"
            nxt = await self.ask(self._messages, think=True)
            self._store_assistant_message(self._conversation, nxt.message)
            self._messages.append(nxt.message.model_dump())
            text = self._format_output(nxt.message)
            if text:
                yield text
            async for part in self._handle_tool_calls_stream(
                self._messages, nxt, self._conversation
            ):
                text = self._format_output(part.message)
                if text:
                    yield text
        else:
            resp = await user_task
            self._store_assistant_message(self._conversation, resp.message)
            self._messages.append(resp.message.model_dump())
            async with self._lock:
                self._state = "awaiting_tool"
            text = self._format_output(resp.message)
            if text:
                yield text
            result = await exec_task
            self._tool_task = None
            self._remove_tool_placeholder(self._messages)
            name = self._current_tool_name or "tool"
            self._current_tool_name = None
            self._messages.append({"role": "tool", "name": name, "content": result})
            DBMessage.create(
                conversation=self._conversation, role="tool", content=result
            )
            async with self._lock:
                self._state = "generating"
            nxt = await self.ask(self._messages, think=True)
            self._store_assistant_message(self._conversation, nxt.message)
            self._messages.append(nxt.message.model_dump())
            text = self._format_output(nxt.message)
            if text:
                yield text
            async for part in self._handle_tool_calls_stream(
                self._messages, nxt, self._conversation
            ):
                text = self._format_output(part.message)
                if text:
                    yield text