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.
Files changed (53) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +94 -33
  23. package/backend/src/flowent/llm.py +126 -6
  24. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
  25. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  28. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  29. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/conftest.py +39 -0
  42. package/backend/tests/test_agent_tools.py +136 -0
  43. package/backend/tests/test_llm_providers.py +161 -0
  44. package/backend/tests/test_workspace_chat.py +52 -0
  45. package/backend/uv.lock +1 -1
  46. package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
  47. package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-Cl20cARb.css +0 -2
  51. package/backend/src/flowent/static/assets/index-dsDDsEym.js +0 -81
  52. package/dist/frontend/assets/index-Cl20cARb.css +0 -2
  53. package/dist/frontend/assets/index-dsDDsEym.js +0 -81
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.5"
3
+ version = "0.2.0"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- async for chunk in stream_chat_chunks(
154
- connection,
155
- conversation,
156
- completion=completion,
157
- tools=[*tool_specs(), *list(extra_tool_specs or [])],
158
- ):
159
- reasoning = chunk_delta_reasoning(chunk)
160
- if reasoning:
161
- final_thinking += reasoning
162
- logger.log(
163
- TRACE_LEVEL,
164
- "Agent stream reasoning id=%s content=%r",
165
- assistant_id,
166
- reasoning,
167
- )
168
- yield AgentStreamEvent(
169
- event="thinking_delta", data={"content": reasoning}
170
- )
171
- content = chunk_delta_content(chunk)
172
- if content:
173
- round_content += content
174
- final_content += content
175
- logger.log(
176
- TRACE_LEVEL,
177
- "Agent stream delta id=%s content=%r",
178
- assistant_id,
179
- content,
180
- )
181
- yield AgentStreamEvent(event="delta", data={"content": content})
182
- for delta in chunk_delta_tool_calls(chunk):
183
- pending.setdefault(delta.index, PendingToolCall()).apply_delta(delta)
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
- if connection.base_url:
165
- request["api_base"] = connection.base_url
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
- connection.base_url or "",
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": connection.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