flowent 0.0.4 → 0.0.6
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/README.md +1 -1
- package/backend/README.md +74 -0
- package/backend/pyproject.toml +2 -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__/access.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/image_assets.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_service.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/{stats_service.cpython-313.pyc → observability_service.cpython-313.pyc} +0 -0
- package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +364 -52
- package/backend/src/flowent/assistant_commands.py +31 -22
- package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
- package/backend/src/flowent/channels/telegram.py +4 -4
- package/backend/src/flowent/graph_service.py +1307 -145
- package/backend/src/flowent/mcp_service.py +21 -7
- package/backend/src/flowent/model_metadata.py +4 -0
- package/backend/src/flowent/models/__init__.py +6 -2
- package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/agent.py +1 -0
- package/backend/src/flowent/models/graph.py +44 -9
- package/backend/src/flowent/models/history.py +73 -15
- package/backend/src/flowent/models/llm.py +1 -0
- package/backend/src/flowent/models/message.py +6 -0
- package/backend/src/flowent/models/tab.py +38 -1
- package/backend/src/flowent/{stats_service.py → observability_service.py} +4 -4
- package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/common.py +2 -2
- package/backend/src/flowent/prompts/steward.py +2 -2
- package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/configuration.py +7 -0
- package/backend/src/flowent/role_management.py +12 -0
- package/backend/src/flowent/routes/__init__.py +0 -2
- package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/assistant.py +4 -4
- package/backend/src/flowent/routes/nodes.py +54 -6
- package/backend/src/flowent/routes/providers_route.py +1 -0
- package/backend/src/flowent/routes/roles.py +1 -1
- package/backend/src/flowent/routes/settings.py +4 -0
- package/backend/src/flowent/routes/tabs.py +29 -11
- package/backend/src/flowent/runtime.py +7 -30
- package/backend/src/flowent/security.py +23 -8
- package/backend/src/flowent/settings.py +56 -5
- package/backend/src/flowent/settings_management.py +12 -0
- package/backend/src/flowent/static/assets/AssistantPage-VBohhz4d.js +1 -0
- package/backend/src/flowent/static/assets/ChannelsPage-CIydPZA_.js +1 -0
- package/backend/src/flowent/static/assets/McpPage-CHPm2TPY.js +7 -0
- package/backend/src/flowent/static/assets/PageScaffold-DteOA8V7.js +1 -0
- package/backend/src/flowent/static/assets/PromptsPage-CSmJ3sZg.js +1 -0
- package/backend/src/flowent/static/assets/ProvidersPage-sl2jeG4e.js +3 -0
- package/backend/src/flowent/static/assets/RolesPage-DCe7W6Km.js +1 -0
- package/backend/src/flowent/static/assets/SettingsPage-Bix9e63E.js +3 -0
- package/backend/src/flowent/static/assets/ToolsPage-favNkj5C.js +1 -0
- package/backend/src/flowent/static/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
- package/backend/src/flowent/static/assets/WorkspacePage-KuaDjt_D.js +3 -0
- package/backend/src/flowent/static/assets/WorkspacePanels-BZxBw8M5.js +1 -0
- package/backend/src/flowent/static/assets/alert-dialog-DIBUCmqM.js +1 -0
- package/backend/src/flowent/static/assets/{dialog-BeGSweF6.js → dialog-BOvHIBrg.js} +1 -1
- package/backend/src/flowent/static/assets/index-Biio-CoI.js +10 -0
- package/backend/src/flowent/static/assets/index-CmQvO7sl.css +1 -0
- package/backend/src/flowent/static/assets/modelParams-DcEhGnu0.js +1 -0
- package/backend/src/flowent/static/assets/roles-BbIEIMeG.js +1 -0
- package/backend/src/flowent/static/assets/select-D9SwnlXF.js +1 -0
- package/backend/src/flowent/static/assets/surface-Bzr1FRG4.js +1 -0
- package/backend/src/flowent/static/assets/{ui-vendor-Dg9NNnWX.js → ui-vendor-UazN8rcv.js} +15 -15
- package/backend/src/flowent/static/index.html +3 -4
- package/backend/src/flowent/tools/__init__.py +76 -2
- package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/connect.py +10 -66
- package/backend/src/flowent/tools/contacts.py +1 -1
- package/backend/src/flowent/tools/create_agent.py +9 -88
- package/backend/src/flowent/tools/create_tab.py +7 -5
- package/backend/src/flowent/tools/exec.py +3 -2
- package/backend/src/flowent/tools/list_roles.py +29 -4
- package/backend/src/flowent/tools/list_tabs.py +4 -0
- package/backend/src/flowent/tools/list_tools.py +5 -1
- package/backend/src/flowent/tools/manage_settings.py +18 -0
- package/backend/src/flowent/tools/send.py +21 -3
- package/backend/src/flowent/tools/set_permissions.py +21 -6
- package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/test_assistant_api.py +1 -1
- package/backend/tests/integration/api/test_nodes_api.py +257 -21
- package/backend/tests/integration/api/test_roles_api.py +3 -2
- package/backend/tests/integration/api/test_tabs_api.py +312 -11
- package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/test_agent_public_api.py +162 -71
- package/backend/tests/unit/agent/test_agent_runtime.py +285 -69
- package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/prompts/test_prompts.py +3 -2
- package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/test_providers_route.py +2 -0
- package/backend/tests/unit/routes/test_roles_routes.py +109 -0
- package/backend/tests/unit/routes/test_settings_routes.py +4 -0
- package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/runtime/test_bootstrap_runtime.py +8 -18
- package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/security/test_security.py +16 -2
- package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/settings/test_settings_roles.py +40 -0
- package/backend/tests/unit/test_state_sqlite_storage.py +67 -1
- package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/test_connect_tool.py +2 -3
- package/backend/tests/unit/tools/test_create_agent_tool.py +9 -97
- package/backend/tests/unit/tools/test_delete_tab_tool.py +33 -0
- package/backend/tests/unit/tools/test_manage_providers_tool.py +2 -0
- package/backend/tests/unit/tools/test_manage_settings_tool.py +3 -0
- package/backend/tests/unit/tools/test_set_permissions_tool.py +216 -12
- package/backend/tests/unit/tools/test_tool_registry.py +103 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/AssistantPage-VBohhz4d.js +1 -0
- package/dist/frontend/assets/ChannelsPage-CIydPZA_.js +1 -0
- package/dist/frontend/assets/McpPage-CHPm2TPY.js +7 -0
- package/dist/frontend/assets/PageScaffold-DteOA8V7.js +1 -0
- package/dist/frontend/assets/PromptsPage-CSmJ3sZg.js +1 -0
- package/dist/frontend/assets/ProvidersPage-sl2jeG4e.js +3 -0
- package/dist/frontend/assets/RolesPage-DCe7W6Km.js +1 -0
- package/dist/frontend/assets/SettingsPage-Bix9e63E.js +3 -0
- package/dist/frontend/assets/ToolsPage-favNkj5C.js +1 -0
- package/dist/frontend/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
- package/dist/frontend/assets/WorkspacePage-KuaDjt_D.js +3 -0
- package/dist/frontend/assets/WorkspacePanels-BZxBw8M5.js +1 -0
- package/dist/frontend/assets/alert-dialog-DIBUCmqM.js +1 -0
- package/dist/frontend/assets/{dialog-BeGSweF6.js → dialog-BOvHIBrg.js} +1 -1
- package/dist/frontend/assets/index-Biio-CoI.js +10 -0
- package/dist/frontend/assets/index-CmQvO7sl.css +1 -0
- package/dist/frontend/assets/modelParams-DcEhGnu0.js +1 -0
- package/dist/frontend/assets/roles-BbIEIMeG.js +1 -0
- package/dist/frontend/assets/select-D9SwnlXF.js +1 -0
- package/dist/frontend/assets/surface-Bzr1FRG4.js +1 -0
- package/dist/frontend/assets/{ui-vendor-Dg9NNnWX.js → ui-vendor-UazN8rcv.js} +15 -15
- package/dist/frontend/index.html +3 -4
- package/package.json +3 -3
- package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/stats.py +0 -229
- package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +0 -1
- package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +0 -1
- package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +0 -3
- package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +0 -7
- package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +0 -1
- package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +0 -1
- package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +0 -3
- package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +0 -1
- package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +0 -3
- package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +0 -1
- package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +0 -1
- package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +0 -1
- package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +0 -1
- package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +0 -1
- package/backend/src/flowent/static/assets/badge-74-3jsCg.js +0 -1
- package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +0 -1
- package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +0 -1
- package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +0 -10
- package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +0 -1
- package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +0 -1
- package/backend/src/flowent/static/assets/select-DL_LPeDj.js +0 -1
- package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +0 -1
- package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/test_stats_routes.py +0 -149
- package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +0 -1
- package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +0 -1
- package/dist/frontend/assets/HomePage-C0hAx9_l.js +0 -3
- package/dist/frontend/assets/McpPage-DkrYLvBv.js +0 -7
- package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +0 -1
- package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +0 -1
- package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +0 -3
- package/dist/frontend/assets/RolesPage-CqcclGRw.js +0 -1
- package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +0 -3
- package/dist/frontend/assets/StatsPage-BX9khYzu.js +0 -1
- package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +0 -1
- package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +0 -1
- package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +0 -1
- package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +0 -1
- package/dist/frontend/assets/badge-74-3jsCg.js +0 -1
- package/dist/frontend/assets/constants-XUzFf6i1.js +0 -1
- package/dist/frontend/assets/index-BHC1Vhy8.css +0 -1
- package/dist/frontend/assets/index-CL1ALZ3r.js +0 -10
- package/dist/frontend/assets/modelParams-CaHd0903.js +0 -1
- package/dist/frontend/assets/roles-2OLDeTc5.js +0 -1
- package/dist/frontend/assets/select-DL_LPeDj.js +0 -1
- package/dist/frontend/assets/shared-CMxbpLeQ.js +0 -1
- /package/backend/src/flowent/static/assets/{datetime-m6_O_Ci9.js → datetime-eJqd0V2S.js} +0 -0
- /package/backend/src/flowent/static/assets/{markdown-vendor-DVdy_w12.js → markdown-vendor-C9RtvaJh.js} +0 -0
- /package/backend/src/flowent/static/assets/{triState-DEr3NkXV.js → triState-DgLlKdRR.js} +0 -0
- /package/dist/frontend/assets/{datetime-m6_O_Ci9.js → datetime-eJqd0V2S.js} +0 -0
- /package/dist/frontend/assets/{markdown-vendor-DVdy_w12.js → markdown-vendor-C9RtvaJh.js} +0 -0
- /package/dist/frontend/assets/{triState-DEr3NkXV.js → triState-DgLlKdRR.js} +0 -0
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
3
7
|
import uuid
|
|
4
8
|
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
5
11
|
|
|
6
12
|
from flowent import settings as settings_module
|
|
7
13
|
from flowent.events import event_bus
|
|
@@ -16,12 +22,17 @@ from flowent.models import (
|
|
|
16
22
|
NodeConfig,
|
|
17
23
|
NodeType,
|
|
18
24
|
PortDirection,
|
|
25
|
+
PortInboundEntry,
|
|
26
|
+
PortType,
|
|
19
27
|
ReceivedMessage,
|
|
20
28
|
Tab,
|
|
29
|
+
WorkflowActivationState,
|
|
21
30
|
WorkflowDefinition,
|
|
22
31
|
WorkflowNodeDefinition,
|
|
23
32
|
WorkflowNodeKind,
|
|
24
33
|
WorkflowPort,
|
|
34
|
+
content_parts_to_text,
|
|
35
|
+
deserialize_content_parts,
|
|
25
36
|
)
|
|
26
37
|
from flowent.registry import registry
|
|
27
38
|
from flowent.runtime import SYSTEM_NODE_TIMEOUT
|
|
@@ -33,20 +44,56 @@ from flowent.settings import (
|
|
|
33
44
|
STEWARD_ROLE_INCLUDED_TOOLS,
|
|
34
45
|
STEWARD_ROLE_NAME,
|
|
35
46
|
build_assistant_write_dirs,
|
|
47
|
+
find_provider,
|
|
36
48
|
find_role,
|
|
49
|
+
resolve_model_info,
|
|
37
50
|
resolve_path,
|
|
38
51
|
)
|
|
39
|
-
from flowent.tools import
|
|
52
|
+
from flowent.tools import (
|
|
53
|
+
MINIMUM_TOOLS,
|
|
54
|
+
is_assistant_only_mcp_tool_name,
|
|
55
|
+
is_assistant_only_tool_name,
|
|
56
|
+
)
|
|
40
57
|
from flowent.workspace_store import workspace_store
|
|
41
58
|
|
|
42
59
|
LEADER_NODE_NAME = "Leader"
|
|
43
60
|
|
|
44
61
|
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ContactPath:
|
|
64
|
+
target_id: str
|
|
65
|
+
target_node_type: str
|
|
66
|
+
target_role_name: str | None
|
|
67
|
+
target_name: str | None
|
|
68
|
+
target_state: str | None
|
|
69
|
+
is_leader: bool
|
|
70
|
+
from_output_port_key: str
|
|
71
|
+
to_input_port_key: str
|
|
72
|
+
port_type: str
|
|
73
|
+
edge_id: str
|
|
74
|
+
|
|
75
|
+
def serialize(self) -> dict[str, object]:
|
|
76
|
+
return {
|
|
77
|
+
"id": self.target_id,
|
|
78
|
+
"target_id": self.target_id,
|
|
79
|
+
"node_type": self.target_node_type,
|
|
80
|
+
"role_name": self.target_role_name,
|
|
81
|
+
"name": self.target_name,
|
|
82
|
+
"state": self.target_state,
|
|
83
|
+
"is_leader": self.is_leader,
|
|
84
|
+
"from_output_port_key": self.from_output_port_key,
|
|
85
|
+
"to_input_port_key": self.to_input_port_key,
|
|
86
|
+
"port_type": self.port_type,
|
|
87
|
+
"edge_id": self.edge_id,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
45
91
|
def build_tools_for_role(
|
|
46
92
|
role_name: str,
|
|
47
93
|
*,
|
|
48
94
|
requested_tools: list[str] | None = None,
|
|
49
95
|
settings=None,
|
|
96
|
+
assistant_boundary: bool = False,
|
|
50
97
|
) -> list[str]:
|
|
51
98
|
current_settings = settings or settings_module.get_settings()
|
|
52
99
|
normalized_role_name = role_name.strip()
|
|
@@ -70,6 +117,11 @@ def build_tools_for_role(
|
|
|
70
117
|
for tool_name in [*MINIMUM_TOOLS, *included_tools, *(requested_tools or [])]:
|
|
71
118
|
if tool_name in seen_tools:
|
|
72
119
|
continue
|
|
120
|
+
if not assistant_boundary and (
|
|
121
|
+
is_assistant_only_tool_name(tool_name)
|
|
122
|
+
or is_assistant_only_mcp_tool_name(tool_name)
|
|
123
|
+
):
|
|
124
|
+
continue
|
|
73
125
|
if tool_name in excluded_tools and tool_name not in MINIMUM_TOOLS:
|
|
74
126
|
continue
|
|
75
127
|
final_tools.append(tool_name)
|
|
@@ -82,6 +134,7 @@ def build_assistant_tools(*, settings=None) -> list[str]:
|
|
|
82
134
|
assistant_tools = build_tools_for_role(
|
|
83
135
|
current_settings.assistant.role_name,
|
|
84
136
|
settings=current_settings,
|
|
137
|
+
assistant_boundary=True,
|
|
85
138
|
)
|
|
86
139
|
final_tools: list[str] = []
|
|
87
140
|
seen_tools: set[str] = set()
|
|
@@ -126,87 +179,160 @@ def is_tab_leader(*, node_id: str, tab_id: str | None = None) -> bool:
|
|
|
126
179
|
return get_tab_leader_id(resolved_tab_id) == node_id
|
|
127
180
|
|
|
128
181
|
|
|
182
|
+
def _coerce_port_type(raw_value: object, default: PortType) -> PortType:
|
|
183
|
+
try:
|
|
184
|
+
return PortType(str(raw_value))
|
|
185
|
+
except ValueError:
|
|
186
|
+
return default
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _port_from_code_config(
|
|
190
|
+
raw_port: object,
|
|
191
|
+
*,
|
|
192
|
+
direction: PortDirection,
|
|
193
|
+
) -> WorkflowPort | None:
|
|
194
|
+
if not isinstance(raw_port, dict):
|
|
195
|
+
return None
|
|
196
|
+
key = raw_port.get("key")
|
|
197
|
+
if not isinstance(key, str) or not key.strip():
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
port_type = PortType(str(raw_port.get("type")))
|
|
201
|
+
except ValueError:
|
|
202
|
+
return None
|
|
203
|
+
return WorkflowPort(
|
|
204
|
+
key=key.strip(),
|
|
205
|
+
direction=direction,
|
|
206
|
+
type=port_type,
|
|
207
|
+
required=bool(raw_port.get("required", direction == PortDirection.INPUT)),
|
|
208
|
+
multiple=bool(raw_port.get("multiple", False)),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_code_ports(
|
|
213
|
+
config: dict[str, object],
|
|
214
|
+
) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
|
|
215
|
+
raw_inputs = config.get("inputs")
|
|
216
|
+
raw_outputs = config.get("outputs")
|
|
217
|
+
inputs = [
|
|
218
|
+
port
|
|
219
|
+
for port in (
|
|
220
|
+
_port_from_code_config(item, direction=PortDirection.INPUT)
|
|
221
|
+
for item in (raw_inputs if isinstance(raw_inputs, list) else [])
|
|
222
|
+
)
|
|
223
|
+
if port is not None
|
|
224
|
+
]
|
|
225
|
+
outputs = [
|
|
226
|
+
port
|
|
227
|
+
for port in (
|
|
228
|
+
_port_from_code_config(item, direction=PortDirection.OUTPUT)
|
|
229
|
+
for item in (raw_outputs if isinstance(raw_outputs, list) else [])
|
|
230
|
+
)
|
|
231
|
+
if port is not None
|
|
232
|
+
]
|
|
233
|
+
if not inputs:
|
|
234
|
+
inputs = [
|
|
235
|
+
WorkflowPort(
|
|
236
|
+
key="in",
|
|
237
|
+
direction=PortDirection.INPUT,
|
|
238
|
+
type=PortType.PARTS,
|
|
239
|
+
required=True,
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
if not outputs:
|
|
243
|
+
outputs = [
|
|
244
|
+
WorkflowPort(
|
|
245
|
+
key="out",
|
|
246
|
+
direction=PortDirection.OUTPUT,
|
|
247
|
+
type=PortType.PARTS,
|
|
248
|
+
multiple=True,
|
|
249
|
+
)
|
|
250
|
+
]
|
|
251
|
+
return inputs, outputs
|
|
252
|
+
|
|
253
|
+
|
|
129
254
|
def _default_ports(
|
|
130
255
|
node_kind: WorkflowNodeKind,
|
|
256
|
+
config: dict[str, object] | None = None,
|
|
131
257
|
) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
|
|
258
|
+
node_config = config or {}
|
|
132
259
|
if node_kind == WorkflowNodeKind.TRIGGER:
|
|
260
|
+
output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
|
|
133
261
|
return (
|
|
134
262
|
[],
|
|
135
263
|
[
|
|
136
264
|
WorkflowPort(
|
|
137
265
|
key="out",
|
|
138
266
|
direction=PortDirection.OUTPUT,
|
|
139
|
-
|
|
267
|
+
type=output_type,
|
|
140
268
|
multiple=True,
|
|
141
269
|
)
|
|
142
270
|
],
|
|
143
271
|
)
|
|
144
|
-
if node_kind == WorkflowNodeKind.
|
|
272
|
+
if node_kind == WorkflowNodeKind.LLM:
|
|
273
|
+
input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
|
|
274
|
+
output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
|
|
145
275
|
return (
|
|
146
276
|
[
|
|
147
277
|
WorkflowPort(
|
|
148
278
|
key="in",
|
|
149
279
|
direction=PortDirection.INPUT,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
key="input",
|
|
154
|
-
direction=PortDirection.INPUT,
|
|
155
|
-
kind=EdgeKind.DATA,
|
|
156
|
-
multiple=True,
|
|
157
|
-
),
|
|
280
|
+
type=input_type,
|
|
281
|
+
required=True,
|
|
282
|
+
)
|
|
158
283
|
],
|
|
159
284
|
[
|
|
160
285
|
WorkflowPort(
|
|
161
286
|
key="out",
|
|
162
287
|
direction=PortDirection.OUTPUT,
|
|
163
|
-
|
|
288
|
+
type=output_type,
|
|
164
289
|
multiple=True,
|
|
165
|
-
)
|
|
166
|
-
WorkflowPort(
|
|
167
|
-
key="output",
|
|
168
|
-
direction=PortDirection.OUTPUT,
|
|
169
|
-
kind=EdgeKind.DATA,
|
|
170
|
-
multiple=True,
|
|
171
|
-
),
|
|
290
|
+
)
|
|
172
291
|
],
|
|
173
292
|
)
|
|
293
|
+
if node_kind == WorkflowNodeKind.CODE:
|
|
294
|
+
return _build_code_ports(node_config)
|
|
174
295
|
if node_kind == WorkflowNodeKind.IF:
|
|
296
|
+
input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
|
|
175
297
|
return (
|
|
176
298
|
[
|
|
177
299
|
WorkflowPort(
|
|
178
300
|
key="in",
|
|
179
301
|
direction=PortDirection.INPUT,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
WorkflowPort(
|
|
183
|
-
key="condition",
|
|
184
|
-
direction=PortDirection.INPUT,
|
|
185
|
-
kind=EdgeKind.DATA,
|
|
302
|
+
type=input_type,
|
|
303
|
+
required=True,
|
|
186
304
|
),
|
|
187
305
|
],
|
|
188
306
|
[
|
|
189
307
|
WorkflowPort(
|
|
190
|
-
key="
|
|
308
|
+
key="then",
|
|
191
309
|
direction=PortDirection.OUTPUT,
|
|
192
|
-
|
|
310
|
+
type=input_type,
|
|
193
311
|
multiple=True,
|
|
194
312
|
),
|
|
195
313
|
WorkflowPort(
|
|
196
|
-
key="
|
|
314
|
+
key="else",
|
|
197
315
|
direction=PortDirection.OUTPUT,
|
|
198
|
-
|
|
316
|
+
type=input_type,
|
|
199
317
|
multiple=True,
|
|
200
318
|
),
|
|
201
319
|
],
|
|
202
320
|
)
|
|
203
321
|
if node_kind == WorkflowNodeKind.MERGE:
|
|
322
|
+
input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
|
|
323
|
+
strategy = node_config.get("strategy")
|
|
324
|
+
output_type = (
|
|
325
|
+
input_type
|
|
326
|
+
if strategy == "first_completed"
|
|
327
|
+
else _coerce_port_type(node_config.get("output_type"), PortType.JSON)
|
|
328
|
+
)
|
|
204
329
|
return (
|
|
205
330
|
[
|
|
206
331
|
WorkflowPort(
|
|
207
332
|
key="in",
|
|
208
333
|
direction=PortDirection.INPUT,
|
|
209
|
-
|
|
334
|
+
type=input_type,
|
|
335
|
+
required=True,
|
|
210
336
|
multiple=True,
|
|
211
337
|
)
|
|
212
338
|
],
|
|
@@ -214,7 +340,26 @@ def _default_ports(
|
|
|
214
340
|
WorkflowPort(
|
|
215
341
|
key="out",
|
|
216
342
|
direction=PortDirection.OUTPUT,
|
|
217
|
-
|
|
343
|
+
type=output_type,
|
|
344
|
+
multiple=True,
|
|
345
|
+
)
|
|
346
|
+
],
|
|
347
|
+
)
|
|
348
|
+
if node_kind == WorkflowNodeKind.AGENT:
|
|
349
|
+
return (
|
|
350
|
+
[
|
|
351
|
+
WorkflowPort(
|
|
352
|
+
key="in",
|
|
353
|
+
direction=PortDirection.INPUT,
|
|
354
|
+
type=PortType.PARTS,
|
|
355
|
+
required=False,
|
|
356
|
+
)
|
|
357
|
+
],
|
|
358
|
+
[
|
|
359
|
+
WorkflowPort(
|
|
360
|
+
key="out",
|
|
361
|
+
direction=PortDirection.OUTPUT,
|
|
362
|
+
type=PortType.PARTS,
|
|
218
363
|
multiple=True,
|
|
219
364
|
)
|
|
220
365
|
],
|
|
@@ -224,14 +369,15 @@ def _default_ports(
|
|
|
224
369
|
WorkflowPort(
|
|
225
370
|
key="in",
|
|
226
371
|
direction=PortDirection.INPUT,
|
|
227
|
-
|
|
372
|
+
type=PortType.PARTS,
|
|
373
|
+
required=True,
|
|
228
374
|
)
|
|
229
375
|
],
|
|
230
376
|
[
|
|
231
377
|
WorkflowPort(
|
|
232
378
|
key="out",
|
|
233
379
|
direction=PortDirection.OUTPUT,
|
|
234
|
-
|
|
380
|
+
type=PortType.PARTS,
|
|
235
381
|
multiple=True,
|
|
236
382
|
)
|
|
237
383
|
],
|
|
@@ -244,7 +390,7 @@ def build_workflow_node_definition(
|
|
|
244
390
|
node_kind: WorkflowNodeKind,
|
|
245
391
|
config: dict[str, object] | None = None,
|
|
246
392
|
) -> WorkflowNodeDefinition:
|
|
247
|
-
inputs, outputs = _default_ports(node_kind)
|
|
393
|
+
inputs, outputs = _default_ports(node_kind, config)
|
|
248
394
|
return WorkflowNodeDefinition(
|
|
249
395
|
id=node_id,
|
|
250
396
|
type=node_kind,
|
|
@@ -268,6 +414,376 @@ def get_workflow_node(tab_id: str, node_id: str) -> WorkflowNodeDefinition | Non
|
|
|
268
414
|
return tab.definition.get_node(node_id)
|
|
269
415
|
|
|
270
416
|
|
|
417
|
+
def get_workflow_node_definition(node_id: str) -> WorkflowNodeDefinition | None:
|
|
418
|
+
record = workspace_store.get_node_record(node_id)
|
|
419
|
+
if record is not None and record.config.tab_id:
|
|
420
|
+
return get_workflow_node(record.config.tab_id, node_id)
|
|
421
|
+
live_node = registry.get(node_id)
|
|
422
|
+
if live_node is not None and live_node.config.tab_id:
|
|
423
|
+
return get_workflow_node(live_node.config.tab_id, node_id)
|
|
424
|
+
for tab in workspace_store.list_tabs():
|
|
425
|
+
node = tab.definition.get_node(node_id)
|
|
426
|
+
if node is not None:
|
|
427
|
+
return node
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def resolve_workflow_node_ref(*, tab_id: str, node_ref: str) -> str | None:
|
|
432
|
+
target = registry.get(node_ref)
|
|
433
|
+
if target is not None and target.config.tab_id == tab_id:
|
|
434
|
+
return target.uuid
|
|
435
|
+
|
|
436
|
+
tab = workspace_store.get_tab(tab_id)
|
|
437
|
+
if tab is None:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
definition_nodes = list(tab.definition.nodes)
|
|
441
|
+
exact_match = next((node for node in definition_nodes if node.id == node_ref), None)
|
|
442
|
+
if exact_match is not None:
|
|
443
|
+
return exact_match.id
|
|
444
|
+
|
|
445
|
+
named_matches = [
|
|
446
|
+
node
|
|
447
|
+
for node in definition_nodes
|
|
448
|
+
if isinstance(node.config.get("name"), str) and node.config["name"] == node_ref
|
|
449
|
+
]
|
|
450
|
+
if len(named_matches) == 1:
|
|
451
|
+
return named_matches[0].id
|
|
452
|
+
|
|
453
|
+
role_matches = [
|
|
454
|
+
node
|
|
455
|
+
for node in definition_nodes
|
|
456
|
+
if node.type == WorkflowNodeKind.AGENT
|
|
457
|
+
and isinstance(node.config.get("role_name"), str)
|
|
458
|
+
and node.config["role_name"] == node_ref
|
|
459
|
+
]
|
|
460
|
+
if len(role_matches) == 1:
|
|
461
|
+
return role_matches[0].id
|
|
462
|
+
|
|
463
|
+
if 4 <= len(node_ref) < 36:
|
|
464
|
+
prefix_matches = [
|
|
465
|
+
node for node in definition_nodes if node.id.startswith(node_ref)
|
|
466
|
+
]
|
|
467
|
+
if len(prefix_matches) == 1:
|
|
468
|
+
return prefix_matches[0].id
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _resolve_contact_target_metadata(
|
|
473
|
+
*,
|
|
474
|
+
tab_id: str,
|
|
475
|
+
node_id: str,
|
|
476
|
+
) -> tuple[str, str | None, str | None, str | None, bool]:
|
|
477
|
+
live_node = registry.get(node_id)
|
|
478
|
+
if live_node is not None:
|
|
479
|
+
return (
|
|
480
|
+
live_node.config.node_type.value,
|
|
481
|
+
live_node.config.role_name,
|
|
482
|
+
live_node.config.name,
|
|
483
|
+
live_node.state.value,
|
|
484
|
+
is_tab_leader(node_id=node_id, tab_id=tab_id),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
record = workspace_store.get_node_record(node_id)
|
|
488
|
+
if record is not None:
|
|
489
|
+
return (
|
|
490
|
+
record.config.node_type.value,
|
|
491
|
+
record.config.role_name,
|
|
492
|
+
record.config.name,
|
|
493
|
+
record.state.value,
|
|
494
|
+
is_tab_leader(node_id=node_id, tab_id=tab_id),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
definition = get_workflow_node(tab_id, node_id)
|
|
498
|
+
if definition is None:
|
|
499
|
+
return ("agent", None, None, None, False)
|
|
500
|
+
return (
|
|
501
|
+
definition.type.value,
|
|
502
|
+
str(definition.config["role_name"])
|
|
503
|
+
if isinstance(definition.config.get("role_name"), str)
|
|
504
|
+
else None,
|
|
505
|
+
str(definition.config["name"])
|
|
506
|
+
if isinstance(definition.config.get("name"), str)
|
|
507
|
+
else None,
|
|
508
|
+
None,
|
|
509
|
+
False,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def list_agent_contact_paths(*, tab_id: str, node_id: str) -> list[ContactPath]:
|
|
514
|
+
if is_tab_leader(node_id=node_id, tab_id=tab_id):
|
|
515
|
+
return []
|
|
516
|
+
tab = workspace_store.get_tab(tab_id)
|
|
517
|
+
if tab is None:
|
|
518
|
+
return []
|
|
519
|
+
|
|
520
|
+
paths: list[ContactPath] = []
|
|
521
|
+
source_node = tab.definition.get_node(node_id)
|
|
522
|
+
if source_node is None or source_node.type != WorkflowNodeKind.AGENT:
|
|
523
|
+
return paths
|
|
524
|
+
|
|
525
|
+
for edge in sorted(
|
|
526
|
+
tab.definition.edges, key=lambda item: (item.created_at, item.id)
|
|
527
|
+
):
|
|
528
|
+
if edge.from_node_id != node_id:
|
|
529
|
+
continue
|
|
530
|
+
source_port = _port_matches(
|
|
531
|
+
source_node.outputs,
|
|
532
|
+
port_key=edge.from_port_key,
|
|
533
|
+
direction=PortDirection.OUTPUT,
|
|
534
|
+
)
|
|
535
|
+
target_node = tab.definition.get_node(edge.to_node_id)
|
|
536
|
+
if source_port is None or target_node is None:
|
|
537
|
+
continue
|
|
538
|
+
target_port = _port_matches(
|
|
539
|
+
target_node.inputs,
|
|
540
|
+
port_key=edge.to_port_key,
|
|
541
|
+
direction=PortDirection.INPUT,
|
|
542
|
+
)
|
|
543
|
+
if target_port is None or source_port.type != target_port.type:
|
|
544
|
+
continue
|
|
545
|
+
node_type, role_name, name, state, is_leader_contact = (
|
|
546
|
+
_resolve_contact_target_metadata(tab_id=tab_id, node_id=edge.to_node_id)
|
|
547
|
+
)
|
|
548
|
+
paths.append(
|
|
549
|
+
ContactPath(
|
|
550
|
+
target_id=edge.to_node_id,
|
|
551
|
+
target_node_type=node_type,
|
|
552
|
+
target_role_name=role_name,
|
|
553
|
+
target_name=name,
|
|
554
|
+
target_state=state,
|
|
555
|
+
is_leader=is_leader_contact,
|
|
556
|
+
from_output_port_key=edge.from_port_key,
|
|
557
|
+
to_input_port_key=edge.to_port_key,
|
|
558
|
+
port_type=source_port.type.value,
|
|
559
|
+
edge_id=edge.id,
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
return paths
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def resolve_agent_contact_path(
|
|
566
|
+
*,
|
|
567
|
+
tab_id: str,
|
|
568
|
+
source_node_id: str,
|
|
569
|
+
target_node_id: str,
|
|
570
|
+
from_output_port_key: str,
|
|
571
|
+
to_input_port_key: str,
|
|
572
|
+
) -> ContactPath | None:
|
|
573
|
+
return next(
|
|
574
|
+
(
|
|
575
|
+
path
|
|
576
|
+
for path in list_agent_contact_paths(tab_id=tab_id, node_id=source_node_id)
|
|
577
|
+
if path.target_id == target_node_id
|
|
578
|
+
and path.from_output_port_key == from_output_port_key
|
|
579
|
+
and path.to_input_port_key == to_input_port_key
|
|
580
|
+
),
|
|
581
|
+
None,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _summarize_port_value(
|
|
586
|
+
value: object, *, port_type: PortType, limit: int = 240
|
|
587
|
+
) -> str:
|
|
588
|
+
if port_type == PortType.PARTS:
|
|
589
|
+
text = content_parts_to_text(
|
|
590
|
+
deserialize_content_parts(value if isinstance(value, list) else None)
|
|
591
|
+
)
|
|
592
|
+
elif isinstance(value, str):
|
|
593
|
+
text = value
|
|
594
|
+
else:
|
|
595
|
+
try:
|
|
596
|
+
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
597
|
+
except TypeError:
|
|
598
|
+
text = str(value)
|
|
599
|
+
text = " ".join(text.split())
|
|
600
|
+
if len(text) <= limit:
|
|
601
|
+
return text
|
|
602
|
+
return text[: limit - 3].rstrip() + "..."
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _node_label_snapshot(tab_id: str, node_id: str) -> str | None:
|
|
606
|
+
_, role_name, name, _, is_leader_contact = _resolve_contact_target_metadata(
|
|
607
|
+
tab_id=tab_id,
|
|
608
|
+
node_id=node_id,
|
|
609
|
+
)
|
|
610
|
+
if name:
|
|
611
|
+
return name
|
|
612
|
+
if is_leader_contact:
|
|
613
|
+
return LEADER_NODE_NAME
|
|
614
|
+
if role_name:
|
|
615
|
+
return role_name
|
|
616
|
+
return node_id[:8]
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def dispatch_port_value(
|
|
620
|
+
*,
|
|
621
|
+
tab_id: str,
|
|
622
|
+
source_node_id: str,
|
|
623
|
+
source_output_port_key: str,
|
|
624
|
+
target_node_id: str,
|
|
625
|
+
target_input_port_key: str,
|
|
626
|
+
value: object,
|
|
627
|
+
source_is_agent_send: bool = False,
|
|
628
|
+
message_id: str | None = None,
|
|
629
|
+
) -> tuple[dict[str, object] | None, str | None]:
|
|
630
|
+
tab = workspace_store.get_tab(tab_id)
|
|
631
|
+
if tab is None:
|
|
632
|
+
return None, f"Tab '{tab_id}' not found"
|
|
633
|
+
source_node = tab.definition.get_node(source_node_id)
|
|
634
|
+
target_node = tab.definition.get_node(target_node_id)
|
|
635
|
+
if source_node is None:
|
|
636
|
+
return None, f"Source node '{source_node_id}' not found"
|
|
637
|
+
if target_node is None:
|
|
638
|
+
return None, f"Target node '{target_node_id}' not found"
|
|
639
|
+
source_port = _port_matches(
|
|
640
|
+
source_node.outputs,
|
|
641
|
+
port_key=source_output_port_key,
|
|
642
|
+
direction=PortDirection.OUTPUT,
|
|
643
|
+
)
|
|
644
|
+
if source_port is None:
|
|
645
|
+
return None, f"Output port '{source_output_port_key}' is invalid"
|
|
646
|
+
target_port = _port_matches(
|
|
647
|
+
target_node.inputs,
|
|
648
|
+
port_key=target_input_port_key,
|
|
649
|
+
direction=PortDirection.INPUT,
|
|
650
|
+
)
|
|
651
|
+
if target_port is None:
|
|
652
|
+
return None, f"Input port '{target_input_port_key}' is invalid"
|
|
653
|
+
if source_port.type != target_port.type:
|
|
654
|
+
return (
|
|
655
|
+
None,
|
|
656
|
+
f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
|
|
657
|
+
f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
|
|
658
|
+
)
|
|
659
|
+
if not _validate_typed_value(value, source_port.type):
|
|
660
|
+
return None, f"Value must match {source_port.type.value} port type"
|
|
661
|
+
|
|
662
|
+
edge = next(
|
|
663
|
+
(
|
|
664
|
+
item
|
|
665
|
+
for item in tab.definition.edges
|
|
666
|
+
if item.from_node_id == source_node_id
|
|
667
|
+
and item.to_node_id == target_node_id
|
|
668
|
+
and item.from_port_key == source_output_port_key
|
|
669
|
+
and item.to_port_key == target_input_port_key
|
|
670
|
+
),
|
|
671
|
+
None,
|
|
672
|
+
)
|
|
673
|
+
if edge is None:
|
|
674
|
+
return None, "Send path is not connected"
|
|
675
|
+
|
|
676
|
+
value_summary = _summarize_port_value(value, port_type=source_port.type)
|
|
677
|
+
payload: dict[str, object] = {
|
|
678
|
+
"status": "sent",
|
|
679
|
+
"target_id": target_node_id,
|
|
680
|
+
"from_output_port_key": source_output_port_key,
|
|
681
|
+
"to_input_port_key": target_input_port_key,
|
|
682
|
+
"port_type": source_port.type.value,
|
|
683
|
+
"value_summary": value_summary,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
target = registry.get(target_node_id)
|
|
687
|
+
if target is not None and target_node.type == WorkflowNodeKind.AGENT:
|
|
688
|
+
if source_port.type == PortType.PARTS:
|
|
689
|
+
parts = deserialize_content_parts(value)
|
|
690
|
+
if source_is_agent_send:
|
|
691
|
+
target._append_history(
|
|
692
|
+
ReceivedMessage(
|
|
693
|
+
from_id=source_node_id,
|
|
694
|
+
parts=parts,
|
|
695
|
+
message_id=message_id,
|
|
696
|
+
from_output_port_key=source_output_port_key,
|
|
697
|
+
to_input_port_key=target_input_port_key,
|
|
698
|
+
value_summary=value_summary,
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
target.enqueue_message(
|
|
702
|
+
Message(
|
|
703
|
+
from_id=source_node_id,
|
|
704
|
+
to_id=target_node_id,
|
|
705
|
+
parts=parts,
|
|
706
|
+
message_id=message_id,
|
|
707
|
+
history_recorded=True,
|
|
708
|
+
from_output_port_key=source_output_port_key,
|
|
709
|
+
to_input_port_key=target_input_port_key,
|
|
710
|
+
port_type=source_port.type.value,
|
|
711
|
+
value=value,
|
|
712
|
+
value_summary=value_summary,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
else:
|
|
716
|
+
target._append_history(
|
|
717
|
+
PortInboundEntry(
|
|
718
|
+
from_id=source_node_id,
|
|
719
|
+
from_output_port_key=source_output_port_key,
|
|
720
|
+
to_input_port_key=target_input_port_key,
|
|
721
|
+
port_type=source_port.type.value,
|
|
722
|
+
value=value,
|
|
723
|
+
source_label=_node_label_snapshot(tab_id, source_node_id),
|
|
724
|
+
value_summary=value_summary,
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
target.enqueue_message(
|
|
728
|
+
Message(
|
|
729
|
+
from_id=source_node_id,
|
|
730
|
+
to_id=target_node_id,
|
|
731
|
+
parts=parts,
|
|
732
|
+
message_id=message_id,
|
|
733
|
+
history_recorded=True,
|
|
734
|
+
from_output_port_key=source_output_port_key,
|
|
735
|
+
to_input_port_key=target_input_port_key,
|
|
736
|
+
port_type=source_port.type.value,
|
|
737
|
+
value=value,
|
|
738
|
+
value_summary=value_summary,
|
|
739
|
+
port_inbound_recorded=True,
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
else:
|
|
743
|
+
target._append_history(
|
|
744
|
+
PortInboundEntry(
|
|
745
|
+
from_id=source_node_id,
|
|
746
|
+
from_output_port_key=source_output_port_key,
|
|
747
|
+
to_input_port_key=target_input_port_key,
|
|
748
|
+
port_type=source_port.type.value,
|
|
749
|
+
value=value,
|
|
750
|
+
source_label=_node_label_snapshot(tab_id, source_node_id),
|
|
751
|
+
value_summary=value_summary,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
target.enqueue_message(
|
|
755
|
+
Message(
|
|
756
|
+
from_id=source_node_id,
|
|
757
|
+
to_id=target_node_id,
|
|
758
|
+
content=value_summary,
|
|
759
|
+
message_id=message_id,
|
|
760
|
+
history_recorded=True,
|
|
761
|
+
from_output_port_key=source_output_port_key,
|
|
762
|
+
to_input_port_key=target_input_port_key,
|
|
763
|
+
port_type=source_port.type.value,
|
|
764
|
+
value=value,
|
|
765
|
+
value_summary=value_summary,
|
|
766
|
+
port_inbound_recorded=True,
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
event_bus.emit(
|
|
771
|
+
Event(
|
|
772
|
+
type=EventType.NODE_MESSAGE,
|
|
773
|
+
agent_id=source_node_id,
|
|
774
|
+
data={
|
|
775
|
+
"to_id": target_node_id,
|
|
776
|
+
"content": value_summary,
|
|
777
|
+
"message_id": message_id,
|
|
778
|
+
"from_output_port_key": source_output_port_key,
|
|
779
|
+
"to_input_port_key": target_input_port_key,
|
|
780
|
+
"port_type": source_port.type.value,
|
|
781
|
+
},
|
|
782
|
+
),
|
|
783
|
+
)
|
|
784
|
+
return payload, None
|
|
785
|
+
|
|
786
|
+
|
|
271
787
|
def _sync_runtime_positions_into_definition(tab: Tab) -> bool:
|
|
272
788
|
changed = False
|
|
273
789
|
for record in workspace_store.list_node_records(tab.id):
|
|
@@ -290,6 +806,9 @@ def serialize_tab_summary(tab: Tab) -> dict[str, object]:
|
|
|
290
806
|
"id": tab.id,
|
|
291
807
|
"title": tab.title,
|
|
292
808
|
"leader_id": tab.leader_id,
|
|
809
|
+
"activation_state": tab.activation_state.value,
|
|
810
|
+
"allow_network": tab.allow_network,
|
|
811
|
+
"write_dirs": list(tab.write_dirs),
|
|
293
812
|
"created_at": tab.created_at,
|
|
294
813
|
"updated_at": tab.updated_at,
|
|
295
814
|
"definition": tab.definition.serialize(),
|
|
@@ -303,14 +822,8 @@ def _build_leader_record(
|
|
|
303
822
|
tab_id: str,
|
|
304
823
|
leader_id: str,
|
|
305
824
|
settings,
|
|
306
|
-
allow_network: bool = False,
|
|
307
|
-
write_dirs: list[str] | None = None,
|
|
308
825
|
) -> GraphNodeRecord:
|
|
309
826
|
role_name = resolve_leader_role_name(settings=settings)
|
|
310
|
-
normalized_write_dirs = build_assistant_write_dirs(
|
|
311
|
-
write_dirs or [],
|
|
312
|
-
field_name="write_dirs",
|
|
313
|
-
)
|
|
314
827
|
return GraphNodeRecord(
|
|
315
828
|
id=leader_id,
|
|
316
829
|
config=NodeConfig(
|
|
@@ -319,13 +832,28 @@ def _build_leader_record(
|
|
|
319
832
|
tab_id=tab_id,
|
|
320
833
|
name=LEADER_NODE_NAME,
|
|
321
834
|
tools=build_tools_for_role(role_name, settings=settings),
|
|
322
|
-
write_dirs=normalized_write_dirs,
|
|
323
|
-
allow_network=allow_network,
|
|
324
835
|
),
|
|
325
836
|
state=AgentState.INITIALIZING,
|
|
326
837
|
)
|
|
327
838
|
|
|
328
839
|
|
|
840
|
+
def _sync_tab_permissions_from_legacy_leader(tab: Tab) -> bool:
|
|
841
|
+
if tab.permissions_initialized:
|
|
842
|
+
return False
|
|
843
|
+
tab.permissions_initialized = True
|
|
844
|
+
if not tab.leader_id:
|
|
845
|
+
workspace_store.upsert_tab(tab)
|
|
846
|
+
return False
|
|
847
|
+
record = workspace_store.get_node_record(tab.leader_id)
|
|
848
|
+
if record is None:
|
|
849
|
+
workspace_store.upsert_tab(tab)
|
|
850
|
+
return False
|
|
851
|
+
tab.allow_network = record.config.allow_network
|
|
852
|
+
tab.write_dirs = list(record.config.write_dirs)
|
|
853
|
+
workspace_store.upsert_tab(tab)
|
|
854
|
+
return True
|
|
855
|
+
|
|
856
|
+
|
|
329
857
|
def _sync_leader_record(
|
|
330
858
|
*,
|
|
331
859
|
tab_id: str,
|
|
@@ -359,7 +887,19 @@ def _start_persisted_agent(
|
|
|
359
887
|
) -> tuple[GraphNodeRecord | None, str | None]:
|
|
360
888
|
from flowent.agent import Agent
|
|
361
889
|
|
|
362
|
-
|
|
890
|
+
allow_network, write_dirs = resolve_effective_permissions_for_node_record(record)
|
|
891
|
+
node = Agent(
|
|
892
|
+
NodeConfig(
|
|
893
|
+
node_type=record.config.node_type,
|
|
894
|
+
role_name=record.config.role_name,
|
|
895
|
+
tab_id=record.config.tab_id,
|
|
896
|
+
name=record.config.name,
|
|
897
|
+
tools=list(record.config.tools),
|
|
898
|
+
write_dirs=write_dirs,
|
|
899
|
+
allow_network=allow_network,
|
|
900
|
+
),
|
|
901
|
+
uuid=record.id,
|
|
902
|
+
)
|
|
363
903
|
registry.register(node)
|
|
364
904
|
node.start()
|
|
365
905
|
return workspace_store.get_node_record(record.id), None
|
|
@@ -416,6 +956,9 @@ def ensure_tab_leaders(*, start_nodes: bool = False) -> bool:
|
|
|
416
956
|
workspace_store.upsert_tab(tab)
|
|
417
957
|
changed = True
|
|
418
958
|
|
|
959
|
+
if _sync_tab_permissions_from_legacy_leader(tab):
|
|
960
|
+
changed = True
|
|
961
|
+
|
|
419
962
|
if _sync_leader_record(tab_id=tab.id, record=leader_record, settings=settings):
|
|
420
963
|
workspace_store.upsert_node_record(leader_record)
|
|
421
964
|
changed = True
|
|
@@ -460,8 +1003,6 @@ def sync_tab_leaders(*, reason: str) -> None:
|
|
|
460
1003
|
live_node.config.role_name = record.config.role_name
|
|
461
1004
|
live_node.config.name = record.config.name
|
|
462
1005
|
live_node.config.tools = list(record.config.tools)
|
|
463
|
-
live_node.config.write_dirs = list(record.config.write_dirs)
|
|
464
|
-
live_node.config.allow_network = record.config.allow_network
|
|
465
1006
|
live_node._sync_system_prompt_entry()
|
|
466
1007
|
live_node.set_state(
|
|
467
1008
|
live_node.state,
|
|
@@ -514,14 +1055,18 @@ def create_tab(
|
|
|
514
1055
|
title=title.strip(),
|
|
515
1056
|
leader_id=leader_id,
|
|
516
1057
|
definition=WorkflowDefinition(),
|
|
1058
|
+
allow_network=allow_network,
|
|
1059
|
+
write_dirs=build_assistant_write_dirs(
|
|
1060
|
+
write_dirs or [],
|
|
1061
|
+
field_name="write_dirs",
|
|
1062
|
+
),
|
|
1063
|
+
permissions_initialized=True,
|
|
517
1064
|
)
|
|
518
1065
|
workspace_store.upsert_tab(tab)
|
|
519
1066
|
leader_record = _build_leader_record(
|
|
520
1067
|
tab_id=tab.id,
|
|
521
1068
|
leader_id=leader_id,
|
|
522
1069
|
settings=settings,
|
|
523
|
-
allow_network=allow_network,
|
|
524
|
-
write_dirs=write_dirs,
|
|
525
1070
|
)
|
|
526
1071
|
workspace_store.upsert_node_record(leader_record)
|
|
527
1072
|
if registry.get_all():
|
|
@@ -544,13 +1089,7 @@ def duplicate_tab(
|
|
|
544
1089
|
if source_tab is None:
|
|
545
1090
|
return None, f"Tab '{tab_id}' not found"
|
|
546
1091
|
|
|
547
|
-
|
|
548
|
-
workspace_store.get_node_record(source_tab.leader_id)
|
|
549
|
-
if source_tab.leader_id
|
|
550
|
-
else None
|
|
551
|
-
)
|
|
552
|
-
allow_network = leader_record.config.allow_network if leader_record else False
|
|
553
|
-
write_dirs = list(leader_record.config.write_dirs) if leader_record else []
|
|
1092
|
+
_sync_tab_permissions_from_legacy_leader(source_tab)
|
|
554
1093
|
duplicated_definition = WorkflowDefinition.from_mapping(
|
|
555
1094
|
source_tab.definition.serialize()
|
|
556
1095
|
)
|
|
@@ -589,6 +1128,9 @@ def duplicate_tab(
|
|
|
589
1128
|
id=str(uuid.uuid4()),
|
|
590
1129
|
title=f"{source_tab.title} Copy",
|
|
591
1130
|
leader_id=str(uuid.uuid4()),
|
|
1131
|
+
allow_network=source_tab.allow_network,
|
|
1132
|
+
write_dirs=list(source_tab.write_dirs),
|
|
1133
|
+
permissions_initialized=True,
|
|
592
1134
|
definition=WorkflowDefinition(
|
|
593
1135
|
version=duplicated_definition.version,
|
|
594
1136
|
nodes=duplicated_nodes,
|
|
@@ -605,8 +1147,6 @@ def duplicate_tab(
|
|
|
605
1147
|
tab_id=new_tab.id,
|
|
606
1148
|
leader_id=new_tab.leader_id,
|
|
607
1149
|
settings=settings,
|
|
608
|
-
allow_network=allow_network,
|
|
609
|
-
write_dirs=write_dirs,
|
|
610
1150
|
)
|
|
611
1151
|
)
|
|
612
1152
|
|
|
@@ -654,15 +1194,30 @@ def _is_path_within_boundary(path: str, boundary_dirs: list[str]) -> bool:
|
|
|
654
1194
|
)
|
|
655
1195
|
|
|
656
1196
|
|
|
657
|
-
def
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1197
|
+
def resolve_effective_permissions_for_agent(agent) -> tuple[bool, list[str]]:
|
|
1198
|
+
if agent.config.node_type == NodeType.ASSISTANT:
|
|
1199
|
+
settings = settings_module.get_settings()
|
|
1200
|
+
return settings.assistant.allow_network, list(settings.assistant.write_dirs)
|
|
1201
|
+
if agent.config.tab_id:
|
|
1202
|
+
tab = workspace_store.get_tab(agent.config.tab_id)
|
|
1203
|
+
if tab is not None:
|
|
1204
|
+
_sync_tab_permissions_from_legacy_leader(tab)
|
|
1205
|
+
return tab.allow_network, list(tab.write_dirs)
|
|
1206
|
+
return agent.config.allow_network, list(agent.config.write_dirs)
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def resolve_effective_permissions_for_node_record(
|
|
1210
|
+
record: GraphNodeRecord,
|
|
1211
|
+
) -> tuple[bool, list[str]]:
|
|
1212
|
+
if record.config.node_type == NodeType.ASSISTANT:
|
|
1213
|
+
settings = settings_module.get_settings()
|
|
1214
|
+
return settings.assistant.allow_network, list(settings.assistant.write_dirs)
|
|
1215
|
+
if record.config.tab_id:
|
|
1216
|
+
tab = workspace_store.get_tab(record.config.tab_id)
|
|
1217
|
+
if tab is not None:
|
|
1218
|
+
_sync_tab_permissions_from_legacy_leader(tab)
|
|
1219
|
+
return tab.allow_network, list(tab.write_dirs)
|
|
1220
|
+
return record.config.allow_network, list(record.config.write_dirs)
|
|
666
1221
|
|
|
667
1222
|
|
|
668
1223
|
def set_tab_permissions(
|
|
@@ -677,6 +1232,9 @@ def set_tab_permissions(
|
|
|
677
1232
|
tab = workspace_store.get_tab(tab_id)
|
|
678
1233
|
if tab is None:
|
|
679
1234
|
return None, f"Tab '{tab_id}' not found"
|
|
1235
|
+
_sync_tab_permissions_from_legacy_leader(tab)
|
|
1236
|
+
if _is_active(tab):
|
|
1237
|
+
return None, _active_edit_error("permissions")
|
|
680
1238
|
|
|
681
1239
|
leader_id = get_tab_leader_id(tab_id)
|
|
682
1240
|
if not leader_id:
|
|
@@ -703,64 +1261,29 @@ def set_tab_permissions(
|
|
|
703
1261
|
"write_dirs boundary exceeded: " + ", ".join(invalid_write_dirs),
|
|
704
1262
|
)
|
|
705
1263
|
|
|
706
|
-
next_allow_network =
|
|
707
|
-
|
|
708
|
-
)
|
|
709
|
-
next_write_dirs = (
|
|
710
|
-
list(leader_record.config.write_dirs)
|
|
711
|
-
if write_dirs is None
|
|
712
|
-
else list(write_dirs)
|
|
713
|
-
)
|
|
1264
|
+
next_allow_network = tab.allow_network if allow_network is None else allow_network
|
|
1265
|
+
next_write_dirs = list(tab.write_dirs) if write_dirs is None else list(write_dirs)
|
|
714
1266
|
|
|
715
1267
|
changed_node_ids: list[str] = []
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
for
|
|
727
|
-
|
|
728
|
-
continue
|
|
729
|
-
next_node_allow_network = record.config.allow_network and next_allow_network
|
|
730
|
-
next_node_write_dirs = _clamp_write_dirs_to_boundary(
|
|
731
|
-
record.config.write_dirs,
|
|
732
|
-
next_write_dirs,
|
|
733
|
-
)
|
|
734
|
-
if (
|
|
735
|
-
record.config.allow_network == next_node_allow_network
|
|
736
|
-
and record.config.write_dirs == next_node_write_dirs
|
|
737
|
-
):
|
|
738
|
-
continue
|
|
739
|
-
record.config.allow_network = next_node_allow_network
|
|
740
|
-
record.config.write_dirs = list(next_node_write_dirs)
|
|
741
|
-
workspace_store.upsert_node_record(record)
|
|
742
|
-
changed_node_ids.append(record.id)
|
|
743
|
-
|
|
744
|
-
live_node = registry.get(record.id)
|
|
1268
|
+
if tab.allow_network != next_allow_network or tab.write_dirs != next_write_dirs:
|
|
1269
|
+
tab.allow_network = next_allow_network
|
|
1270
|
+
tab.write_dirs = list(next_write_dirs)
|
|
1271
|
+
workspace_store.upsert_tab(tab)
|
|
1272
|
+
changed_node_ids = [
|
|
1273
|
+
record.id for record in list_tab_nodes(tab_id) if record.id != leader_id
|
|
1274
|
+
]
|
|
1275
|
+
if leader_record.id:
|
|
1276
|
+
changed_node_ids.insert(0, leader_record.id)
|
|
1277
|
+
|
|
1278
|
+
for node_id in changed_node_ids:
|
|
1279
|
+
live_node = registry.get(node_id)
|
|
745
1280
|
if live_node is not None:
|
|
746
|
-
live_node.config.allow_network = next_node_allow_network
|
|
747
|
-
live_node.config.write_dirs = list(next_node_write_dirs)
|
|
748
1281
|
live_node.set_state(
|
|
749
1282
|
live_node.state,
|
|
750
1283
|
"tab_permissions_updated",
|
|
751
1284
|
force_emit=True,
|
|
752
1285
|
)
|
|
753
1286
|
|
|
754
|
-
live_leader = registry.get(leader_id)
|
|
755
|
-
if live_leader is not None:
|
|
756
|
-
live_leader.config.allow_network = next_allow_network
|
|
757
|
-
live_leader.config.write_dirs = list(next_write_dirs)
|
|
758
|
-
live_leader.set_state(
|
|
759
|
-
live_leader.state,
|
|
760
|
-
"tab_permissions_updated",
|
|
761
|
-
force_emit=True,
|
|
762
|
-
)
|
|
763
|
-
|
|
764
1287
|
updated_tab = workspace_store.get_tab(tab_id)
|
|
765
1288
|
if updated_tab is not None:
|
|
766
1289
|
event_bus.emit(
|
|
@@ -791,6 +1314,17 @@ def delete_tab(
|
|
|
791
1314
|
tab = workspace_store.get_tab(tab_id)
|
|
792
1315
|
if tab is None:
|
|
793
1316
|
return None, f"Tab '{tab_id}' not found"
|
|
1317
|
+
if _is_active(tab):
|
|
1318
|
+
_, deactivate_error = deactivate_tab(
|
|
1319
|
+
tab_id=tab_id,
|
|
1320
|
+
actor_id="assistant",
|
|
1321
|
+
timeout=timeout,
|
|
1322
|
+
)
|
|
1323
|
+
if deactivate_error is not None:
|
|
1324
|
+
return None, deactivate_error
|
|
1325
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1326
|
+
if tab is None:
|
|
1327
|
+
return None, f"Tab '{tab_id}' not found"
|
|
794
1328
|
|
|
795
1329
|
stored_nodes = list_tab_nodes(tab_id)
|
|
796
1330
|
live_nodes = [node for node in registry.get_all() if node.config.tab_id == tab_id]
|
|
@@ -843,8 +1377,6 @@ def build_node_config(
|
|
|
843
1377
|
tab_id: str,
|
|
844
1378
|
name: str | None = None,
|
|
845
1379
|
tools: list[str] | None = None,
|
|
846
|
-
write_dirs: list[str] | None = None,
|
|
847
|
-
allow_network: bool = False,
|
|
848
1380
|
) -> tuple[NodeConfig | None, str | None]:
|
|
849
1381
|
settings = settings_module.get_settings()
|
|
850
1382
|
role = find_role(settings, role_name.strip())
|
|
@@ -854,16 +1386,6 @@ def build_node_config(
|
|
|
854
1386
|
requested_tools = tools or []
|
|
855
1387
|
if not all(isinstance(item, str) for item in requested_tools):
|
|
856
1388
|
return None, "tools must be an array of strings"
|
|
857
|
-
requested_write_dirs = write_dirs or []
|
|
858
|
-
if not all(isinstance(item, str) for item in requested_write_dirs):
|
|
859
|
-
return None, "write_dirs must be an array of strings"
|
|
860
|
-
try:
|
|
861
|
-
normalized_write_dirs = build_assistant_write_dirs(
|
|
862
|
-
requested_write_dirs,
|
|
863
|
-
field_name="write_dirs",
|
|
864
|
-
)
|
|
865
|
-
except ValueError as exc:
|
|
866
|
-
return None, str(exc)
|
|
867
1389
|
|
|
868
1390
|
return (
|
|
869
1391
|
NodeConfig(
|
|
@@ -876,8 +1398,6 @@ def build_node_config(
|
|
|
876
1398
|
requested_tools=requested_tools,
|
|
877
1399
|
settings=settings,
|
|
878
1400
|
),
|
|
879
|
-
write_dirs=normalized_write_dirs,
|
|
880
|
-
allow_network=allow_network,
|
|
881
1401
|
),
|
|
882
1402
|
None,
|
|
883
1403
|
)
|
|
@@ -889,6 +1409,14 @@ def _persist_tab(tab: Tab, *, actor_id: str) -> Tab:
|
|
|
889
1409
|
return tab
|
|
890
1410
|
|
|
891
1411
|
|
|
1412
|
+
def _is_active(tab: Tab) -> bool:
|
|
1413
|
+
return tab.activation_state == WorkflowActivationState.ACTIVE
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _active_edit_error(noun: str) -> str:
|
|
1417
|
+
return f"Workflow is active; deactivate it before changing {noun}"
|
|
1418
|
+
|
|
1419
|
+
|
|
892
1420
|
def create_graph_node(
|
|
893
1421
|
*,
|
|
894
1422
|
tab_id: str,
|
|
@@ -899,6 +1427,8 @@ def create_graph_node(
|
|
|
899
1427
|
tab = workspace_store.get_tab(tab_id)
|
|
900
1428
|
if tab is None:
|
|
901
1429
|
return None, f"Tab '{tab_id}' not found"
|
|
1430
|
+
if _is_active(tab):
|
|
1431
|
+
return None, _active_edit_error("nodes")
|
|
902
1432
|
node_id = str(uuid.uuid4())
|
|
903
1433
|
node = build_workflow_node_definition(
|
|
904
1434
|
node_id=node_id,
|
|
@@ -916,8 +1446,6 @@ def create_agent_node(
|
|
|
916
1446
|
tab_id: str,
|
|
917
1447
|
name: str | None = None,
|
|
918
1448
|
tools: list[str] | None = None,
|
|
919
|
-
write_dirs: list[str] | None = None,
|
|
920
|
-
allow_network: bool = False,
|
|
921
1449
|
creator_node_id: str | None = None,
|
|
922
1450
|
connect_to_creator: bool | None = None,
|
|
923
1451
|
) -> tuple[GraphNodeRecord | None, str | None]:
|
|
@@ -925,14 +1453,14 @@ def create_agent_node(
|
|
|
925
1453
|
tab = workspace_store.get_tab(tab_id)
|
|
926
1454
|
if tab is None:
|
|
927
1455
|
return None, f"Tab '{tab_id}' not found"
|
|
1456
|
+
if _is_active(tab):
|
|
1457
|
+
return None, _active_edit_error("nodes")
|
|
928
1458
|
|
|
929
1459
|
config, error = build_node_config(
|
|
930
1460
|
role_name=role_name,
|
|
931
1461
|
tab_id=tab_id,
|
|
932
1462
|
name=name,
|
|
933
1463
|
tools=tools,
|
|
934
|
-
write_dirs=write_dirs,
|
|
935
|
-
allow_network=allow_network,
|
|
936
1464
|
)
|
|
937
1465
|
if error is not None or config is None:
|
|
938
1466
|
return None, error
|
|
@@ -980,6 +1508,10 @@ def update_tab_definition(
|
|
|
980
1508
|
edge_ids = [edge.id for edge in next_definition.edges]
|
|
981
1509
|
if len(edge_ids) != len(set(edge_ids)):
|
|
982
1510
|
return None, "Workflow definition contains duplicate edge ids"
|
|
1511
|
+
if _is_active(tab) and _semantic_definition(tab.definition) != _semantic_definition(
|
|
1512
|
+
next_definition
|
|
1513
|
+
):
|
|
1514
|
+
return None, _active_edit_error("workflow structure")
|
|
983
1515
|
|
|
984
1516
|
current_agent_ids = {
|
|
985
1517
|
node.id for node in tab.definition.nodes if node.type == WorkflowNodeKind.AGENT
|
|
@@ -1010,8 +1542,6 @@ def update_tab_definition(
|
|
|
1010
1542
|
name=str(node.config["name"])
|
|
1011
1543
|
if isinstance(node.config.get("name"), str)
|
|
1012
1544
|
else None,
|
|
1013
|
-
write_dirs=list(record.config.write_dirs),
|
|
1014
|
-
allow_network=record.config.allow_network,
|
|
1015
1545
|
)
|
|
1016
1546
|
if error is not None or config is None:
|
|
1017
1547
|
return None, error or f"Failed to validate agent node '{node.id}'"
|
|
@@ -1043,7 +1573,6 @@ def update_tab_definition(
|
|
|
1043
1573
|
source_node.outputs,
|
|
1044
1574
|
port_key=edge.from_port_key,
|
|
1045
1575
|
direction=PortDirection.OUTPUT,
|
|
1046
|
-
kind=edge.kind,
|
|
1047
1576
|
)
|
|
1048
1577
|
if source_port is None:
|
|
1049
1578
|
return None, f"Output port '{edge.from_port_key}' is invalid"
|
|
@@ -1051,10 +1580,15 @@ def update_tab_definition(
|
|
|
1051
1580
|
target_node.inputs,
|
|
1052
1581
|
port_key=edge.to_port_key,
|
|
1053
1582
|
direction=PortDirection.INPUT,
|
|
1054
|
-
kind=edge.kind,
|
|
1055
1583
|
)
|
|
1056
1584
|
if target_port is None:
|
|
1057
1585
|
return None, f"Input port '{edge.to_port_key}' is invalid"
|
|
1586
|
+
if source_port.type != target_port.type:
|
|
1587
|
+
return (
|
|
1588
|
+
None,
|
|
1589
|
+
f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
|
|
1590
|
+
f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
|
|
1591
|
+
)
|
|
1058
1592
|
target_key = (edge.to_node_id, edge.to_port_key)
|
|
1059
1593
|
if target_key in seen_target_ports and not target_port.multiple:
|
|
1060
1594
|
return None, f"Input port '{edge.to_port_key}' already has an incoming edge"
|
|
@@ -1070,20 +1604,640 @@ def _port_matches(
|
|
|
1070
1604
|
*,
|
|
1071
1605
|
port_key: str,
|
|
1072
1606
|
direction: PortDirection,
|
|
1073
|
-
kind: EdgeKind,
|
|
1074
1607
|
) -> WorkflowPort | None:
|
|
1075
1608
|
return next(
|
|
1076
1609
|
(
|
|
1077
1610
|
port
|
|
1078
1611
|
for port in ports
|
|
1079
|
-
if port.key == port_key
|
|
1080
|
-
and port.direction == direction
|
|
1081
|
-
and port.kind == kind
|
|
1612
|
+
if port.key == port_key and port.direction == direction
|
|
1082
1613
|
),
|
|
1083
1614
|
None,
|
|
1084
1615
|
)
|
|
1085
1616
|
|
|
1086
1617
|
|
|
1618
|
+
def _semantic_definition(definition: WorkflowDefinition) -> dict[str, object]:
|
|
1619
|
+
payload = definition.serialize()
|
|
1620
|
+
payload.pop("view", None)
|
|
1621
|
+
return payload
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
_PORT_TYPES = {item.value for item in PortType}
|
|
1625
|
+
_TRIGGER_KINDS = {"manual", "cron"}
|
|
1626
|
+
_LLM_RESPONSE_FORMAT_KINDS = {"text", "json_schema"}
|
|
1627
|
+
_IF_OPERATORS = {
|
|
1628
|
+
"eq",
|
|
1629
|
+
"neq",
|
|
1630
|
+
"contains",
|
|
1631
|
+
"not_contains",
|
|
1632
|
+
"is_empty",
|
|
1633
|
+
"is_not_empty",
|
|
1634
|
+
"gt",
|
|
1635
|
+
"lt",
|
|
1636
|
+
"gte",
|
|
1637
|
+
"lte",
|
|
1638
|
+
"is_truthy",
|
|
1639
|
+
"is_falsy",
|
|
1640
|
+
}
|
|
1641
|
+
_MERGE_STRATEGIES = {"collect", "named_object", "first_completed"}
|
|
1642
|
+
_CODE_RUNTIMES = {"javascript", "python"}
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def _validation_error(
|
|
1646
|
+
errors: list[dict[str, str]],
|
|
1647
|
+
*,
|
|
1648
|
+
message: str,
|
|
1649
|
+
node_id: str | None = None,
|
|
1650
|
+
edge_id: str | None = None,
|
|
1651
|
+
path: str | None = None,
|
|
1652
|
+
) -> None:
|
|
1653
|
+
error: dict[str, str] = {"message": message}
|
|
1654
|
+
if node_id is not None:
|
|
1655
|
+
error["node_id"] = node_id
|
|
1656
|
+
if edge_id is not None:
|
|
1657
|
+
error["edge_id"] = edge_id
|
|
1658
|
+
if path is not None:
|
|
1659
|
+
error["path"] = path
|
|
1660
|
+
errors.append(error)
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _is_json_serializable(value: object) -> bool:
|
|
1664
|
+
try:
|
|
1665
|
+
json.dumps(value)
|
|
1666
|
+
except (TypeError, ValueError):
|
|
1667
|
+
return False
|
|
1668
|
+
return True
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _is_valid_parts_value(value: object) -> bool:
|
|
1672
|
+
if not isinstance(value, list) or not value:
|
|
1673
|
+
return False
|
|
1674
|
+
for part in value:
|
|
1675
|
+
if not isinstance(part, dict):
|
|
1676
|
+
return False
|
|
1677
|
+
part_type = part.get("type")
|
|
1678
|
+
if part_type == "text":
|
|
1679
|
+
text = part.get("text")
|
|
1680
|
+
if not isinstance(text, str) or not text:
|
|
1681
|
+
return False
|
|
1682
|
+
continue
|
|
1683
|
+
if part_type == "image":
|
|
1684
|
+
asset_id = part.get("asset_id")
|
|
1685
|
+
if not isinstance(asset_id, str) or not asset_id.strip():
|
|
1686
|
+
return False
|
|
1687
|
+
continue
|
|
1688
|
+
return False
|
|
1689
|
+
return True
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def _validate_typed_value(value: object, port_type: PortType) -> bool:
|
|
1693
|
+
if port_type == PortType.PARTS:
|
|
1694
|
+
return _is_valid_parts_value(value)
|
|
1695
|
+
if port_type == PortType.STRING:
|
|
1696
|
+
return isinstance(value, str) and bool(value)
|
|
1697
|
+
return isinstance(value, dict) and _is_json_serializable(value)
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _get_string_config(config: dict[str, object], key: str) -> str:
|
|
1701
|
+
value = config.get(key)
|
|
1702
|
+
return value.strip() if isinstance(value, str) else ""
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _parse_response_format_kind(value: object) -> str:
|
|
1706
|
+
if isinstance(value, str):
|
|
1707
|
+
return value.strip()
|
|
1708
|
+
if isinstance(value, dict):
|
|
1709
|
+
kind = value.get("kind")
|
|
1710
|
+
return kind.strip() if isinstance(kind, str) else ""
|
|
1711
|
+
return ""
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _response_format_schema(value: object) -> object:
|
|
1715
|
+
if not isinstance(value, dict):
|
|
1716
|
+
return None
|
|
1717
|
+
return value.get("schema")
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _validate_cron_expression(value: object) -> bool:
|
|
1721
|
+
if not isinstance(value, str):
|
|
1722
|
+
return False
|
|
1723
|
+
fields = value.split()
|
|
1724
|
+
if len(fields) not in {5, 6}:
|
|
1725
|
+
return False
|
|
1726
|
+
return all(field.strip() for field in fields)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _validate_timezone(value: object) -> bool:
|
|
1730
|
+
if not isinstance(value, str) or not value.strip():
|
|
1731
|
+
return False
|
|
1732
|
+
try:
|
|
1733
|
+
ZoneInfo(value.strip())
|
|
1734
|
+
except ZoneInfoNotFoundError:
|
|
1735
|
+
return False
|
|
1736
|
+
return True
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _validate_trigger_node(
|
|
1740
|
+
node: WorkflowNodeDefinition,
|
|
1741
|
+
errors: list[dict[str, str]],
|
|
1742
|
+
) -> None:
|
|
1743
|
+
kind = _get_string_config(node.config, "kind")
|
|
1744
|
+
if kind not in _TRIGGER_KINDS:
|
|
1745
|
+
_validation_error(
|
|
1746
|
+
errors,
|
|
1747
|
+
node_id=node.id,
|
|
1748
|
+
path="config.kind",
|
|
1749
|
+
message="trigger kind must be manual or cron",
|
|
1750
|
+
)
|
|
1751
|
+
output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
|
|
1752
|
+
if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
|
|
1753
|
+
_validation_error(
|
|
1754
|
+
errors,
|
|
1755
|
+
node_id=node.id,
|
|
1756
|
+
path="config.output_type",
|
|
1757
|
+
message="trigger output_type must be parts, string, or json",
|
|
1758
|
+
)
|
|
1759
|
+
if not node.outputs or any(port.type != output_type for port in node.outputs):
|
|
1760
|
+
_validation_error(
|
|
1761
|
+
errors,
|
|
1762
|
+
node_id=node.id,
|
|
1763
|
+
path="outputs",
|
|
1764
|
+
message="trigger output port type must match config.output_type",
|
|
1765
|
+
)
|
|
1766
|
+
if "message" not in node.config or not _validate_typed_value(
|
|
1767
|
+
node.config.get("message"),
|
|
1768
|
+
output_type,
|
|
1769
|
+
):
|
|
1770
|
+
_validation_error(
|
|
1771
|
+
errors,
|
|
1772
|
+
node_id=node.id,
|
|
1773
|
+
path="config.message",
|
|
1774
|
+
message="trigger message must match output_type",
|
|
1775
|
+
)
|
|
1776
|
+
if kind == "cron":
|
|
1777
|
+
if not _validate_cron_expression(node.config.get("cron")):
|
|
1778
|
+
_validation_error(
|
|
1779
|
+
errors,
|
|
1780
|
+
node_id=node.id,
|
|
1781
|
+
path="config.cron",
|
|
1782
|
+
message="cron trigger requires a 5-field or 6-field expression",
|
|
1783
|
+
)
|
|
1784
|
+
if not _validate_timezone(node.config.get("timezone")):
|
|
1785
|
+
_validation_error(
|
|
1786
|
+
errors,
|
|
1787
|
+
node_id=node.id,
|
|
1788
|
+
path="config.timezone",
|
|
1789
|
+
message="cron trigger requires an IANA timezone",
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _resolve_llm_model(
|
|
1794
|
+
node: WorkflowNodeDefinition,
|
|
1795
|
+
) -> tuple[str, str]:
|
|
1796
|
+
model_config = node.config.get("model")
|
|
1797
|
+
if isinstance(model_config, dict):
|
|
1798
|
+
provider_id = model_config.get("provider_id")
|
|
1799
|
+
model = model_config.get("model")
|
|
1800
|
+
return (
|
|
1801
|
+
provider_id.strip() if isinstance(provider_id, str) else "",
|
|
1802
|
+
model.strip() if isinstance(model, str) else "",
|
|
1803
|
+
)
|
|
1804
|
+
provider_id = node.config.get("provider_id")
|
|
1805
|
+
model = node.config.get("model")
|
|
1806
|
+
return (
|
|
1807
|
+
provider_id.strip() if isinstance(provider_id, str) else "",
|
|
1808
|
+
model.strip() if isinstance(model, str) else "",
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def _validate_llm_node(
|
|
1813
|
+
node: WorkflowNodeDefinition,
|
|
1814
|
+
errors: list[dict[str, str]],
|
|
1815
|
+
) -> None:
|
|
1816
|
+
input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
|
|
1817
|
+
output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
|
|
1818
|
+
if str(node.config.get("input_type", input_type.value)) not in _PORT_TYPES:
|
|
1819
|
+
_validation_error(
|
|
1820
|
+
errors,
|
|
1821
|
+
node_id=node.id,
|
|
1822
|
+
path="config.input_type",
|
|
1823
|
+
message="llm input_type must be parts, string, or json",
|
|
1824
|
+
)
|
|
1825
|
+
if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
|
|
1826
|
+
_validation_error(
|
|
1827
|
+
errors,
|
|
1828
|
+
node_id=node.id,
|
|
1829
|
+
path="config.output_type",
|
|
1830
|
+
message="llm output_type must be parts, string, or json",
|
|
1831
|
+
)
|
|
1832
|
+
response_format = node.config.get("response_format", {"kind": "text"})
|
|
1833
|
+
response_format_kind = _parse_response_format_kind(response_format)
|
|
1834
|
+
if response_format_kind not in _LLM_RESPONSE_FORMAT_KINDS:
|
|
1835
|
+
_validation_error(
|
|
1836
|
+
errors,
|
|
1837
|
+
node_id=node.id,
|
|
1838
|
+
path="config.response_format",
|
|
1839
|
+
message="llm response_format must be text or json_schema",
|
|
1840
|
+
)
|
|
1841
|
+
if response_format_kind == "json_schema":
|
|
1842
|
+
schema = _response_format_schema(response_format)
|
|
1843
|
+
if not isinstance(schema, dict) or not _is_json_serializable(schema):
|
|
1844
|
+
_validation_error(
|
|
1845
|
+
errors,
|
|
1846
|
+
node_id=node.id,
|
|
1847
|
+
path="config.response_format.schema",
|
|
1848
|
+
message="json_schema response_format requires a JSON schema object",
|
|
1849
|
+
)
|
|
1850
|
+
if output_type != PortType.JSON:
|
|
1851
|
+
_validation_error(
|
|
1852
|
+
errors,
|
|
1853
|
+
node_id=node.id,
|
|
1854
|
+
path="config.output_type",
|
|
1855
|
+
message="json_schema response_format requires json output_type",
|
|
1856
|
+
)
|
|
1857
|
+
elif output_type == PortType.JSON:
|
|
1858
|
+
_validation_error(
|
|
1859
|
+
errors,
|
|
1860
|
+
node_id=node.id,
|
|
1861
|
+
path="config.output_type",
|
|
1862
|
+
message="text response_format requires parts or string output_type",
|
|
1863
|
+
)
|
|
1864
|
+
provider_id, model_id = _resolve_llm_model(node)
|
|
1865
|
+
settings = settings_module.get_settings()
|
|
1866
|
+
provider = find_provider(settings, provider_id)
|
|
1867
|
+
if provider is None:
|
|
1868
|
+
_validation_error(
|
|
1869
|
+
errors,
|
|
1870
|
+
node_id=node.id,
|
|
1871
|
+
path="config.model",
|
|
1872
|
+
message="llm model provider was not found",
|
|
1873
|
+
)
|
|
1874
|
+
return
|
|
1875
|
+
if not model_id:
|
|
1876
|
+
_validation_error(
|
|
1877
|
+
errors,
|
|
1878
|
+
node_id=node.id,
|
|
1879
|
+
path="config.model",
|
|
1880
|
+
message="llm model must not be empty",
|
|
1881
|
+
)
|
|
1882
|
+
return
|
|
1883
|
+
if provider.models and all(entry.model != model_id for entry in provider.models):
|
|
1884
|
+
_validation_error(
|
|
1885
|
+
errors,
|
|
1886
|
+
node_id=node.id,
|
|
1887
|
+
path="config.model",
|
|
1888
|
+
message="llm model is not in the provider model catalog",
|
|
1889
|
+
)
|
|
1890
|
+
return
|
|
1891
|
+
model_info = resolve_model_info(provider=provider, model_id=model_id)
|
|
1892
|
+
if (
|
|
1893
|
+
response_format_kind == "json_schema"
|
|
1894
|
+
and not model_info.capabilities.structured_output
|
|
1895
|
+
):
|
|
1896
|
+
_validation_error(
|
|
1897
|
+
errors,
|
|
1898
|
+
node_id=node.id,
|
|
1899
|
+
path="config.model",
|
|
1900
|
+
message="llm model does not support structured_output",
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _validate_if_node(
|
|
1905
|
+
node: WorkflowNodeDefinition,
|
|
1906
|
+
errors: list[dict[str, str]],
|
|
1907
|
+
) -> None:
|
|
1908
|
+
expression = node.config.get("expression")
|
|
1909
|
+
if not isinstance(expression, dict):
|
|
1910
|
+
_validation_error(
|
|
1911
|
+
errors,
|
|
1912
|
+
node_id=node.id,
|
|
1913
|
+
path="config.expression",
|
|
1914
|
+
message="if expression must be an object",
|
|
1915
|
+
)
|
|
1916
|
+
return
|
|
1917
|
+
field = expression.get("field")
|
|
1918
|
+
operator = expression.get("operator")
|
|
1919
|
+
if not isinstance(field, str) or not field.strip().startswith("{{input."):
|
|
1920
|
+
_validation_error(
|
|
1921
|
+
errors,
|
|
1922
|
+
node_id=node.id,
|
|
1923
|
+
path="config.expression.field",
|
|
1924
|
+
message="if expression field must reference an input path",
|
|
1925
|
+
)
|
|
1926
|
+
if not isinstance(operator, str) or operator not in _IF_OPERATORS:
|
|
1927
|
+
_validation_error(
|
|
1928
|
+
errors,
|
|
1929
|
+
node_id=node.id,
|
|
1930
|
+
path="config.expression.operator",
|
|
1931
|
+
message="if expression operator is not supported",
|
|
1932
|
+
)
|
|
1933
|
+
if (
|
|
1934
|
+
operator in {"eq", "neq", "contains", "not_contains", "gt", "lt", "gte", "lte"}
|
|
1935
|
+
and "value" not in expression
|
|
1936
|
+
):
|
|
1937
|
+
_validation_error(
|
|
1938
|
+
errors,
|
|
1939
|
+
node_id=node.id,
|
|
1940
|
+
path="config.expression.value",
|
|
1941
|
+
message="if expression operator requires a value",
|
|
1942
|
+
)
|
|
1943
|
+
input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
|
|
1944
|
+
if any(port.type != input_type for port in node.inputs + node.outputs):
|
|
1945
|
+
_validation_error(
|
|
1946
|
+
errors,
|
|
1947
|
+
node_id=node.id,
|
|
1948
|
+
path="ports",
|
|
1949
|
+
message="if node input and output port types must match",
|
|
1950
|
+
)
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def _validate_merge_node(
|
|
1954
|
+
node: WorkflowNodeDefinition,
|
|
1955
|
+
errors: list[dict[str, str]],
|
|
1956
|
+
) -> None:
|
|
1957
|
+
strategy = _get_string_config(node.config, "strategy") or "collect"
|
|
1958
|
+
if strategy not in _MERGE_STRATEGIES:
|
|
1959
|
+
_validation_error(
|
|
1960
|
+
errors,
|
|
1961
|
+
node_id=node.id,
|
|
1962
|
+
path="config.strategy",
|
|
1963
|
+
message="merge strategy must be collect, named_object, or first_completed",
|
|
1964
|
+
)
|
|
1965
|
+
if strategy == "named_object" and not isinstance(
|
|
1966
|
+
node.config.get("named_inputs"),
|
|
1967
|
+
dict,
|
|
1968
|
+
):
|
|
1969
|
+
_validation_error(
|
|
1970
|
+
errors,
|
|
1971
|
+
node_id=node.id,
|
|
1972
|
+
path="config.named_inputs",
|
|
1973
|
+
message="named_object merge requires named_inputs",
|
|
1974
|
+
)
|
|
1975
|
+
if not node.inputs or not node.inputs[0].multiple:
|
|
1976
|
+
_validation_error(
|
|
1977
|
+
errors,
|
|
1978
|
+
node_id=node.id,
|
|
1979
|
+
path="inputs",
|
|
1980
|
+
message="merge input port must allow multiple upstream values",
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
def _validate_code_node(
|
|
1985
|
+
node: WorkflowNodeDefinition,
|
|
1986
|
+
errors: list[dict[str, str]],
|
|
1987
|
+
) -> None:
|
|
1988
|
+
runtime = _get_string_config(node.config, "runtime")
|
|
1989
|
+
source = node.config.get("source")
|
|
1990
|
+
if runtime not in _CODE_RUNTIMES:
|
|
1991
|
+
_validation_error(
|
|
1992
|
+
errors,
|
|
1993
|
+
node_id=node.id,
|
|
1994
|
+
path="config.runtime",
|
|
1995
|
+
message="code runtime must be javascript or python",
|
|
1996
|
+
)
|
|
1997
|
+
if not isinstance(source, str) or not source.strip():
|
|
1998
|
+
_validation_error(
|
|
1999
|
+
errors,
|
|
2000
|
+
node_id=node.id,
|
|
2001
|
+
path="config.source",
|
|
2002
|
+
message="code source must not be empty",
|
|
2003
|
+
)
|
|
2004
|
+
return
|
|
2005
|
+
if runtime == "python":
|
|
2006
|
+
try:
|
|
2007
|
+
ast.parse(source)
|
|
2008
|
+
except SyntaxError as exc:
|
|
2009
|
+
_validation_error(
|
|
2010
|
+
errors,
|
|
2011
|
+
node_id=node.id,
|
|
2012
|
+
path="config.source",
|
|
2013
|
+
message=f"python source is not parseable: {exc.msg}",
|
|
2014
|
+
)
|
|
2015
|
+
elif runtime == "javascript" and shutil.which("node"):
|
|
2016
|
+
completed = subprocess.run(
|
|
2017
|
+
["node", "--check"],
|
|
2018
|
+
input=source,
|
|
2019
|
+
text=True,
|
|
2020
|
+
capture_output=True,
|
|
2021
|
+
timeout=5,
|
|
2022
|
+
check=False,
|
|
2023
|
+
)
|
|
2024
|
+
if completed.returncode != 0:
|
|
2025
|
+
_validation_error(
|
|
2026
|
+
errors,
|
|
2027
|
+
node_id=node.id,
|
|
2028
|
+
path="config.source",
|
|
2029
|
+
message="javascript source is not parseable",
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def _validate_node_config(
|
|
2034
|
+
node: WorkflowNodeDefinition,
|
|
2035
|
+
errors: list[dict[str, str]],
|
|
2036
|
+
) -> None:
|
|
2037
|
+
if node.type == WorkflowNodeKind.TRIGGER:
|
|
2038
|
+
_validate_trigger_node(node, errors)
|
|
2039
|
+
elif node.type == WorkflowNodeKind.LLM:
|
|
2040
|
+
_validate_llm_node(node, errors)
|
|
2041
|
+
elif node.type == WorkflowNodeKind.IF:
|
|
2042
|
+
_validate_if_node(node, errors)
|
|
2043
|
+
elif node.type == WorkflowNodeKind.MERGE:
|
|
2044
|
+
_validate_merge_node(node, errors)
|
|
2045
|
+
elif node.type == WorkflowNodeKind.CODE:
|
|
2046
|
+
_validate_code_node(node, errors)
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
def _is_legacy_required_agent_input(
|
|
2050
|
+
node: WorkflowNodeDefinition,
|
|
2051
|
+
port: WorkflowPort,
|
|
2052
|
+
) -> bool:
|
|
2053
|
+
return (
|
|
2054
|
+
node.type == WorkflowNodeKind.AGENT
|
|
2055
|
+
and port.key == "in"
|
|
2056
|
+
and port.direction == PortDirection.INPUT
|
|
2057
|
+
and port.type == PortType.PARTS
|
|
2058
|
+
and not port.multiple
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def validate_workflow_activation(tab: Tab) -> list[dict[str, str]]:
|
|
2063
|
+
errors: list[dict[str, str]] = []
|
|
2064
|
+
if not tab.definition.nodes:
|
|
2065
|
+
_validation_error(
|
|
2066
|
+
errors,
|
|
2067
|
+
message="Add at least one node before activating this workflow",
|
|
2068
|
+
path="definition.nodes",
|
|
2069
|
+
)
|
|
2070
|
+
node_ids = [node.id for node in tab.definition.nodes]
|
|
2071
|
+
if len(node_ids) != len(set(node_ids)):
|
|
2072
|
+
_validation_error(
|
|
2073
|
+
errors,
|
|
2074
|
+
message="workflow definition contains duplicate node ids",
|
|
2075
|
+
path="definition.nodes",
|
|
2076
|
+
)
|
|
2077
|
+
edge_ids = [edge.id for edge in tab.definition.edges]
|
|
2078
|
+
if len(edge_ids) != len(set(edge_ids)):
|
|
2079
|
+
_validation_error(
|
|
2080
|
+
errors,
|
|
2081
|
+
message="workflow definition contains duplicate edge ids",
|
|
2082
|
+
path="definition.edges",
|
|
2083
|
+
)
|
|
2084
|
+
incoming_edges_by_port: dict[tuple[str, str], list[GraphEdge]] = {}
|
|
2085
|
+
seen_edge_endpoints: set[tuple[str, str, str, str]] = set()
|
|
2086
|
+
for edge in tab.definition.edges:
|
|
2087
|
+
edge_endpoint = (
|
|
2088
|
+
edge.from_node_id,
|
|
2089
|
+
edge.from_port_key,
|
|
2090
|
+
edge.to_node_id,
|
|
2091
|
+
edge.to_port_key,
|
|
2092
|
+
)
|
|
2093
|
+
if edge_endpoint in seen_edge_endpoints:
|
|
2094
|
+
_validation_error(
|
|
2095
|
+
errors,
|
|
2096
|
+
edge_id=edge.id,
|
|
2097
|
+
message="duplicate edges are not allowed",
|
|
2098
|
+
)
|
|
2099
|
+
seen_edge_endpoints.add(edge_endpoint)
|
|
2100
|
+
source_node = tab.definition.get_node(edge.from_node_id)
|
|
2101
|
+
target_node = tab.definition.get_node(edge.to_node_id)
|
|
2102
|
+
if source_node is None:
|
|
2103
|
+
_validation_error(
|
|
2104
|
+
errors,
|
|
2105
|
+
edge_id=edge.id,
|
|
2106
|
+
message=f"edge source node '{edge.from_node_id}' does not exist",
|
|
2107
|
+
)
|
|
2108
|
+
continue
|
|
2109
|
+
if target_node is None:
|
|
2110
|
+
_validation_error(
|
|
2111
|
+
errors,
|
|
2112
|
+
edge_id=edge.id,
|
|
2113
|
+
message=f"edge target node '{edge.to_node_id}' does not exist",
|
|
2114
|
+
)
|
|
2115
|
+
continue
|
|
2116
|
+
source_port = _port_matches(
|
|
2117
|
+
source_node.outputs,
|
|
2118
|
+
port_key=edge.from_port_key,
|
|
2119
|
+
direction=PortDirection.OUTPUT,
|
|
2120
|
+
)
|
|
2121
|
+
target_port = _port_matches(
|
|
2122
|
+
target_node.inputs,
|
|
2123
|
+
port_key=edge.to_port_key,
|
|
2124
|
+
direction=PortDirection.INPUT,
|
|
2125
|
+
)
|
|
2126
|
+
if source_port is None:
|
|
2127
|
+
_validation_error(
|
|
2128
|
+
errors,
|
|
2129
|
+
edge_id=edge.id,
|
|
2130
|
+
path="from_port_key",
|
|
2131
|
+
message=f"output port '{edge.from_port_key}' is invalid",
|
|
2132
|
+
)
|
|
2133
|
+
continue
|
|
2134
|
+
if target_port is None:
|
|
2135
|
+
_validation_error(
|
|
2136
|
+
errors,
|
|
2137
|
+
edge_id=edge.id,
|
|
2138
|
+
path="to_port_key",
|
|
2139
|
+
message=f"input port '{edge.to_port_key}' is invalid",
|
|
2140
|
+
)
|
|
2141
|
+
continue
|
|
2142
|
+
if source_port.type != target_port.type:
|
|
2143
|
+
_validation_error(
|
|
2144
|
+
errors,
|
|
2145
|
+
edge_id=edge.id,
|
|
2146
|
+
message=(
|
|
2147
|
+
f"port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
|
|
2148
|
+
f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}"
|
|
2149
|
+
),
|
|
2150
|
+
)
|
|
2151
|
+
incoming_edges_by_port.setdefault(
|
|
2152
|
+
(edge.to_node_id, edge.to_port_key), []
|
|
2153
|
+
).append(edge)
|
|
2154
|
+
for node in tab.definition.nodes:
|
|
2155
|
+
for port in node.inputs:
|
|
2156
|
+
edges = incoming_edges_by_port.get((node.id, port.key), [])
|
|
2157
|
+
if (
|
|
2158
|
+
port.required
|
|
2159
|
+
and not edges
|
|
2160
|
+
and not _is_legacy_required_agent_input(node, port)
|
|
2161
|
+
):
|
|
2162
|
+
_validation_error(
|
|
2163
|
+
errors,
|
|
2164
|
+
node_id=node.id,
|
|
2165
|
+
path=f"inputs.{port.key}",
|
|
2166
|
+
message=f"required input port '{port.key}' has no upstream edge",
|
|
2167
|
+
)
|
|
2168
|
+
if len(edges) > 1 and not port.multiple:
|
|
2169
|
+
_validation_error(
|
|
2170
|
+
errors,
|
|
2171
|
+
node_id=node.id,
|
|
2172
|
+
path=f"inputs.{port.key}",
|
|
2173
|
+
message=f"input port '{port.key}' accepts only one upstream edge",
|
|
2174
|
+
)
|
|
2175
|
+
_validate_node_config(node, errors)
|
|
2176
|
+
return errors
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
def activate_tab(
|
|
2180
|
+
*,
|
|
2181
|
+
tab_id: str,
|
|
2182
|
+
actor_id: str = "assistant",
|
|
2183
|
+
) -> tuple[Tab | None, list[dict[str, str]] | None, str | None]:
|
|
2184
|
+
tab = workspace_store.get_tab(tab_id)
|
|
2185
|
+
if tab is None:
|
|
2186
|
+
return None, None, f"Tab '{tab_id}' not found"
|
|
2187
|
+
errors = validate_workflow_activation(tab)
|
|
2188
|
+
if errors:
|
|
2189
|
+
return None, errors, None
|
|
2190
|
+
if tab.activation_state != WorkflowActivationState.ACTIVE:
|
|
2191
|
+
tab.activation_state = WorkflowActivationState.ACTIVE
|
|
2192
|
+
_persist_tab(tab, actor_id=actor_id)
|
|
2193
|
+
return tab, None, None
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def deactivate_tab(
|
|
2197
|
+
*,
|
|
2198
|
+
tab_id: str,
|
|
2199
|
+
actor_id: str = "assistant",
|
|
2200
|
+
timeout: float = SYSTEM_NODE_TIMEOUT,
|
|
2201
|
+
) -> tuple[Tab | None, str | None]:
|
|
2202
|
+
tab = workspace_store.get_tab(tab_id)
|
|
2203
|
+
if tab is None:
|
|
2204
|
+
return None, f"Tab '{tab_id}' not found"
|
|
2205
|
+
|
|
2206
|
+
lingering_node_ids: list[str] = []
|
|
2207
|
+
ordinary_node_ids = {
|
|
2208
|
+
node.id
|
|
2209
|
+
for node in tab.definition.nodes
|
|
2210
|
+
if node.type == WorkflowNodeKind.AGENT
|
|
2211
|
+
and not is_tab_leader(node_id=node.id, tab_id=tab_id)
|
|
2212
|
+
}
|
|
2213
|
+
for node_id in ordinary_node_ids:
|
|
2214
|
+
live_node = registry.get(node_id)
|
|
2215
|
+
if live_node is not None:
|
|
2216
|
+
if live_node.state in {AgentState.RUNNING, AgentState.SLEEPING}:
|
|
2217
|
+
live_node.request_interrupt()
|
|
2218
|
+
if not live_node.wait_until_idle(timeout=timeout):
|
|
2219
|
+
lingering_node_ids.append(live_node.uuid)
|
|
2220
|
+
continue
|
|
2221
|
+
record = workspace_store.get_node_record(node_id)
|
|
2222
|
+
if record is None:
|
|
2223
|
+
continue
|
|
2224
|
+
if record.state in {AgentState.RUNNING, AgentState.SLEEPING}:
|
|
2225
|
+
record.state = AgentState.IDLE
|
|
2226
|
+
workspace_store.upsert_node_record(record)
|
|
2227
|
+
|
|
2228
|
+
if lingering_node_ids:
|
|
2229
|
+
return (
|
|
2230
|
+
None,
|
|
2231
|
+
"Failed to deactivate workflow because some nodes did not stop: "
|
|
2232
|
+
+ ", ".join(node_id[:8] for node_id in lingering_node_ids),
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
if tab.activation_state != WorkflowActivationState.INACTIVE:
|
|
2236
|
+
tab.activation_state = WorkflowActivationState.INACTIVE
|
|
2237
|
+
_persist_tab(tab, actor_id=actor_id)
|
|
2238
|
+
return tab, None
|
|
2239
|
+
|
|
2240
|
+
|
|
1087
2241
|
def create_edge(
|
|
1088
2242
|
*,
|
|
1089
2243
|
tab_id: str | None = None,
|
|
@@ -1093,7 +2247,7 @@ def create_edge(
|
|
|
1093
2247
|
to_port_key: str = "in",
|
|
1094
2248
|
kind: EdgeKind | str = EdgeKind.CONTROL,
|
|
1095
2249
|
) -> tuple[GraphEdge | None, str | None]:
|
|
1096
|
-
|
|
2250
|
+
del kind
|
|
1097
2251
|
resolved_tab_id = tab_id
|
|
1098
2252
|
if resolved_tab_id is None:
|
|
1099
2253
|
source_record = workspace_store.get_node_record(from_node_id)
|
|
@@ -1107,6 +2261,8 @@ def create_edge(
|
|
|
1107
2261
|
tab = workspace_store.get_tab(resolved_tab_id)
|
|
1108
2262
|
if tab is None:
|
|
1109
2263
|
return None, f"Tab '{resolved_tab_id}' not found"
|
|
2264
|
+
if _is_active(tab):
|
|
2265
|
+
return None, _active_edit_error("edges")
|
|
1110
2266
|
if is_tab_leader(node_id=from_node_id, tab_id=resolved_tab_id) or is_tab_leader(
|
|
1111
2267
|
node_id=to_node_id,
|
|
1112
2268
|
tab_id=resolved_tab_id,
|
|
@@ -1124,7 +2280,6 @@ def create_edge(
|
|
|
1124
2280
|
source_node.outputs,
|
|
1125
2281
|
port_key=from_port_key,
|
|
1126
2282
|
direction=PortDirection.OUTPUT,
|
|
1127
|
-
kind=resolved_kind,
|
|
1128
2283
|
)
|
|
1129
2284
|
if source_port is None:
|
|
1130
2285
|
return None, f"Output port '{from_port_key}' is invalid"
|
|
@@ -1132,16 +2287,20 @@ def create_edge(
|
|
|
1132
2287
|
target_node.inputs,
|
|
1133
2288
|
port_key=to_port_key,
|
|
1134
2289
|
direction=PortDirection.INPUT,
|
|
1135
|
-
kind=resolved_kind,
|
|
1136
2290
|
)
|
|
1137
2291
|
if target_port is None:
|
|
1138
2292
|
return None, f"Input port '{to_port_key}' is invalid"
|
|
2293
|
+
if source_port.type != target_port.type:
|
|
2294
|
+
return (
|
|
2295
|
+
None,
|
|
2296
|
+
f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
|
|
2297
|
+
f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
|
|
2298
|
+
)
|
|
1139
2299
|
if any(
|
|
1140
2300
|
edge.from_node_id == from_node_id
|
|
1141
2301
|
and edge.from_port_key == from_port_key
|
|
1142
2302
|
and edge.to_node_id == to_node_id
|
|
1143
2303
|
and edge.to_port_key == to_port_key
|
|
1144
|
-
and edge.kind == resolved_kind
|
|
1145
2304
|
for edge in tab.definition.edges
|
|
1146
2305
|
):
|
|
1147
2306
|
return None, "Duplicate edges are not allowed"
|
|
@@ -1158,7 +2317,6 @@ def create_edge(
|
|
|
1158
2317
|
from_port_key=from_port_key,
|
|
1159
2318
|
to_node_id=to_node_id,
|
|
1160
2319
|
to_port_key=to_port_key,
|
|
1161
|
-
kind=resolved_kind,
|
|
1162
2320
|
)
|
|
1163
2321
|
tab.definition.edges.append(edge)
|
|
1164
2322
|
_persist_tab(tab, actor_id=from_node_id)
|
|
@@ -1177,6 +2335,8 @@ def delete_edge(
|
|
|
1177
2335
|
tab = workspace_store.get_tab(tab_id)
|
|
1178
2336
|
if tab is None:
|
|
1179
2337
|
return None, f"Tab '{tab_id}' not found"
|
|
2338
|
+
if _is_active(tab):
|
|
2339
|
+
return None, _active_edit_error("edges")
|
|
1180
2340
|
|
|
1181
2341
|
matched_edge: GraphEdge | None = None
|
|
1182
2342
|
for edge in tab.definition.edges:
|
|
@@ -1212,6 +2372,8 @@ def delete_agent_node(
|
|
|
1212
2372
|
tab = workspace_store.get_tab(tab_id)
|
|
1213
2373
|
if tab is None:
|
|
1214
2374
|
return None, f"Tab '{tab_id}' not found"
|
|
2375
|
+
if _is_active(tab):
|
|
2376
|
+
return None, _active_edit_error("nodes")
|
|
1215
2377
|
|
|
1216
2378
|
node_definition = tab.definition.get_node(node_id)
|
|
1217
2379
|
if node_definition is None:
|