flowent 0.3.2 → 0.3.3
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 +61 -2
- package/backend/src/flowent/app.py +7 -2
- package/backend/src/flowent/permissions.py +12 -3
- package/backend/src/flowent/routes/workflow_routes.py +9 -41
- package/backend/src/flowent/sandbox.py +63 -19
- package/backend/src/flowent/state/models.py +1 -1
- package/backend/src/flowent/static/assets/index-CCf0mo80.css +2 -0
- package/backend/src/flowent/static/assets/index-CROofCFl.js +102 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/tools.py +60 -4
- package/backend/src/flowent/workflow_service.py +93 -0
- package/backend/src/flowent/workflow_tools.py +271 -0
- package/backend/src/flowent/workflows.py +71 -3
- package/backend/src/flowent/workspace/runtime.py +79 -17
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-CCf0mo80.css +2 -0
- package/dist/frontend/assets/index-CROofCFl.js +102 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BX18a4Jz.js +0 -100
- package/backend/src/flowent/static/assets/index-EC37agAH.css +0 -2
- package/dist/frontend/assets/index-BX18a4Jz.js +0 -100
- package/dist/frontend/assets/index-EC37agAH.css +0 -2
package/backend/pyproject.toml
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
7
9
|
from uuid import uuid4
|
|
8
10
|
|
|
9
11
|
from pydantic import BaseModel, ConfigDict
|
|
@@ -44,6 +46,10 @@ Use tools deliberately:
|
|
|
44
46
|
- Apply structured patches for file edits.
|
|
45
47
|
- Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
|
|
46
48
|
- When a shell command needs to write outside the current workspace, declare each needed writable directory with sandbox_permissions set to with_additional_permissions and additional_permissions.file_system.write. Flowent reviews elevated permissions automatically, so keep the requested paths specific and tied to the task.
|
|
49
|
+
- Use workflow tools when the user asks to view, inspect, run, create, or modify saved workflows. List workflows first when you need the workflow id. Read a workflow before modifying it.
|
|
50
|
+
- When running a workflow and the user's current message contains the content to process, pass that content as the run_workflow input. Use inputs only when you need to target specific input node ids.
|
|
51
|
+
- When creating or updating a workflow, save a complete workflow object with valid node ids and edges. If saving fails, use the validation error as context and explain what needs to change.
|
|
52
|
+
- Do not delete workflows. If the user asks to delete a workflow, say that you cannot do that directly.
|
|
47
53
|
- Search the web only when current external information is needed.
|
|
48
54
|
- Update the plan when a task has multiple meaningful steps.
|
|
49
55
|
|
|
@@ -315,6 +321,20 @@ async def run_agent_stream(
|
|
|
315
321
|
)
|
|
316
322
|
logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
|
|
317
323
|
yield AgentStreamEvent(event="tool_start", data={"tool": tool_item})
|
|
324
|
+
tool_event_queue: asyncio.Queue[dict[str, object]] = asyncio.Queue()
|
|
325
|
+
|
|
326
|
+
async def emit_tool_event(
|
|
327
|
+
data: dict[str, object],
|
|
328
|
+
*,
|
|
329
|
+
queue: asyncio.Queue[dict[str, object]] = tool_event_queue,
|
|
330
|
+
tool_id: str = str(tool_item["id"]),
|
|
331
|
+
) -> None:
|
|
332
|
+
yield_data = {
|
|
333
|
+
"id": tool_id,
|
|
334
|
+
**data,
|
|
335
|
+
}
|
|
336
|
+
await queue.put(yield_data)
|
|
337
|
+
|
|
318
338
|
extra_result = (
|
|
319
339
|
await extra_tool_runner(tool_call.name, arguments)
|
|
320
340
|
if extra_tool_runner is not None
|
|
@@ -324,8 +344,12 @@ async def run_agent_stream(
|
|
|
324
344
|
extra_result if isinstance(extra_result, ToolResult) else None
|
|
325
345
|
)
|
|
326
346
|
if tool_result is None:
|
|
327
|
-
context = ToolContext(
|
|
328
|
-
|
|
347
|
+
context = ToolContext(
|
|
348
|
+
cwd=cwd,
|
|
349
|
+
emit_event=emit_tool_event,
|
|
350
|
+
web_searcher=web_searcher,
|
|
351
|
+
)
|
|
352
|
+
tool_task: asyncio.Future[ToolResult] = asyncio.ensure_future(
|
|
329
353
|
tool_runner(
|
|
330
354
|
tool_call.name,
|
|
331
355
|
arguments,
|
|
@@ -338,6 +362,41 @@ async def run_agent_stream(
|
|
|
338
362
|
context,
|
|
339
363
|
)
|
|
340
364
|
)
|
|
365
|
+
pending_event_task: asyncio.Future[dict[str, object]] | None = None
|
|
366
|
+
try:
|
|
367
|
+
while True:
|
|
368
|
+
if pending_event_task is None:
|
|
369
|
+
pending_event_task = asyncio.create_task(
|
|
370
|
+
tool_event_queue.get()
|
|
371
|
+
)
|
|
372
|
+
done, _ = await asyncio.wait(
|
|
373
|
+
{
|
|
374
|
+
cast(asyncio.Future[object], tool_task),
|
|
375
|
+
cast(asyncio.Future[object], pending_event_task),
|
|
376
|
+
},
|
|
377
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
378
|
+
)
|
|
379
|
+
if pending_event_task in done:
|
|
380
|
+
yield AgentStreamEvent(
|
|
381
|
+
event="tool_update",
|
|
382
|
+
data=pending_event_task.result(),
|
|
383
|
+
)
|
|
384
|
+
pending_event_task = None
|
|
385
|
+
if tool_task in done:
|
|
386
|
+
if pending_event_task is not None:
|
|
387
|
+
pending_event_task.cancel()
|
|
388
|
+
break
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
tool_task.cancel()
|
|
391
|
+
if pending_event_task is not None:
|
|
392
|
+
pending_event_task.cancel()
|
|
393
|
+
raise
|
|
394
|
+
tool_result = await tool_task
|
|
395
|
+
while not tool_event_queue.empty():
|
|
396
|
+
yield AgentStreamEvent(
|
|
397
|
+
event="tool_update",
|
|
398
|
+
data=tool_event_queue.get_nowait(),
|
|
399
|
+
)
|
|
341
400
|
result_content = tool_result_model_content(tool_result)
|
|
342
401
|
logger.debug(
|
|
343
402
|
"Tool call finished name=%s id=%s ok=%s",
|
|
@@ -23,6 +23,7 @@ from flowent.routes.workspace import register_workspace_routes
|
|
|
23
23
|
from flowent.sandbox import ensure_sandbox_available
|
|
24
24
|
from flowent.storage import StateStore
|
|
25
25
|
from flowent.system_tools import ensure_ripgrep_available
|
|
26
|
+
from flowent.workflow_service import WorkflowService
|
|
26
27
|
from flowent.workspace.runtime import WorkspaceRuntime
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger("flowent.app")
|
|
@@ -57,6 +58,10 @@ def create_app(
|
|
|
57
58
|
store = StateStore()
|
|
58
59
|
compact_provider = LocalSummaryCompactProvider()
|
|
59
60
|
mcp_manager = McpManager(store=store, transport=mcp_transport)
|
|
61
|
+
workflow_service = WorkflowService(
|
|
62
|
+
chat_completion=chat_completion,
|
|
63
|
+
store=store,
|
|
64
|
+
)
|
|
60
65
|
|
|
61
66
|
static_dir = frontend_static_directory().resolve(strict=False)
|
|
62
67
|
logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
|
|
@@ -69,6 +74,7 @@ def create_app(
|
|
|
69
74
|
cwd=cwd,
|
|
70
75
|
mcp_manager=mcp_manager,
|
|
71
76
|
store=store,
|
|
77
|
+
workflow_service=workflow_service,
|
|
72
78
|
)
|
|
73
79
|
|
|
74
80
|
telegram_bot_manager = TelegramBotManager(
|
|
@@ -121,8 +127,7 @@ def create_app(
|
|
|
121
127
|
)
|
|
122
128
|
register_workflow_routes(
|
|
123
129
|
app,
|
|
124
|
-
|
|
125
|
-
store=store,
|
|
130
|
+
workflow_service=workflow_service,
|
|
126
131
|
)
|
|
127
132
|
register_permission_routes(app, cwd=cwd, store=store)
|
|
128
133
|
register_workspace_routes(app, runtime=runtime, store=store)
|
|
@@ -14,6 +14,7 @@ from flowent.patch import affected_paths
|
|
|
14
14
|
from flowent.sandbox import SandboxError, SandboxRunner, path_is_within
|
|
15
15
|
from flowent.shell import shell_invocation
|
|
16
16
|
from flowent.tools import (
|
|
17
|
+
CommandOutputCollector,
|
|
17
18
|
ToolContext,
|
|
18
19
|
ToolResult,
|
|
19
20
|
command_tool_result,
|
|
@@ -305,17 +306,25 @@ async def shell_command_with_writable_paths(
|
|
|
305
306
|
command = str(arguments["command"])
|
|
306
307
|
timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
|
|
307
308
|
invocation = shell_invocation(command)
|
|
309
|
+
collector = CommandOutputCollector(command, context.emit_event)
|
|
308
310
|
result = await SandboxRunner(
|
|
309
311
|
cwd=context.cwd,
|
|
310
312
|
writable_roots=writable_paths,
|
|
311
|
-
).run_async(
|
|
313
|
+
).run_async(
|
|
314
|
+
invocation.args,
|
|
315
|
+
env=invocation.env,
|
|
316
|
+
on_stderr=collector.append_stderr,
|
|
317
|
+
on_stdout=collector.append_stdout,
|
|
318
|
+
timeout_seconds=timeout_seconds,
|
|
319
|
+
)
|
|
312
320
|
ok = result.exit_code == 0
|
|
313
321
|
return ToolResult(
|
|
314
322
|
result=command_tool_result(
|
|
315
323
|
command=command,
|
|
316
324
|
exit_code=result.exit_code,
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
output_chunks=collector.output_chunks,
|
|
326
|
+
stderr=result.stderr or collector.stderr,
|
|
327
|
+
stdout=result.stdout or collector.stdout,
|
|
319
328
|
),
|
|
320
329
|
ok=ok,
|
|
321
330
|
title=f"Ran {command}",
|
|
@@ -1,63 +1,31 @@
|
|
|
1
1
|
from fastapi import FastAPI, HTTPException
|
|
2
2
|
|
|
3
|
-
from flowent.
|
|
4
|
-
from flowent.
|
|
5
|
-
from flowent.
|
|
6
|
-
from flowent.workflows import (
|
|
7
|
-
WorkflowRunResponse,
|
|
8
|
-
run_workflow_definition,
|
|
9
|
-
validate_workflow_draft,
|
|
10
|
-
workflow_requires_connection,
|
|
11
|
-
)
|
|
3
|
+
from flowent.storage import StoredWorkflow
|
|
4
|
+
from flowent.workflow_service import WorkflowService
|
|
5
|
+
from flowent.workflows import WorkflowRunResponse
|
|
12
6
|
|
|
13
7
|
|
|
14
8
|
def register_workflow_routes(
|
|
15
9
|
app: FastAPI,
|
|
16
10
|
*,
|
|
17
|
-
|
|
18
|
-
store: StateStore,
|
|
11
|
+
workflow_service: WorkflowService,
|
|
19
12
|
) -> None:
|
|
20
13
|
@app.put("/api/workflows")
|
|
21
14
|
async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
|
|
22
15
|
try:
|
|
23
|
-
return
|
|
24
|
-
validate_workflow_draft(
|
|
25
|
-
workflow.model_copy(
|
|
26
|
-
update={"name": workflow.name.strip() or "Untitled Workflow"}
|
|
27
|
-
)
|
|
28
|
-
)
|
|
29
|
-
)
|
|
16
|
+
return workflow_service.save_workflow(workflow)
|
|
30
17
|
except ValueError as error:
|
|
31
18
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
32
19
|
|
|
33
20
|
@app.delete("/api/workflows/{workflow_id}")
|
|
34
21
|
async def delete_workflow(workflow_id: str) -> dict[str, bool]:
|
|
35
|
-
store.delete_workflow(workflow_id)
|
|
22
|
+
workflow_service.store.delete_workflow(workflow_id)
|
|
36
23
|
return {"ok": True}
|
|
37
24
|
|
|
38
25
|
@app.post("/api/workflows/{workflow_id}/run")
|
|
39
26
|
async def run_workflow(workflow_id: str) -> WorkflowRunResponse:
|
|
40
|
-
workflow = next(
|
|
41
|
-
(
|
|
42
|
-
current_workflow
|
|
43
|
-
for current_workflow in store.read_workflows()
|
|
44
|
-
if current_workflow.id == workflow_id
|
|
45
|
-
),
|
|
46
|
-
None,
|
|
47
|
-
)
|
|
48
|
-
if workflow is None:
|
|
49
|
-
raise HTTPException(status_code=404, detail="Workflow not found.")
|
|
50
27
|
try:
|
|
51
|
-
|
|
52
|
-
selected_connection(store.read_state())
|
|
53
|
-
if workflow_requires_connection(workflow.definition)
|
|
54
|
-
else None
|
|
55
|
-
)
|
|
56
|
-
return await run_workflow_definition(
|
|
57
|
-
completion=chat_completion,
|
|
58
|
-
connection=connection,
|
|
59
|
-
definition=workflow.definition,
|
|
60
|
-
workflow_id=workflow.id,
|
|
61
|
-
)
|
|
28
|
+
return await workflow_service.run_workflow(workflow_id)
|
|
62
29
|
except ValueError as error:
|
|
63
|
-
|
|
30
|
+
status_code = 404 if str(error) == "Workflow not found." else 400
|
|
31
|
+
raise HTTPException(status_code=status_code, detail=str(error)) from error
|
|
@@ -8,7 +8,7 @@ import shutil
|
|
|
8
8
|
import signal
|
|
9
9
|
import subprocess
|
|
10
10
|
import tempfile
|
|
11
|
-
from collections.abc import Mapping
|
|
11
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
12
12
|
from contextlib import suppress
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from pathlib import Path
|
|
@@ -34,6 +34,9 @@ class SandboxError(RuntimeError):
|
|
|
34
34
|
pass
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
OutputCallback = Callable[[str], Awaitable[None]]
|
|
38
|
+
|
|
39
|
+
|
|
37
40
|
SANDBOX_INSTALL_HINT = (
|
|
38
41
|
"Install bubblewrap and try again. Debian/Ubuntu: "
|
|
39
42
|
"sudo apt-get install bubblewrap. Fedora: sudo dnf install bubblewrap. "
|
|
@@ -213,6 +216,29 @@ class SandboxRunner:
|
|
|
213
216
|
return value.decode(errors="replace")
|
|
214
217
|
return value
|
|
215
218
|
|
|
219
|
+
async def _read_stream(
|
|
220
|
+
self,
|
|
221
|
+
stream: asyncio.StreamReader | None,
|
|
222
|
+
callback: OutputCallback | None,
|
|
223
|
+
) -> str:
|
|
224
|
+
if stream is None:
|
|
225
|
+
return ""
|
|
226
|
+
chunks: list[str] = []
|
|
227
|
+
remaining = self.output_limit
|
|
228
|
+
while True:
|
|
229
|
+
chunk = await stream.read(4096)
|
|
230
|
+
if not chunk:
|
|
231
|
+
break
|
|
232
|
+
text = self._text_output(chunk)
|
|
233
|
+
if remaining <= 0:
|
|
234
|
+
continue
|
|
235
|
+
limited = text[:remaining]
|
|
236
|
+
remaining -= len(limited)
|
|
237
|
+
chunks.append(limited)
|
|
238
|
+
if callback is not None and limited:
|
|
239
|
+
await callback(limited)
|
|
240
|
+
return "".join(chunks)
|
|
241
|
+
|
|
216
242
|
def __init__(
|
|
217
243
|
self,
|
|
218
244
|
*,
|
|
@@ -334,6 +360,8 @@ class SandboxRunner:
|
|
|
334
360
|
*,
|
|
335
361
|
env: dict[str, str] | None = None,
|
|
336
362
|
input_text: str | None = None,
|
|
363
|
+
on_stderr: OutputCallback | None = None,
|
|
364
|
+
on_stdout: OutputCallback | None = None,
|
|
337
365
|
timeout_seconds: float | None = None,
|
|
338
366
|
) -> CommandResult:
|
|
339
367
|
process_env = build_shell_environment(env)
|
|
@@ -346,22 +374,26 @@ class SandboxRunner:
|
|
|
346
374
|
stdout=asyncio.subprocess.PIPE,
|
|
347
375
|
stderr=asyncio.subprocess.PIPE,
|
|
348
376
|
)
|
|
377
|
+
stdout_task = asyncio.create_task(self._read_stream(process.stdout, on_stdout))
|
|
378
|
+
stderr_task = asyncio.create_task(self._read_stream(process.stderr, on_stderr))
|
|
379
|
+
if input_text is not None and process.stdin is not None:
|
|
380
|
+
process.stdin.write(input_text.encode())
|
|
381
|
+
await process.stdin.drain()
|
|
382
|
+
process.stdin.close()
|
|
349
383
|
try:
|
|
350
|
-
|
|
351
|
-
process.
|
|
352
|
-
input_text.encode() if input_text is not None else None
|
|
353
|
-
),
|
|
354
|
-
timeout=timeout_seconds or self.timeout_seconds,
|
|
384
|
+
await asyncio.wait_for(
|
|
385
|
+
process.wait(), timeout=timeout_seconds or self.timeout_seconds
|
|
355
386
|
)
|
|
356
387
|
except TimeoutError as error:
|
|
357
388
|
with suppress(ProcessLookupError):
|
|
358
389
|
os.killpg(process.pid, signal.SIGKILL)
|
|
359
|
-
|
|
390
|
+
await process.wait()
|
|
391
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
360
392
|
return CommandResult(
|
|
361
393
|
command=" ".join(command),
|
|
362
394
|
exit_code=124,
|
|
363
395
|
stderr=str(error) or "Command timed out.",
|
|
364
|
-
stdout=
|
|
396
|
+
stdout=stdout,
|
|
365
397
|
)
|
|
366
398
|
except asyncio.CancelledError:
|
|
367
399
|
with suppress(ProcessLookupError):
|
|
@@ -372,13 +404,16 @@ class SandboxRunner:
|
|
|
372
404
|
with suppress(ProcessLookupError):
|
|
373
405
|
os.killpg(process.pid, signal.SIGKILL)
|
|
374
406
|
await process.wait()
|
|
407
|
+
for task in [stdout_task, stderr_task]:
|
|
408
|
+
task.cancel()
|
|
375
409
|
raise
|
|
376
410
|
|
|
411
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
377
412
|
return CommandResult(
|
|
378
413
|
command=" ".join(command),
|
|
379
414
|
exit_code=process.returncode or 0,
|
|
380
|
-
stderr=
|
|
381
|
-
stdout=
|
|
415
|
+
stderr=stderr,
|
|
416
|
+
stdout=stdout,
|
|
382
417
|
)
|
|
383
418
|
|
|
384
419
|
async def run_async(
|
|
@@ -387,6 +422,8 @@ class SandboxRunner:
|
|
|
387
422
|
*,
|
|
388
423
|
env: dict[str, str] | None = None,
|
|
389
424
|
input_text: str | None = None,
|
|
425
|
+
on_stderr: OutputCallback | None = None,
|
|
426
|
+
on_stdout: OutputCallback | None = None,
|
|
390
427
|
timeout_seconds: float | None = None,
|
|
391
428
|
) -> CommandResult:
|
|
392
429
|
sandbox_command = self.build_command(command)
|
|
@@ -405,22 +442,26 @@ class SandboxRunner:
|
|
|
405
442
|
stdout=asyncio.subprocess.PIPE,
|
|
406
443
|
stderr=asyncio.subprocess.PIPE,
|
|
407
444
|
)
|
|
445
|
+
stdout_task = asyncio.create_task(self._read_stream(process.stdout, on_stdout))
|
|
446
|
+
stderr_task = asyncio.create_task(self._read_stream(process.stderr, on_stderr))
|
|
447
|
+
if input_text is not None and process.stdin is not None:
|
|
448
|
+
process.stdin.write(input_text.encode())
|
|
449
|
+
await process.stdin.drain()
|
|
450
|
+
process.stdin.close()
|
|
408
451
|
try:
|
|
409
|
-
|
|
410
|
-
process.
|
|
411
|
-
input_text.encode() if input_text is not None else None
|
|
412
|
-
),
|
|
413
|
-
timeout=timeout_seconds or self.timeout_seconds,
|
|
452
|
+
await asyncio.wait_for(
|
|
453
|
+
process.wait(), timeout=timeout_seconds or self.timeout_seconds
|
|
414
454
|
)
|
|
415
455
|
except TimeoutError as error:
|
|
416
456
|
with suppress(ProcessLookupError):
|
|
417
457
|
os.killpg(process.pid, signal.SIGKILL)
|
|
418
|
-
|
|
458
|
+
await process.wait()
|
|
459
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
419
460
|
return CommandResult(
|
|
420
461
|
command=" ".join(command),
|
|
421
462
|
exit_code=124,
|
|
422
463
|
stderr=str(error) or "Command timed out.",
|
|
423
|
-
stdout=
|
|
464
|
+
stdout=stdout,
|
|
424
465
|
)
|
|
425
466
|
except asyncio.CancelledError:
|
|
426
467
|
with suppress(ProcessLookupError):
|
|
@@ -431,14 +472,17 @@ class SandboxRunner:
|
|
|
431
472
|
with suppress(ProcessLookupError):
|
|
432
473
|
os.killpg(process.pid, signal.SIGKILL)
|
|
433
474
|
await process.wait()
|
|
475
|
+
for task in [stdout_task, stderr_task]:
|
|
476
|
+
task.cancel()
|
|
434
477
|
raise
|
|
435
478
|
finally:
|
|
436
479
|
if sandbox_command.seccomp_file is not None:
|
|
437
480
|
sandbox_command.seccomp_file.close()
|
|
438
481
|
|
|
482
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
439
483
|
return CommandResult(
|
|
440
484
|
command=" ".join(command),
|
|
441
485
|
exit_code=process.returncode or 0,
|
|
442
|
-
stderr=
|
|
443
|
-
stdout=
|
|
486
|
+
stderr=stderr,
|
|
487
|
+
stdout=stdout,
|
|
444
488
|
)
|
|
@@ -90,7 +90,7 @@ class StoredWorkflowNode(BaseModel):
|
|
|
90
90
|
position: StoredWorkflowNodePosition = Field(
|
|
91
91
|
default_factory=StoredWorkflowNodePosition
|
|
92
92
|
)
|
|
93
|
-
type: Literal["input", "agent", "merge", "output"]
|
|
93
|
+
type: Literal["input", "agent", "merge", "code", "output"]
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
class StoredWorkflowEdge(BaseModel):
|