flowent 0.3.1 → 0.3.2

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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -25,6 +25,8 @@ from flowent.tools import (
25
25
  new_tool_item,
26
26
  parse_tool_arguments,
27
27
  run_tool_async,
28
+ text_tool_result,
29
+ tool_result_model_content,
28
30
  tool_specs,
29
31
  )
30
32
 
@@ -277,7 +279,12 @@ async def run_agent_stream(
277
279
  arguments = parse_tool_arguments(tool_call.arguments)
278
280
  except Exception as error:
279
281
  arguments = {}
280
- result_content = str(error)
282
+ result = ToolResult(
283
+ result=text_tool_result(str(error)),
284
+ ok=False,
285
+ title=tool_call.name or "Tool failed",
286
+ )
287
+ result_content = tool_result_model_content(result)
281
288
  tool_item = new_tool_item(tool_call.name, arguments)
282
289
  logger.debug("Tool call argument parse failed name=%s", tool_call.name)
283
290
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
@@ -292,10 +299,9 @@ async def run_agent_stream(
292
299
  event="tool_error",
293
300
  data={
294
301
  "id": tool_item["id"],
295
- "content": result_content,
296
- "data": {},
302
+ "result": result.result,
297
303
  "status": "failed",
298
- "title": tool_call.name or "Tool failed",
304
+ "title": result.title,
299
305
  },
300
306
  )
301
307
  else:
@@ -314,10 +320,12 @@ async def run_agent_stream(
314
320
  if extra_tool_runner is not None
315
321
  else None
316
322
  )
317
- result = extra_result if isinstance(extra_result, ToolResult) else None
318
- if result is None:
323
+ tool_result: ToolResult | None = (
324
+ extra_result if isinstance(extra_result, ToolResult) else None
325
+ )
326
+ if tool_result is None:
319
327
  context = ToolContext(cwd=cwd, web_searcher=web_searcher)
320
- result = await (
328
+ tool_result = await (
321
329
  tool_runner(
322
330
  tool_call.name,
323
331
  arguments,
@@ -330,27 +338,26 @@ async def run_agent_stream(
330
338
  context,
331
339
  )
332
340
  )
333
- result_content = result.content
341
+ result_content = tool_result_model_content(tool_result)
334
342
  logger.debug(
335
343
  "Tool call finished name=%s id=%s ok=%s",
336
344
  tool_call.name,
337
345
  tool_item["id"],
338
- result.ok,
346
+ tool_result.ok,
339
347
  )
340
348
  logger.log(
341
349
  TRACE_LEVEL,
342
350
  "Tool result id=%s result=%r",
343
351
  tool_item["id"],
344
- result.model_dump(),
352
+ tool_result.model_dump(),
345
353
  )
346
354
  yield AgentStreamEvent(
347
- event="tool_done" if result.ok else "tool_error",
355
+ event="tool_done" if tool_result.ok else "tool_error",
348
356
  data={
349
357
  "id": tool_item["id"],
350
- "content": result.content,
351
- "data": result.data,
352
- "status": "success" if result.ok else "failed",
353
- "title": result.title,
358
+ "result": tool_result.result,
359
+ "status": "success" if tool_result.ok else "failed",
360
+ "title": tool_result.title,
354
361
  },
355
362
  )
356
363
  conversation.append(tool_result_message(tool_call_id, result_content))
@@ -662,11 +662,12 @@ class McpManager:
662
662
  content = mcp_result_content(result)
663
663
  server_name = self._server_names.get(server_id, server_id)
664
664
  return ToolResult(
665
- content=content,
666
- data={
665
+ result={
666
+ "type": "mcp",
667
+ "output": content,
667
668
  "server": server_name,
668
669
  "tool": tool_name,
669
- "result": result,
670
+ "raw_result": result,
670
671
  },
671
672
  ok=not mcp_result_is_error(result),
672
673
  title=f"Calling {server_name}.{tool_name}",
@@ -16,10 +16,13 @@ from flowent.shell import shell_invocation
16
16
  from flowent.tools import (
17
17
  ToolContext,
18
18
  ToolResult,
19
+ command_tool_result,
19
20
  number_argument,
20
21
  patch_title_from_result,
21
22
  run_tool_async,
23
+ text_tool_result,
22
24
  tool_failure_content,
25
+ tool_result_model_content,
23
26
  )
24
27
 
25
28
  SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
@@ -66,7 +69,7 @@ def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult
66
69
  return None
67
70
  if sandbox_permissions != SANDBOX_WITH_ADDITIONAL_PERMISSIONS:
68
71
  return ToolResult(
69
- content=(
72
+ result=text_tool_result(
70
73
  "additional_permissions requires sandbox_permissions to be "
71
74
  "with_additional_permissions."
72
75
  ),
@@ -130,8 +133,10 @@ async def review_missing_write_paths(
130
133
  return (
131
134
  effective_paths,
132
135
  ToolResult(
133
- content=approval_denial_content(decision),
134
- data=review_data,
136
+ result=text_tool_result(
137
+ approval_denial_content(decision),
138
+ **review_data,
139
+ ),
135
140
  ok=False,
136
141
  title="Denied by reviewer",
137
142
  ),
@@ -196,7 +201,7 @@ async def run_shell_command_with_permissions(
196
201
  arguments, context, effective_paths
197
202
  )
198
203
  if approval_data is not None:
199
- result = tool_result_with_data(result, approval_data)
204
+ result = tool_result_with_fields(result, approval_data)
200
205
  if result.ok or not is_likely_sandbox_denied_result(result):
201
206
  return result
202
207
  review_request = ApprovalReviewRequest(
@@ -210,13 +215,16 @@ async def run_shell_command_with_permissions(
210
215
  review_data = approval_result_data(review_request, decision)
211
216
  if decision.decision == "denied":
212
217
  return ToolResult(
213
- content=approval_denial_content(decision),
214
- data={**result.data, **review_data},
218
+ result=text_tool_result(
219
+ approval_denial_content(decision),
220
+ previous_result=result.result,
221
+ **review_data,
222
+ ),
215
223
  ok=False,
216
224
  title="Denied by reviewer",
217
225
  )
218
226
  retry_result = await shell_command_without_sandbox(arguments, context)
219
- return tool_result_with_data(retry_result, review_data)
227
+ return tool_result_with_fields(retry_result, review_data)
220
228
 
221
229
 
222
230
  async def run_apply_patch_with_permissions(
@@ -244,7 +252,7 @@ async def run_apply_patch_with_permissions(
244
252
 
245
253
  result = await apply_patch_with_writable_paths(arguments, context, effective_paths)
246
254
  if approval_data is not None:
247
- result = tool_result_with_data(result, approval_data)
255
+ result = tool_result_with_fields(result, approval_data)
248
256
  return result
249
257
 
250
258
 
@@ -268,18 +276,23 @@ async def apply_patch_with_writable_paths(
268
276
  input_text=patch,
269
277
  )
270
278
  except SandboxError as error:
271
- return ToolResult(content=str(error), ok=False, title="Edit failed")
279
+ return ToolResult(
280
+ result=text_tool_result(str(error)), ok=False, title="Edit failed"
281
+ )
272
282
 
273
283
  if result.exit_code != 0:
274
284
  return ToolResult(
275
- content=tool_failure_content(result),
285
+ result=text_tool_result(tool_failure_content(result)),
276
286
  ok=False,
277
287
  title="Edit failed",
278
288
  )
279
289
  data = json.loads(result.stdout or "{}")
280
290
  return ToolResult(
281
- content=result.stdout,
282
- data=data if isinstance(data, dict) else {},
291
+ result={
292
+ "type": "patch",
293
+ "output": result.stdout,
294
+ **(data if isinstance(data, dict) else {}),
295
+ },
283
296
  title=patch_title_from_result(data),
284
297
  )
285
298
 
@@ -297,27 +310,25 @@ async def shell_command_with_writable_paths(
297
310
  writable_roots=writable_paths,
298
311
  ).run_async(invocation.args, env=invocation.env, timeout_seconds=timeout_seconds)
299
312
  ok = result.exit_code == 0
300
- content = result.stdout or result.stderr
301
313
  return ToolResult(
302
- content=content,
303
- data={
304
- "command": command,
305
- "exit_code": result.exit_code,
306
- "stderr": result.stderr,
307
- "stdout": result.stdout,
308
- },
314
+ result=command_tool_result(
315
+ command=command,
316
+ exit_code=result.exit_code,
317
+ stderr=result.stderr,
318
+ stdout=result.stdout,
319
+ ),
309
320
  ok=ok,
310
321
  title=f"Ran {command}",
311
322
  )
312
323
 
313
324
 
314
325
  def is_likely_sandbox_denied_result(result: ToolResult) -> bool:
315
- data = result.data
316
- exit_code = int_result_field(data.get("exit_code"))
326
+ payload = result.result
327
+ exit_code = int_result_field(payload.get("exit_code"))
317
328
  if exit_code == 0:
318
329
  return False
319
330
  output = "\n".join(
320
- str(data.get(name, "") or "") for name in ["stderr", "stdout"]
331
+ str(payload.get(name, "") or "") for name in ["stderr", "stdout", "output"]
321
332
  ).lower()
322
333
  return any(
323
334
  keyword in output
@@ -345,13 +356,17 @@ def int_result_field(value: object) -> int:
345
356
 
346
357
 
347
358
  def tool_failure_text(result: ToolResult) -> str:
348
- stderr = str(result.data.get("stderr", "") or "").strip()
349
- stdout = str(result.data.get("stdout", "") or "").strip()
350
- content = result.content.strip()
359
+ payload = result.result
360
+ stderr = str(payload.get("stderr", "") or "").strip()
361
+ stdout = str(payload.get("stdout", "") or "").strip()
351
362
  parts: list[str] = []
352
- for part in [stderr, stdout, content]:
363
+ for part in [stderr, stdout]:
353
364
  if part and part not in parts:
354
365
  parts.append(part)
366
+ if not parts:
367
+ content = tool_result_model_content(result).strip()
368
+ if content:
369
+ parts.append(content)
355
370
  return "\n".join(parts)
356
371
 
357
372
 
@@ -366,15 +381,13 @@ async def shell_command_without_sandbox(
366
381
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
367
382
  )
368
383
  ok = result.exit_code == 0
369
- content = result.stdout or result.stderr
370
384
  return ToolResult(
371
- content=content,
372
- data={
373
- "command": command,
374
- "exit_code": result.exit_code,
375
- "stderr": result.stderr,
376
- "stdout": result.stdout,
377
- },
385
+ result=command_tool_result(
386
+ command=command,
387
+ exit_code=result.exit_code,
388
+ stderr=result.stderr,
389
+ stdout=result.stdout,
390
+ ),
378
391
  ok=ok,
379
392
  title=f"Ran {command}",
380
393
  )
@@ -403,7 +416,7 @@ def approval_result_data(
403
416
  }
404
417
 
405
418
 
406
- def tool_result_with_data(
407
- result: ToolResult, extra_data: dict[str, object]
419
+ def tool_result_with_fields(
420
+ result: ToolResult, extra_fields: dict[str, object]
408
421
  ) -> ToolResult:
409
- return result.model_copy(update={"data": {**result.data, **extra_data}})
422
+ return result.model_copy(update={"result": {**result.result, **extra_fields}})
@@ -151,8 +151,7 @@ class StoredToolItem(BaseModel):
151
151
  status: str
152
152
  title: str
153
153
  arguments: dict[str, object] | None = None
154
- content: str | None = None
155
- data: dict[str, object] | None = None
154
+ result: dict[str, object] | None = None
156
155
 
157
156
 
158
157
  class StoredThinkingOutputItem(BaseModel):
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import sqlite3
2
3
 
3
4
 
@@ -185,7 +186,122 @@ def migrate(connection: sqlite3.Connection) -> None:
185
186
  "ALTER TABLE workspace_context "
186
187
  "ADD COLUMN is_compacting INTEGER NOT NULL DEFAULT 0"
187
188
  )
189
+ if not migration_version_exists(connection, 2):
190
+ migrate_tool_result_items(connection)
191
+ connection.execute("INSERT INTO schema_migrations (version) VALUES (2)")
188
192
 
189
193
 
190
194
  def table_columns(connection: sqlite3.Connection, table: str) -> set[str]:
191
195
  return {row["name"] for row in connection.execute(f"PRAGMA table_info({table})")}
196
+
197
+
198
+ def migration_version_exists(connection: sqlite3.Connection, version: int) -> bool:
199
+ return (
200
+ connection.execute(
201
+ "SELECT 1 FROM schema_migrations WHERE version = ?",
202
+ (version,),
203
+ ).fetchone()
204
+ is not None
205
+ )
206
+
207
+
208
+ def migrate_tool_result_items(connection: sqlite3.Connection) -> None:
209
+ for row in connection.execute("SELECT id, tools, groups FROM messages"):
210
+ tools, tools_changed = migrate_tool_list(json.loads(row["tools"] or "[]"))
211
+ groups, groups_changed = migrate_tool_groups(json.loads(row["groups"] or "[]"))
212
+ if not tools_changed and not groups_changed:
213
+ continue
214
+ connection.execute(
215
+ "UPDATE messages SET tools = ?, groups = ? WHERE id = ?",
216
+ (
217
+ json.dumps(tools, ensure_ascii=False),
218
+ json.dumps(groups, ensure_ascii=False),
219
+ row["id"],
220
+ ),
221
+ )
222
+
223
+
224
+ def migrate_tool_groups(groups: object) -> tuple[object, bool]:
225
+ if not isinstance(groups, list):
226
+ return groups, False
227
+ changed = False
228
+ next_groups: list[object] = []
229
+ for group in groups:
230
+ if not isinstance(group, dict):
231
+ next_groups.append(group)
232
+ continue
233
+ items = group.get("items")
234
+ if not isinstance(items, list):
235
+ next_groups.append(group)
236
+ continue
237
+ next_items: list[object] = []
238
+ for item in items:
239
+ if not isinstance(item, dict) or item.get("type") != "tool":
240
+ next_items.append(item)
241
+ continue
242
+ tool, tool_changed = migrate_tool_item(item.get("tool"))
243
+ changed = changed or tool_changed
244
+ next_items.append({**item, "tool": tool})
245
+ next_groups.append({**group, "items": next_items})
246
+ return next_groups, changed
247
+
248
+
249
+ def migrate_tool_list(tools: object) -> tuple[object, bool]:
250
+ if not isinstance(tools, list):
251
+ return tools, False
252
+ changed = False
253
+ next_tools: list[object] = []
254
+ for tool in tools:
255
+ next_tool, tool_changed = migrate_tool_item(tool)
256
+ changed = changed or tool_changed
257
+ next_tools.append(next_tool)
258
+ return next_tools, changed
259
+
260
+
261
+ def migrate_tool_item(tool: object) -> tuple[object, bool]:
262
+ if not isinstance(tool, dict):
263
+ return tool, False
264
+ if "content" not in tool and "data" not in tool:
265
+ return tool, False
266
+ legacy_content = tool.get("content")
267
+ legacy_data = tool.get("data")
268
+ result = tool.get("result")
269
+ if not isinstance(result, dict):
270
+ result = legacy_tool_result(legacy_content, legacy_data)
271
+ return (
272
+ {
273
+ key: value
274
+ for key, value in {**tool, "result": result}.items()
275
+ if key not in {"content", "data"}
276
+ },
277
+ True,
278
+ )
279
+
280
+
281
+ def legacy_tool_result(content: object, data: object) -> dict[str, object]:
282
+ text = content if isinstance(content, str) else ""
283
+ payload = data if isinstance(data, dict) else {}
284
+ if {"command", "exit_code", "stderr", "stdout"}.issubset(payload):
285
+ return {
286
+ "type": "command",
287
+ "command": str(payload.get("command") or ""),
288
+ "exit_code": payload.get("exit_code"),
289
+ "stderr": str(payload.get("stderr") or ""),
290
+ "stdout": str(payload.get("stdout") or ""),
291
+ "output": text or str(payload.get("stdout") or payload.get("stderr") or ""),
292
+ }
293
+ if "server" in payload and "tool" in payload and "result" in payload:
294
+ return {
295
+ "type": "mcp",
296
+ "output": text,
297
+ "server": payload.get("server"),
298
+ "tool": payload.get("tool"),
299
+ "raw_result": payload.get("result"),
300
+ }
301
+ if "items" in payload:
302
+ return {"type": "plan", "output": text, **payload}
303
+ if "results" in payload and "query" in payload:
304
+ return {"type": "web_search", "output": text, **payload}
305
+ if "files" in payload:
306
+ return {"type": "patch", "output": text, **payload}
307
+ return {"type": "text", "text": text, **payload}