flowent 0.0.11 → 0.0.13
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.
- package/README.md +14 -0
- package/backend/README.md +14 -0
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +15 -0
- package/backend/src/flowent/channels.py +296 -0
- package/backend/src/flowent/cli.py +11 -0
- package/backend/src/flowent/llm.py +49 -1
- package/backend/src/flowent/main.py +143 -2
- package/backend/src/flowent/sandbox.py +18 -3
- package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
- package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +217 -8
- package/backend/src/flowent/tools.py +28 -5
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/conftest.py +21 -0
- package/backend/tests/test_agent_tools.py +99 -0
- package/backend/tests/test_channels.py +360 -0
- package/backend/tests/test_llm_providers.py +58 -0
- package/backend/tests/test_persistence.py +46 -0
- package/backend/tests/test_startup_requirements.py +48 -0
- package/backend/tests/test_workspace_chat.py +28 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-CEZrWoDG.css +2 -0
- package/dist/frontend/assets/index-S5a0Rkj1.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-C76K95ty.js +0 -81
- package/backend/src/flowent/static/assets/index-iUMNKvlU.css +0 -2
- package/dist/frontend/assets/index-C76K95ty.js +0 -81
- package/dist/frontend/assets/index-iUMNKvlU.css +0 -2
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@ A workflow orchestration platform for multi-agent collaboration.
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
+
Flowent requires Bubblewrap for local tool isolation. Install the system package
|
|
21
|
+
first:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
sudo apt-get install bubblewrap
|
|
25
|
+
```
|
|
26
|
+
|
|
20
27
|
Install the CLI globally:
|
|
21
28
|
|
|
22
29
|
```bash
|
|
@@ -35,6 +42,12 @@ Start the server:
|
|
|
35
42
|
flowent
|
|
36
43
|
```
|
|
37
44
|
|
|
45
|
+
Check system requirements:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
flowent doctor
|
|
49
|
+
```
|
|
50
|
+
|
|
38
51
|
## Docker Compose
|
|
39
52
|
|
|
40
53
|
Run the server with Docker Compose:
|
|
@@ -59,6 +72,7 @@ docker compose up
|
|
|
59
72
|
Install dependencies and start the local development server:
|
|
60
73
|
|
|
61
74
|
```bash
|
|
75
|
+
sudo apt-get install bubblewrap ripgrep
|
|
62
76
|
pnpm install
|
|
63
77
|
uv sync --project backend
|
|
64
78
|
pnpm dev
|
package/backend/README.md
CHANGED
|
@@ -17,6 +17,13 @@ A workflow orchestration platform for multi-agent collaboration.
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
+
Flowent requires Bubblewrap for local tool isolation. Install the system package
|
|
21
|
+
first:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
sudo apt-get install bubblewrap
|
|
25
|
+
```
|
|
26
|
+
|
|
20
27
|
Install the CLI globally:
|
|
21
28
|
|
|
22
29
|
```bash
|
|
@@ -35,6 +42,12 @@ Start the server:
|
|
|
35
42
|
flowent
|
|
36
43
|
```
|
|
37
44
|
|
|
45
|
+
Check system requirements:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
flowent doctor
|
|
49
|
+
```
|
|
50
|
+
|
|
38
51
|
## Docker Compose
|
|
39
52
|
|
|
40
53
|
Run the server with Docker Compose:
|
|
@@ -59,6 +72,7 @@ docker compose up
|
|
|
59
72
|
Install dependencies and start the local development server:
|
|
60
73
|
|
|
61
74
|
```bash
|
|
75
|
+
sudo apt-get install bubblewrap ripgrep
|
|
62
76
|
pnpm install
|
|
63
77
|
uv sync --project backend
|
|
64
78
|
pnpm dev
|
package/backend/pyproject.toml
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -13,6 +13,7 @@ from flowent.llm import (
|
|
|
13
13
|
ProviderConnection,
|
|
14
14
|
ToolCallDelta,
|
|
15
15
|
chunk_delta_content,
|
|
16
|
+
chunk_delta_reasoning,
|
|
16
17
|
chunk_delta_tool_calls,
|
|
17
18
|
stream_chat_chunks,
|
|
18
19
|
)
|
|
@@ -120,6 +121,7 @@ async def run_agent_stream(
|
|
|
120
121
|
yield AgentStreamEvent(event="start", data={"id": assistant_id})
|
|
121
122
|
|
|
122
123
|
final_content = ""
|
|
124
|
+
final_thinking = ""
|
|
123
125
|
|
|
124
126
|
round_number = 0
|
|
125
127
|
while True:
|
|
@@ -132,6 +134,18 @@ async def run_agent_stream(
|
|
|
132
134
|
async for chunk in stream_chat_chunks(
|
|
133
135
|
connection, conversation, completion=completion, tools=tool_specs()
|
|
134
136
|
):
|
|
137
|
+
reasoning = chunk_delta_reasoning(chunk)
|
|
138
|
+
if reasoning:
|
|
139
|
+
final_thinking += reasoning
|
|
140
|
+
logger.log(
|
|
141
|
+
TRACE_LEVEL,
|
|
142
|
+
"Agent stream reasoning id=%s content=%r",
|
|
143
|
+
assistant_id,
|
|
144
|
+
reasoning,
|
|
145
|
+
)
|
|
146
|
+
yield AgentStreamEvent(
|
|
147
|
+
event="thinking_delta", data={"content": reasoning}
|
|
148
|
+
)
|
|
135
149
|
content = chunk_delta_content(chunk)
|
|
136
150
|
if content:
|
|
137
151
|
round_content += content
|
|
@@ -172,6 +186,7 @@ async def run_agent_stream(
|
|
|
172
186
|
"author": "assistant",
|
|
173
187
|
"content": final_content,
|
|
174
188
|
"id": assistant_id,
|
|
189
|
+
"thinking": final_thinking,
|
|
175
190
|
}
|
|
176
191
|
},
|
|
177
192
|
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from flowent.storage import StateStore, StoredTelegramBot, StoredTelegramSession
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("flowent.channels")
|
|
17
|
+
|
|
18
|
+
TELEGRAM_MESSAGE_LIMIT = 4096
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChannelStatus(StrEnum):
|
|
22
|
+
DISABLED = "disabled"
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
RUNNING = "running"
|
|
25
|
+
STARTING = "starting"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TelegramTransport(Protocol):
|
|
29
|
+
async def get_updates(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
offset: int | None,
|
|
33
|
+
timeout: int,
|
|
34
|
+
token: str,
|
|
35
|
+
) -> list[dict[str, Any]]: ...
|
|
36
|
+
|
|
37
|
+
async def send_message(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
chat_id: str,
|
|
41
|
+
text: str,
|
|
42
|
+
token: str,
|
|
43
|
+
) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TelegramBotTransport:
|
|
47
|
+
async def get_updates(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
offset: int | None,
|
|
51
|
+
timeout: int,
|
|
52
|
+
token: str,
|
|
53
|
+
) -> list[dict[str, Any]]:
|
|
54
|
+
payload: dict[str, object] = {
|
|
55
|
+
"allowed_updates": ["message"],
|
|
56
|
+
"timeout": timeout,
|
|
57
|
+
}
|
|
58
|
+
if offset is not None:
|
|
59
|
+
payload["offset"] = offset
|
|
60
|
+
response = await asyncio.to_thread(
|
|
61
|
+
self._post,
|
|
62
|
+
token,
|
|
63
|
+
"getUpdates",
|
|
64
|
+
payload,
|
|
65
|
+
)
|
|
66
|
+
result = response.get("result")
|
|
67
|
+
if not isinstance(result, list):
|
|
68
|
+
raise RuntimeError("Updates could not be fetched.")
|
|
69
|
+
return [update for update in result if isinstance(update, dict)]
|
|
70
|
+
|
|
71
|
+
async def send_message(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
chat_id: str,
|
|
75
|
+
text: str,
|
|
76
|
+
token: str,
|
|
77
|
+
) -> None:
|
|
78
|
+
await asyncio.to_thread(
|
|
79
|
+
self._post,
|
|
80
|
+
token,
|
|
81
|
+
"sendMessage",
|
|
82
|
+
{"chat_id": chat_id, "text": text},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _post(
|
|
86
|
+
self,
|
|
87
|
+
token: str,
|
|
88
|
+
method: str,
|
|
89
|
+
payload: dict[str, object],
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
request = urllib.request.Request(
|
|
92
|
+
f"https://api.telegram.org/bot{token}/{method}",
|
|
93
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
94
|
+
headers={"Content-Type": "application/json"},
|
|
95
|
+
method="POST",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(request, timeout=40) as response:
|
|
99
|
+
raw_body = response.read().decode("utf-8")
|
|
100
|
+
except urllib.error.HTTPError as error:
|
|
101
|
+
raw_body = error.read().decode("utf-8")
|
|
102
|
+
try:
|
|
103
|
+
body = json.loads(raw_body)
|
|
104
|
+
except json.JSONDecodeError as decode_error:
|
|
105
|
+
raise RuntimeError("Telegram request failed.") from decode_error
|
|
106
|
+
description = body.get("description")
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
str(description) if description else "Telegram request failed."
|
|
109
|
+
) from error
|
|
110
|
+
except urllib.error.URLError as error:
|
|
111
|
+
raise RuntimeError(str(error.reason)) from error
|
|
112
|
+
|
|
113
|
+
body = json.loads(raw_body)
|
|
114
|
+
if not body.get("ok"):
|
|
115
|
+
description = body.get("description")
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
str(description) if description else "Telegram request failed."
|
|
118
|
+
)
|
|
119
|
+
return body
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ChannelRuntime:
|
|
124
|
+
error: str = ""
|
|
125
|
+
offset: int | None = None
|
|
126
|
+
status: ChannelStatus = ChannelStatus.DISABLED
|
|
127
|
+
task: asyncio.Task[None] | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TelegramBotManager:
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
message_handler: Callable[[str], Awaitable[str]],
|
|
135
|
+
store: StateStore,
|
|
136
|
+
telegram_transport: TelegramTransport | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.message_handler = message_handler
|
|
139
|
+
self.store = store
|
|
140
|
+
self.telegram_transport = telegram_transport or TelegramBotTransport()
|
|
141
|
+
self.runtime = ChannelRuntime()
|
|
142
|
+
|
|
143
|
+
def bot_with_status(self, bot: StoredTelegramBot) -> StoredTelegramBot:
|
|
144
|
+
if not bot.enabled:
|
|
145
|
+
return bot.model_copy(
|
|
146
|
+
update={"status": ChannelStatus.DISABLED, "error": ""}
|
|
147
|
+
)
|
|
148
|
+
if self.runtime.status == ChannelStatus.DISABLED:
|
|
149
|
+
return bot.model_copy(
|
|
150
|
+
update={"status": ChannelStatus.STARTING, "error": ""}
|
|
151
|
+
)
|
|
152
|
+
return bot.model_copy(
|
|
153
|
+
update={"status": self.runtime.status, "error": self.runtime.error}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def start_enabled(self) -> None:
|
|
157
|
+
await self.sync_bot(self.store.read_telegram_bot())
|
|
158
|
+
|
|
159
|
+
async def stop_all(self) -> None:
|
|
160
|
+
if self.runtime.task is not None:
|
|
161
|
+
self.runtime.task.cancel()
|
|
162
|
+
with suppress(asyncio.CancelledError):
|
|
163
|
+
await self.runtime.task
|
|
164
|
+
self.runtime = ChannelRuntime()
|
|
165
|
+
|
|
166
|
+
async def sync_bot(self, bot: StoredTelegramBot) -> None:
|
|
167
|
+
if not bot.enabled:
|
|
168
|
+
if self.runtime.task is not None:
|
|
169
|
+
self.runtime.task.cancel()
|
|
170
|
+
self.runtime.status = ChannelStatus.DISABLED
|
|
171
|
+
self.runtime.error = ""
|
|
172
|
+
return
|
|
173
|
+
if self.runtime.task is not None and not self.runtime.task.done():
|
|
174
|
+
return
|
|
175
|
+
self.runtime.status = ChannelStatus.STARTING
|
|
176
|
+
self.runtime.error = ""
|
|
177
|
+
self.runtime.task = asyncio.create_task(self._run_bot(bot))
|
|
178
|
+
|
|
179
|
+
async def poll_once(self, bot: StoredTelegramBot) -> None:
|
|
180
|
+
if not bot.enabled:
|
|
181
|
+
self.runtime.status = ChannelStatus.DISABLED
|
|
182
|
+
self.runtime.error = ""
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self.runtime.status = ChannelStatus.STARTING
|
|
186
|
+
self.runtime.error = ""
|
|
187
|
+
try:
|
|
188
|
+
updates = await self.telegram_transport.get_updates(
|
|
189
|
+
offset=self.runtime.offset,
|
|
190
|
+
timeout=30,
|
|
191
|
+
token=bot.bot_token,
|
|
192
|
+
)
|
|
193
|
+
self.runtime.status = ChannelStatus.RUNNING
|
|
194
|
+
for update in updates:
|
|
195
|
+
update_id = update.get("update_id")
|
|
196
|
+
if isinstance(update_id, int):
|
|
197
|
+
self.runtime.offset = max(self.runtime.offset or 0, update_id + 1)
|
|
198
|
+
await self._handle_telegram_update(bot, update)
|
|
199
|
+
except Exception as error:
|
|
200
|
+
self.runtime.status = ChannelStatus.ERROR
|
|
201
|
+
self.runtime.error = str(error) or "Connection failed."
|
|
202
|
+
logger.exception("Telegram polling failed")
|
|
203
|
+
|
|
204
|
+
async def _run_bot(self, bot: StoredTelegramBot) -> None:
|
|
205
|
+
while True:
|
|
206
|
+
await self.poll_once(bot)
|
|
207
|
+
if self.runtime.status == ChannelStatus.ERROR:
|
|
208
|
+
await asyncio.sleep(5)
|
|
209
|
+
|
|
210
|
+
async def _handle_telegram_update(
|
|
211
|
+
self,
|
|
212
|
+
bot: StoredTelegramBot,
|
|
213
|
+
update: dict[str, Any],
|
|
214
|
+
) -> None:
|
|
215
|
+
message = update.get("message")
|
|
216
|
+
if not isinstance(message, dict):
|
|
217
|
+
return
|
|
218
|
+
text = message.get("text")
|
|
219
|
+
if not isinstance(text, str) or text == "":
|
|
220
|
+
return
|
|
221
|
+
chat = message.get("chat")
|
|
222
|
+
sender = message.get("from")
|
|
223
|
+
chat_id = str(chat.get("id")) if isinstance(chat, dict) else ""
|
|
224
|
+
if not chat_id:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
session = self._telegram_session(
|
|
228
|
+
chat=chat if isinstance(chat, dict) else {},
|
|
229
|
+
message=text,
|
|
230
|
+
sender=sender if isinstance(sender, dict) else {},
|
|
231
|
+
)
|
|
232
|
+
if not self._is_approved(session.chat_id):
|
|
233
|
+
self.store.save_telegram_session(session)
|
|
234
|
+
await self._send_telegram_reply(
|
|
235
|
+
bot,
|
|
236
|
+
chat_id,
|
|
237
|
+
"Request received. Approve this conversation in Flowent.",
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
self.store.save_telegram_session(
|
|
242
|
+
session.model_copy(update={"status": "approved"})
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
reply = await self.message_handler(text)
|
|
246
|
+
await self._send_telegram_reply(bot, chat_id, reply)
|
|
247
|
+
|
|
248
|
+
def _is_approved(self, chat_id: str) -> bool:
|
|
249
|
+
return any(
|
|
250
|
+
session.chat_id == chat_id and session.status == "approved"
|
|
251
|
+
for session in self.store.read_telegram_bot().sessions
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _telegram_session(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
chat: dict[str, Any],
|
|
258
|
+
message: str,
|
|
259
|
+
sender: dict[str, Any],
|
|
260
|
+
) -> StoredTelegramSession:
|
|
261
|
+
first_name = str(sender.get("first_name") or "")
|
|
262
|
+
last_name = str(sender.get("last_name") or "")
|
|
263
|
+
title = str(chat.get("title") or "")
|
|
264
|
+
display_name = title or " ".join(
|
|
265
|
+
part for part in [first_name, last_name] if part
|
|
266
|
+
)
|
|
267
|
+
return StoredTelegramSession(
|
|
268
|
+
chat_id=str(chat.get("id") or ""),
|
|
269
|
+
display_name=display_name,
|
|
270
|
+
recent_message=message,
|
|
271
|
+
status="pending",
|
|
272
|
+
user_id=str(sender.get("id") or ""),
|
|
273
|
+
username=str(sender.get("username") or ""),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
async def _send_telegram_reply(
|
|
277
|
+
self,
|
|
278
|
+
bot: StoredTelegramBot,
|
|
279
|
+
chat_id: str,
|
|
280
|
+
content: str,
|
|
281
|
+
) -> None:
|
|
282
|
+
for part in split_telegram_message(content):
|
|
283
|
+
await self.telegram_transport.send_message(
|
|
284
|
+
chat_id=chat_id,
|
|
285
|
+
text=part,
|
|
286
|
+
token=bot.bot_token,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def split_telegram_message(content: str) -> list[str]:
|
|
291
|
+
if content == "":
|
|
292
|
+
return [""]
|
|
293
|
+
return [
|
|
294
|
+
content[index : index + TELEGRAM_MESSAGE_LIMIT]
|
|
295
|
+
for index in range(0, len(content), TELEGRAM_MESSAGE_LIMIT)
|
|
296
|
+
]
|
|
@@ -14,6 +14,7 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
14
14
|
subparsers = parser.add_subparsers(dest="command")
|
|
15
15
|
apply_patch_parser = subparsers.add_parser("apply-patch", help=argparse.SUPPRESS)
|
|
16
16
|
apply_patch_parser.add_argument("--cwd", required=True)
|
|
17
|
+
subparsers.add_parser("doctor", help="Check system requirements")
|
|
17
18
|
parser.add_argument(
|
|
18
19
|
"--host",
|
|
19
20
|
"--hostname",
|
|
@@ -47,6 +48,16 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
47
48
|
run_apply_patch_cli(cwd=Path(args.cwd), patch=sys.stdin.read())
|
|
48
49
|
)
|
|
49
50
|
|
|
51
|
+
if args.command == "doctor":
|
|
52
|
+
from flowent.sandbox import SANDBOX_INSTALL_HINT, sandbox_binary
|
|
53
|
+
|
|
54
|
+
bwrap = sandbox_binary()
|
|
55
|
+
if bwrap:
|
|
56
|
+
print(f"Sandbox: {bwrap}")
|
|
57
|
+
raise SystemExit(0)
|
|
58
|
+
print(f"Sandbox: missing. {SANDBOX_INSTALL_HINT}", file=sys.stderr)
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
|
|
50
61
|
if args.version:
|
|
51
62
|
try:
|
|
52
63
|
from importlib.metadata import version
|
|
@@ -15,6 +15,14 @@ class ProviderFormat(StrEnum):
|
|
|
15
15
|
GEMINI = "gemini"
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class ReasoningEffort(StrEnum):
|
|
19
|
+
DEFAULT = "default"
|
|
20
|
+
LOW = "low"
|
|
21
|
+
MEDIUM = "medium"
|
|
22
|
+
HIGH = "high"
|
|
23
|
+
XHIGH = "xhigh"
|
|
24
|
+
|
|
25
|
+
|
|
18
26
|
class ProviderConnection(BaseModel):
|
|
19
27
|
model_config = ConfigDict(extra="forbid")
|
|
20
28
|
|
|
@@ -23,6 +31,7 @@ class ProviderConnection(BaseModel):
|
|
|
23
31
|
model: str = Field(min_length=1)
|
|
24
32
|
secret_reference: str = Field(min_length=1)
|
|
25
33
|
base_url: str | None = None
|
|
34
|
+
reasoning_effort: ReasoningEffort = ReasoningEffort.DEFAULT
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
class ChatMessage(BaseModel):
|
|
@@ -132,14 +141,17 @@ def build_litellm_request(
|
|
|
132
141
|
request["stream"] = True
|
|
133
142
|
if connection.base_url:
|
|
134
143
|
request["api_base"] = connection.base_url
|
|
144
|
+
if connection.reasoning_effort != ReasoningEffort.DEFAULT:
|
|
145
|
+
request["reasoning_effort"] = connection.reasoning_effort.value
|
|
135
146
|
logger.log(
|
|
136
147
|
TRACE_LEVEL,
|
|
137
|
-
"Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s messages=%r",
|
|
148
|
+
"Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s reasoning_effort=%s messages=%r",
|
|
138
149
|
connection.provider,
|
|
139
150
|
connection.model,
|
|
140
151
|
connection.base_url or "",
|
|
141
152
|
stream,
|
|
142
153
|
bool(tools),
|
|
154
|
+
connection.reasoning_effort,
|
|
143
155
|
request_messages,
|
|
144
156
|
)
|
|
145
157
|
return request
|
|
@@ -188,6 +200,42 @@ def chunk_delta_content(chunk: Any) -> str:
|
|
|
188
200
|
return content if isinstance(content, str) else ""
|
|
189
201
|
|
|
190
202
|
|
|
203
|
+
def chunk_delta_reasoning(chunk: Any) -> str:
|
|
204
|
+
try:
|
|
205
|
+
choice = chunk.choices[0]
|
|
206
|
+
delta = choice.delta
|
|
207
|
+
except (AttributeError, IndexError, TypeError):
|
|
208
|
+
try:
|
|
209
|
+
delta = chunk["choices"][0]["delta"]
|
|
210
|
+
except (KeyError, IndexError, TypeError):
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
content = value_at(delta, "reasoning_content", "")
|
|
214
|
+
if isinstance(content, str) and content:
|
|
215
|
+
return content
|
|
216
|
+
|
|
217
|
+
return reasoning_text_from_items(
|
|
218
|
+
[
|
|
219
|
+
*list(value_at(delta, "thinking_blocks", []) or []),
|
|
220
|
+
*list(value_at(delta, "reasoning_items", []) or []),
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def reasoning_text_from_items(items: Sequence[Any]) -> str:
|
|
226
|
+
parts: list[str] = []
|
|
227
|
+
for item in items:
|
|
228
|
+
for key in ["thinking", "text", "content", "summary"]:
|
|
229
|
+
value = value_at(item, key, "")
|
|
230
|
+
if isinstance(value, str) and value:
|
|
231
|
+
parts.append(value)
|
|
232
|
+
elif isinstance(value, Sequence) and not isinstance(
|
|
233
|
+
value, str | bytes | bytearray
|
|
234
|
+
):
|
|
235
|
+
parts.append(reasoning_text_from_items(value))
|
|
236
|
+
return "".join(parts)
|
|
237
|
+
|
|
238
|
+
|
|
191
239
|
def chunk_delta_tool_calls(chunk: Any) -> list[ToolCallDelta]:
|
|
192
240
|
try:
|
|
193
241
|
choice = chunk.choices[0]
|