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.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +22 -15
- package/backend/src/flowent/mcp.py +4 -3
- package/backend/src/flowent/permissions.py +51 -38
- package/backend/src/flowent/state/models.py +1 -2
- package/backend/src/flowent/state/schema.py +116 -0
- package/backend/src/flowent/static/assets/{index-BaZmIi2Y.js → index-BX18a4Jz.js} +9 -7
- package/backend/src/flowent/static/index.html +1 -1
- package/backend/src/flowent/tools.py +84 -33
- package/backend/src/flowent/usage.py +66 -0
- package/backend/src/flowent/workspace/context.py +14 -7
- package/backend/src/flowent/workspace/output.py +4 -1
- package/backend/src/flowent/workspace/runtime.py +94 -5
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/{index-BaZmIi2Y.js → index-BX18a4Jz.js} +9 -7
- package/dist/frontend/index.html +1 -1
- package/package.json +8 -10
package/backend/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
296
|
-
"data": {},
|
|
302
|
+
"result": result.result,
|
|
297
303
|
"status": "failed",
|
|
298
|
-
"title":
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
tool_result.model_dump(),
|
|
345
353
|
)
|
|
346
354
|
yield AgentStreamEvent(
|
|
347
|
-
event="tool_done" if
|
|
355
|
+
event="tool_done" if tool_result.ok else "tool_error",
|
|
348
356
|
data={
|
|
349
357
|
"id": tool_item["id"],
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"
|
|
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
|
-
|
|
666
|
-
|
|
665
|
+
result={
|
|
666
|
+
"type": "mcp",
|
|
667
|
+
"output": content,
|
|
667
668
|
"server": server_name,
|
|
668
669
|
"tool": tool_name,
|
|
669
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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 =
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
316
|
-
exit_code = int_result_field(
|
|
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(
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
407
|
-
result: ToolResult,
|
|
419
|
+
def tool_result_with_fields(
|
|
420
|
+
result: ToolResult, extra_fields: dict[str, object]
|
|
408
421
|
) -> ToolResult:
|
|
409
|
-
return result.model_copy(update={"
|
|
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
|
-
|
|
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}
|