flowent 0.1.5 → 0.2.0
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/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__/approval.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__/compact.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__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.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__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.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 +94 -33
- package/backend/src/flowent/llm.py +126 -6
- package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- 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_approval.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_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.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_skills.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 +39 -0
- package/backend/tests/test_agent_tools.py +136 -0
- package/backend/tests/test_llm_providers.py +161 -0
- package/backend/tests/test_workspace_chat.py +52 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
- package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-Cl20cARb.css +0 -2
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +0 -81
- package/dist/frontend/assets/index-Cl20cARb.css +0 -2
- package/dist/frontend/assets/index-dsDDsEym.js +0 -81
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -146,56 +146,102 @@ async def run_agent_stream(
|
|
|
146
146
|
while True:
|
|
147
147
|
round_number += 1
|
|
148
148
|
logger.debug("Agent round started id=%s round=%s", assistant_id, round_number)
|
|
149
|
+
logger.info(
|
|
150
|
+
"Agent model call started id=%s round=%s conversation_messages=%s",
|
|
151
|
+
assistant_id,
|
|
152
|
+
round_number,
|
|
153
|
+
len(conversation),
|
|
154
|
+
)
|
|
149
155
|
yield AgentStreamEvent(event="output_start", data={"index": round_number})
|
|
150
156
|
round_content = ""
|
|
151
157
|
pending: dict[int, PendingToolCall] = {}
|
|
158
|
+
chunk_count = 0
|
|
159
|
+
content_delta_count = 0
|
|
160
|
+
reasoning_delta_count = 0
|
|
161
|
+
tool_delta_count = 0
|
|
152
162
|
|
|
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
|
-
content
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
163
|
+
try:
|
|
164
|
+
async for chunk in stream_chat_chunks(
|
|
165
|
+
connection,
|
|
166
|
+
conversation,
|
|
167
|
+
completion=completion,
|
|
168
|
+
tools=[*tool_specs(), *list(extra_tool_specs or [])],
|
|
169
|
+
):
|
|
170
|
+
chunk_count += 1
|
|
171
|
+
reasoning = chunk_delta_reasoning(chunk)
|
|
172
|
+
if reasoning:
|
|
173
|
+
reasoning_delta_count += 1
|
|
174
|
+
final_thinking += reasoning
|
|
175
|
+
logger.log(
|
|
176
|
+
TRACE_LEVEL,
|
|
177
|
+
"Agent stream reasoning id=%s round=%s content=%r",
|
|
178
|
+
assistant_id,
|
|
179
|
+
round_number,
|
|
180
|
+
reasoning,
|
|
181
|
+
)
|
|
182
|
+
yield AgentStreamEvent(
|
|
183
|
+
event="thinking_delta", data={"content": reasoning}
|
|
184
|
+
)
|
|
185
|
+
content = chunk_delta_content(chunk)
|
|
186
|
+
if content:
|
|
187
|
+
content_delta_count += 1
|
|
188
|
+
round_content += content
|
|
189
|
+
final_content += content
|
|
190
|
+
logger.log(
|
|
191
|
+
TRACE_LEVEL,
|
|
192
|
+
"Agent stream delta id=%s round=%s content=%r",
|
|
193
|
+
assistant_id,
|
|
194
|
+
round_number,
|
|
195
|
+
content,
|
|
196
|
+
)
|
|
197
|
+
yield AgentStreamEvent(event="delta", data={"content": content})
|
|
198
|
+
for delta in chunk_delta_tool_calls(chunk):
|
|
199
|
+
tool_delta_count += 1
|
|
200
|
+
pending.setdefault(delta.index, PendingToolCall()).apply_delta(
|
|
201
|
+
delta
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
logger.exception(
|
|
205
|
+
"Agent model call failed id=%s round=%s chunk_count=%s content_deltas=%s reasoning_deltas=%s tool_deltas=%s conversation_messages=%s",
|
|
206
|
+
assistant_id,
|
|
207
|
+
round_number,
|
|
208
|
+
chunk_count,
|
|
209
|
+
content_delta_count,
|
|
210
|
+
reasoning_delta_count,
|
|
211
|
+
tool_delta_count,
|
|
212
|
+
len(conversation),
|
|
213
|
+
)
|
|
214
|
+
raise
|
|
184
215
|
|
|
185
216
|
tool_calls = [pending[index] for index in sorted(pending)]
|
|
217
|
+
logger.info(
|
|
218
|
+
"Agent model call completed id=%s round=%s chunk_count=%s content_deltas=%s reasoning_deltas=%s tool_deltas=%s tool_calls=%s content_length=%s decision=%s",
|
|
219
|
+
assistant_id,
|
|
220
|
+
round_number,
|
|
221
|
+
chunk_count,
|
|
222
|
+
content_delta_count,
|
|
223
|
+
reasoning_delta_count,
|
|
224
|
+
tool_delta_count,
|
|
225
|
+
len(tool_calls),
|
|
226
|
+
len(round_content),
|
|
227
|
+
"run_tools" if tool_calls else "final_response",
|
|
228
|
+
)
|
|
186
229
|
logger.log(
|
|
187
230
|
TRACE_LEVEL,
|
|
188
|
-
"Agent round tool calls id=%s tool_calls=%r",
|
|
231
|
+
"Agent round tool calls id=%s round=%s tool_calls=%r",
|
|
189
232
|
assistant_id,
|
|
233
|
+
round_number,
|
|
190
234
|
tool_calls,
|
|
191
235
|
)
|
|
192
236
|
if not tool_calls:
|
|
193
237
|
if not final_content and not final_thinking:
|
|
194
238
|
raise RuntimeError(EMPTY_MODEL_RESPONSE_ERROR)
|
|
195
239
|
logger.info(
|
|
196
|
-
"Agent response completed id=%s content_length=%s",
|
|
240
|
+
"Agent response completed id=%s rounds=%s content_length=%s thinking_length=%s decision=final_response",
|
|
197
241
|
assistant_id,
|
|
242
|
+
round_number,
|
|
198
243
|
len(final_content),
|
|
244
|
+
len(final_thinking),
|
|
199
245
|
)
|
|
200
246
|
logger.log(
|
|
201
247
|
TRACE_LEVEL,
|
|
@@ -301,9 +347,24 @@ async def run_agent_stream(
|
|
|
301
347
|
)
|
|
302
348
|
conversation.append(tool_result_message(tool_call_id, result_content))
|
|
303
349
|
|
|
350
|
+
logger.info(
|
|
351
|
+
"Agent continuing after tools id=%s completed_round=%s tool_results=%s conversation_messages=%s decision=continue",
|
|
352
|
+
assistant_id,
|
|
353
|
+
round_number,
|
|
354
|
+
len(tool_calls),
|
|
355
|
+
len(conversation),
|
|
356
|
+
)
|
|
357
|
+
|
|
304
358
|
if context_compactor is not None:
|
|
305
359
|
compaction = await context_compactor(conversation)
|
|
306
360
|
if compaction is not None:
|
|
361
|
+
logger.info(
|
|
362
|
+
"Agent context optimized id=%s round=%s conversation_messages_before=%s conversation_messages_after=%s",
|
|
363
|
+
assistant_id,
|
|
364
|
+
round_number,
|
|
365
|
+
len(conversation),
|
|
366
|
+
len(compaction.conversation),
|
|
367
|
+
)
|
|
307
368
|
conversation = [dict(message) for message in compaction.conversation]
|
|
308
369
|
yield AgentStreamEvent(
|
|
309
370
|
event="context_optimized",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
|
|
3
4
|
from enum import StrEnum
|
|
4
|
-
from typing import Any, Literal, Protocol
|
|
5
|
+
from typing import Any, Literal, Protocol, cast
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
5
7
|
|
|
6
8
|
from pydantic import BaseModel, ConfigDict, Field
|
|
7
9
|
|
|
@@ -66,12 +68,26 @@ class ModelListCallable(Protocol):
|
|
|
66
68
|
logger = logging.getLogger("flowent.llm")
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
class LLMStreamError(RuntimeError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
69
75
|
MODEL_PREFIXES: dict[ProviderFormat, str] = {
|
|
70
76
|
ProviderFormat.OPENAI: "openai",
|
|
71
77
|
ProviderFormat.OPENAI_RESPONSES: "openai",
|
|
72
78
|
ProviderFormat.ANTHROPIC: "anthropic",
|
|
73
79
|
ProviderFormat.GEMINI: "gemini",
|
|
74
80
|
}
|
|
81
|
+
_litellm_stream_error_patch_installed = False
|
|
82
|
+
|
|
83
|
+
PROVIDER_API_VERSIONS: dict[ProviderFormat, str] = {
|
|
84
|
+
ProviderFormat.OPENAI: "v1",
|
|
85
|
+
ProviderFormat.OPENAI_RESPONSES: "v1",
|
|
86
|
+
ProviderFormat.ANTHROPIC: "v1",
|
|
87
|
+
ProviderFormat.GEMINI: "v1beta",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
VERSION_PATH_SEGMENT = re.compile(r"^v\d+(?:[a-z]+)?$", re.IGNORECASE)
|
|
75
91
|
|
|
76
92
|
|
|
77
93
|
def provider_model_name(connection: ProviderConnection) -> str:
|
|
@@ -82,6 +98,40 @@ def provider_litellm_name(provider: ProviderFormat) -> str:
|
|
|
82
98
|
return MODEL_PREFIXES[provider]
|
|
83
99
|
|
|
84
100
|
|
|
101
|
+
def normalize_provider_base_url(
|
|
102
|
+
provider: ProviderFormat, base_url: str | None
|
|
103
|
+
) -> str | None:
|
|
104
|
+
if base_url is None:
|
|
105
|
+
return None
|
|
106
|
+
raw_base_url = base_url.strip()
|
|
107
|
+
if not raw_base_url:
|
|
108
|
+
return None
|
|
109
|
+
if raw_base_url.endswith("#"):
|
|
110
|
+
return raw_base_url[:-1].rstrip("/") or None
|
|
111
|
+
|
|
112
|
+
trimmed_base_url = raw_base_url.rstrip("/")
|
|
113
|
+
parsed_base_url = urlsplit(trimmed_base_url)
|
|
114
|
+
path_segments = [segment for segment in parsed_base_url.path.split("/") if segment]
|
|
115
|
+
if any(VERSION_PATH_SEGMENT.fullmatch(segment) for segment in path_segments):
|
|
116
|
+
return trimmed_base_url
|
|
117
|
+
|
|
118
|
+
version = PROVIDER_API_VERSIONS[provider]
|
|
119
|
+
if parsed_base_url.scheme and parsed_base_url.netloc:
|
|
120
|
+
path = parsed_base_url.path.rstrip("/")
|
|
121
|
+
normalized_path = f"{path}/{version}" if path else f"/{version}"
|
|
122
|
+
return urlunsplit(
|
|
123
|
+
(
|
|
124
|
+
parsed_base_url.scheme,
|
|
125
|
+
parsed_base_url.netloc,
|
|
126
|
+
normalized_path,
|
|
127
|
+
parsed_base_url.query,
|
|
128
|
+
parsed_base_url.fragment,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return f"{trimmed_base_url}/{version}"
|
|
133
|
+
|
|
134
|
+
|
|
85
135
|
def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
|
|
86
136
|
prefix = f"{provider_litellm_name(provider)}/"
|
|
87
137
|
if model.startswith(prefix):
|
|
@@ -89,6 +139,71 @@ def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
|
|
|
89
139
|
return model
|
|
90
140
|
|
|
91
141
|
|
|
142
|
+
def stream_failure_message(chunk: Any) -> str:
|
|
143
|
+
if isinstance(chunk, BaseModel):
|
|
144
|
+
chunk = chunk.model_dump()
|
|
145
|
+
if not isinstance(chunk, Mapping):
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
event_type = getattr(chunk.get("type"), "value", chunk.get("type"))
|
|
149
|
+
event_type = str(event_type or "")
|
|
150
|
+
if event_type == "error":
|
|
151
|
+
error = chunk.get("error", {})
|
|
152
|
+
elif event_type == "response.failed":
|
|
153
|
+
response = chunk.get("response", {})
|
|
154
|
+
error = value_at(response, "error", {})
|
|
155
|
+
else:
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
message = value_at(error, "message", "")
|
|
159
|
+
if isinstance(message, str) and message:
|
|
160
|
+
return message
|
|
161
|
+
code = value_at(error, "code", "")
|
|
162
|
+
if isinstance(code, str) and code:
|
|
163
|
+
return code
|
|
164
|
+
return "Upstream request failed"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def raise_for_stream_failure(chunk: Any) -> None:
|
|
168
|
+
message = stream_failure_message(chunk)
|
|
169
|
+
if message:
|
|
170
|
+
raise LLMStreamError(message)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def configure_litellm_stream_error_handling() -> None:
|
|
174
|
+
global _litellm_stream_error_patch_installed
|
|
175
|
+
|
|
176
|
+
if _litellm_stream_error_patch_installed:
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
|
180
|
+
OpenAiResponsesToChatCompletionStreamIterator,
|
|
181
|
+
)
|
|
182
|
+
except Exception:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if getattr(
|
|
186
|
+
OpenAiResponsesToChatCompletionStreamIterator,
|
|
187
|
+
"_flowent_stream_error_patch_installed",
|
|
188
|
+
False,
|
|
189
|
+
):
|
|
190
|
+
_litellm_stream_error_patch_installed = True
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
transformer = cast(Any, OpenAiResponsesToChatCompletionStreamIterator)
|
|
194
|
+
original = transformer.translate_responses_chunk_to_openai_stream
|
|
195
|
+
|
|
196
|
+
def translate_responses_chunk_to_openai_stream(parsed_chunk: Any) -> Any:
|
|
197
|
+
raise_for_stream_failure(parsed_chunk)
|
|
198
|
+
return original(parsed_chunk)
|
|
199
|
+
|
|
200
|
+
transformer.translate_responses_chunk_to_openai_stream = staticmethod(
|
|
201
|
+
translate_responses_chunk_to_openai_stream
|
|
202
|
+
)
|
|
203
|
+
transformer._flowent_stream_error_patch_installed = True
|
|
204
|
+
_litellm_stream_error_patch_installed = True
|
|
205
|
+
|
|
206
|
+
|
|
92
207
|
def unique_model_names(provider: ProviderFormat, models: Sequence[str]) -> list[str]:
|
|
93
208
|
seen: set[str] = set()
|
|
94
209
|
normalized_models: list[str] = []
|
|
@@ -115,7 +230,7 @@ def list_provider_models(
|
|
|
115
230
|
model_lister = get_valid_models
|
|
116
231
|
|
|
117
232
|
models = model_lister(
|
|
118
|
-
api_base=base_url,
|
|
233
|
+
api_base=normalize_provider_base_url(provider, base_url),
|
|
119
234
|
api_key=secret_reference,
|
|
120
235
|
check_provider_endpoint=True,
|
|
121
236
|
custom_llm_provider=provider_litellm_name(provider),
|
|
@@ -161,8 +276,11 @@ def build_litellm_request(
|
|
|
161
276
|
request["tools"] = list(tools)
|
|
162
277
|
if stream:
|
|
163
278
|
request["stream"] = True
|
|
164
|
-
|
|
165
|
-
|
|
279
|
+
normalized_base_url = normalize_provider_base_url(
|
|
280
|
+
connection.provider, connection.base_url
|
|
281
|
+
)
|
|
282
|
+
if normalized_base_url:
|
|
283
|
+
request["api_base"] = normalized_base_url
|
|
166
284
|
if connection.reasoning_effort != ReasoningEffort.DEFAULT:
|
|
167
285
|
request["reasoning_effort"] = connection.reasoning_effort.value
|
|
168
286
|
logger.log(
|
|
@@ -170,7 +288,7 @@ def build_litellm_request(
|
|
|
170
288
|
"Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s reasoning_effort=%s messages=%r",
|
|
171
289
|
connection.provider,
|
|
172
290
|
connection.model,
|
|
173
|
-
|
|
291
|
+
normalized_base_url or "",
|
|
174
292
|
stream,
|
|
175
293
|
bool(tools),
|
|
176
294
|
connection.reasoning_effort,
|
|
@@ -185,7 +303,7 @@ def record_litellm_request_diagnostic(
|
|
|
185
303
|
) -> None:
|
|
186
304
|
write_llm_request_diagnostic(
|
|
187
305
|
{
|
|
188
|
-
"base_url":
|
|
306
|
+
"base_url": request.get("api_base"),
|
|
189
307
|
"litellm_model": request["model"],
|
|
190
308
|
"messages": request["messages"],
|
|
191
309
|
"model": connection.model,
|
|
@@ -317,6 +435,7 @@ async def stream_chat_chunks(
|
|
|
317
435
|
from litellm import acompletion
|
|
318
436
|
|
|
319
437
|
configure_litellm_logging()
|
|
438
|
+
configure_litellm_stream_error_handling()
|
|
320
439
|
completion = acompletion
|
|
321
440
|
|
|
322
441
|
logger.debug(
|
|
@@ -328,6 +447,7 @@ async def stream_chat_chunks(
|
|
|
328
447
|
record_litellm_request_diagnostic(connection, request)
|
|
329
448
|
response = await completion(**request)
|
|
330
449
|
async for chunk in response:
|
|
450
|
+
raise_for_stream_failure(chunk)
|
|
331
451
|
logger.log(TRACE_LEVEL, "LLM stream chunk=%r", chunk)
|
|
332
452
|
yield chunk
|
|
333
453
|
|