flowent 0.0.1 → 0.0.5
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 +20 -9
- package/backend/.python-version +1 -0
- package/backend/README.md +74 -0
- package/backend/pyproject.toml +58 -0
- package/backend/src/flowent/__init__.py +3 -0
- 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__/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/_version.py +7 -0
- package/backend/src/flowent/access.py +247 -0
- package/backend/src/flowent/agent.py +3120 -0
- package/backend/src/flowent/assistant_commands.py +115 -0
- package/backend/src/flowent/channels/__init__.py +3 -0
- 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 +615 -0
- package/backend/src/flowent/cli.py +85 -0
- package/backend/src/flowent/config.py +14 -0
- package/backend/src/flowent/dev.py +3 -0
- package/backend/src/flowent/events.py +157 -0
- package/backend/src/flowent/graph_runtime.py +60 -0
- package/backend/src/flowent/graph_service.py +2508 -0
- package/backend/src/flowent/image_assets.py +356 -0
- package/backend/src/flowent/logging.py +155 -0
- package/backend/src/flowent/main.py +124 -0
- package/backend/src/flowent/mcp_service.py +1918 -0
- package/backend/src/flowent/model_metadata.py +102 -0
- package/backend/src/flowent/models/__init__.py +125 -0
- 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 +34 -0
- package/backend/src/flowent/models/base.py +24 -0
- package/backend/src/flowent/models/blueprint.py +176 -0
- package/backend/src/flowent/models/content.py +164 -0
- package/backend/src/flowent/models/delta.py +44 -0
- package/backend/src/flowent/models/event.py +51 -0
- package/backend/src/flowent/models/graph.py +472 -0
- package/backend/src/flowent/models/history.py +272 -0
- package/backend/src/flowent/models/llm.py +62 -0
- package/backend/src/flowent/models/message.py +33 -0
- package/backend/src/flowent/models/tab.py +85 -0
- package/backend/src/flowent/models/todo.py +10 -0
- package/backend/src/flowent/network.py +146 -0
- package/backend/src/flowent/observability_service.py +218 -0
- package/backend/src/flowent/prompts/__init__.py +67 -0
- 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 +250 -0
- package/backend/src/flowent/prompts/steward.py +64 -0
- package/backend/src/flowent/providers/__init__.py +23 -0
- 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/anthropic.py +468 -0
- package/backend/src/flowent/providers/base_url.py +60 -0
- package/backend/src/flowent/providers/configuration.py +189 -0
- package/backend/src/flowent/providers/content.py +122 -0
- package/backend/src/flowent/providers/errors.py +223 -0
- package/backend/src/flowent/providers/gateway.py +169 -0
- package/backend/src/flowent/providers/gemini.py +447 -0
- package/backend/src/flowent/providers/headers.py +20 -0
- package/backend/src/flowent/providers/management.py +96 -0
- package/backend/src/flowent/providers/ollama.py +293 -0
- package/backend/src/flowent/providers/openai.py +422 -0
- package/backend/src/flowent/providers/openai_responses.py +655 -0
- package/backend/src/flowent/providers/registry.py +144 -0
- package/backend/src/flowent/providers/sse.py +31 -0
- package/backend/src/flowent/providers/thinking.py +79 -0
- package/backend/src/flowent/registry.py +73 -0
- package/backend/src/flowent/role_management.py +267 -0
- package/backend/src/flowent/routes/__init__.py +28 -0
- 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/access.py +48 -0
- package/backend/src/flowent/routes/assistant.py +155 -0
- package/backend/src/flowent/routes/image_assets.py +33 -0
- package/backend/src/flowent/routes/mcp.py +125 -0
- package/backend/src/flowent/routes/meta.py +28 -0
- package/backend/src/flowent/routes/nodes.py +413 -0
- package/backend/src/flowent/routes/prompts.py +46 -0
- package/backend/src/flowent/routes/providers_route.py +365 -0
- package/backend/src/flowent/routes/roles.py +207 -0
- package/backend/src/flowent/routes/settings.py +328 -0
- package/backend/src/flowent/routes/tabs.py +310 -0
- package/backend/src/flowent/routes/ws.py +33 -0
- package/backend/src/flowent/runtime.py +165 -0
- package/backend/src/flowent/sandbox.py +45 -0
- package/backend/src/flowent/security.py +57 -0
- package/backend/src/flowent/settings.py +2518 -0
- package/backend/src/flowent/settings_management.py +298 -0
- package/backend/src/flowent/state_db.py +120 -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/datetime-eJqd0V2S.js +1 -0
- package/backend/src/flowent/static/assets/dialog-BOvHIBrg.js +1 -0
- package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
- package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
- 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/layout.worker-jMHqAFbP.js +24 -0
- package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +29 -0
- package/backend/src/flowent/static/assets/modelParams-DcEhGnu0.js +1 -0
- package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
- package/backend/src/flowent/static/assets/roles-BbIEIMeG.js +1 -0
- package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.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/triState-DgLlKdRR.js +1 -0
- package/backend/src/flowent/static/assets/ui-vendor-UazN8rcv.js +51 -0
- package/backend/src/flowent/static/index.html +35 -0
- package/backend/src/flowent/tools/__init__.py +275 -0
- 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 +100 -0
- package/backend/src/flowent/tools/contacts.py +22 -0
- package/backend/src/flowent/tools/create_agent.py +191 -0
- package/backend/src/flowent/tools/create_tab.py +61 -0
- package/backend/src/flowent/tools/delete_tab.py +39 -0
- package/backend/src/flowent/tools/edit.py +142 -0
- package/backend/src/flowent/tools/exec.py +118 -0
- package/backend/src/flowent/tools/fetch.py +85 -0
- package/backend/src/flowent/tools/idle.py +27 -0
- package/backend/src/flowent/tools/list_roles.py +75 -0
- package/backend/src/flowent/tools/list_tabs.py +100 -0
- package/backend/src/flowent/tools/list_tools.py +28 -0
- package/backend/src/flowent/tools/manage_prompts.py +102 -0
- package/backend/src/flowent/tools/manage_providers.py +220 -0
- package/backend/src/flowent/tools/manage_roles.py +275 -0
- package/backend/src/flowent/tools/manage_settings.py +364 -0
- package/backend/src/flowent/tools/mcp.py +199 -0
- package/backend/src/flowent/tools/read.py +152 -0
- package/backend/src/flowent/tools/send.py +68 -0
- package/backend/src/flowent/tools/set_permissions.py +99 -0
- package/backend/src/flowent/tools/sleep.py +41 -0
- package/backend/src/flowent/tools/todo.py +51 -0
- package/backend/src/flowent/workspace_store.py +479 -0
- package/backend/tests/__init__.py +0 -0
- 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/conftest.py +6 -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/conftest.py +29 -0
- package/backend/tests/integration/api/test_access_api.py +182 -0
- package/backend/tests/integration/api/test_assistant_api.py +354 -0
- package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
- package/backend/tests/integration/api/test_mcp_api.py +116 -0
- package/backend/tests/integration/api/test_meta_api.py +33 -0
- package/backend/tests/integration/api/test_nodes_api.py +722 -0
- package/backend/tests/integration/api/test_prompts_api.py +47 -0
- package/backend/tests/integration/api/test_roles_api.py +228 -0
- package/backend/tests/integration/api/test_tabs_api.py +802 -0
- 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 +837 -0
- package/backend/tests/unit/agent/test_agent_runtime.py +2942 -0
- package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
- package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/logging/test_logging.py +132 -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 +570 -0
- 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/providers/test_anthropic_provider.py +185 -0
- package/backend/tests/unit/providers/test_errors.py +68 -0
- package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
- package/backend/tests/unit/providers/test_openai_provider.py +139 -0
- package/backend/tests/unit/providers/test_openai_responses.py +402 -0
- package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
- package/backend/tests/unit/providers/test_think_tag_parser.py +36 -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_prompts_routes.py +104 -0
- package/backend/tests/unit/routes/test_providers_route.py +370 -0
- package/backend/tests/unit/routes/test_roles_routes.py +535 -0
- package/backend/tests/unit/routes/test_settings_routes.py +1142 -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 +1002 -0
- package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -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 +124 -0
- 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 +751 -0
- package/backend/tests/unit/test_access.py +45 -0
- package/backend/tests/unit/test_cli.py +124 -0
- package/backend/tests/unit/test_graph_runtime.py +72 -0
- package/backend/tests/unit/test_network.py +51 -0
- package/backend/tests/unit/test_state_sqlite_storage.py +159 -0
- package/backend/tests/unit/test_workspace_store.py +231 -0
- 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 +228 -0
- package/backend/tests/unit/tools/test_create_agent_tool.py +436 -0
- package/backend/tests/unit/tools/test_delete_tab_tool.py +116 -0
- package/backend/tests/unit/tools/test_edit_tool.py +115 -0
- package/backend/tests/unit/tools/test_exec_tool.py +81 -0
- package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
- package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
- package/backend/tests/unit/tools/test_manage_providers_tool.py +460 -0
- package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
- package/backend/tests/unit/tools/test_manage_settings_tool.py +611 -0
- package/backend/tests/unit/tools/test_read_tool.py +33 -0
- package/backend/tests/unit/tools/test_set_permissions_tool.py +595 -0
- package/backend/tests/unit/tools/test_todo_tool.py +37 -0
- package/backend/tests/unit/tools/test_tool_registry.py +194 -0
- package/backend/uv.lock +1144 -0
- package/bin/flowent.mjs +62 -36
- 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/datetime-eJqd0V2S.js +1 -0
- package/dist/frontend/assets/dialog-BOvHIBrg.js +1 -0
- package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
- package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
- package/dist/frontend/assets/index-Biio-CoI.js +10 -0
- package/dist/frontend/assets/index-CmQvO7sl.css +1 -0
- package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
- package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +29 -0
- package/dist/frontend/assets/modelParams-DcEhGnu0.js +1 -0
- package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
- package/dist/frontend/assets/roles-BbIEIMeG.js +1 -0
- package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.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/triState-DgLlKdRR.js +1 -0
- package/dist/frontend/assets/ui-vendor-UazN8rcv.js +51 -0
- package/dist/frontend/index.html +35 -0
- package/package.json +27 -41
- package/dist/.next/BUILD_ID +0 -1
- package/dist/.next/app-path-routes-manifest.json +0 -6
- package/dist/.next/build-manifest.json +0 -20
- package/dist/.next/package.json +0 -1
- package/dist/.next/prerender-manifest.json +0 -114
- package/dist/.next/required-server-files.json +0 -333
- package/dist/.next/routes-manifest.json +0 -69
- package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
- package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
- package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/_global-error/page.js +0 -9
- package/dist/.next/server/app/_global-error/page.js.map +0 -5
- package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
- package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app/_global-error.html +0 -1
- package/dist/.next/server/app/_global-error.meta +0 -15
- package/dist/.next/server/app/_global-error.rsc +0 -14
- package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
- package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
- package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
- package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
- package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/_not-found/page.js +0 -13
- package/dist/.next/server/app/_not-found/page.js.map +0 -5
- package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
- package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app/_not-found.html +0 -1
- package/dist/.next/server/app/_not-found.meta +0 -16
- package/dist/.next/server/app/_not-found.rsc +0 -16
- package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
- package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
- package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
- package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
- package/dist/.next/server/app/icon.svg/route.js +0 -6
- package/dist/.next/server/app/icon.svg/route.js.map +0 -5
- package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
- package/dist/.next/server/app/icon.svg.meta +0 -1
- package/dist/.next/server/app/index.html +0 -1
- package/dist/.next/server/app/index.meta +0 -14
- package/dist/.next/server/app/index.rsc +0 -15
- package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
- package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
- package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
- package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/page/build-manifest.json +0 -16
- package/dist/.next/server/app/page/next-font-manifest.json +0 -10
- package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/page.js +0 -14
- package/dist/.next/server/app/page.js.map +0 -5
- package/dist/.next/server/app/page.js.nft.json +0 -1
- package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app-paths-manifest.json +0 -6
- package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
- package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
- package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
- package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
- package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
- package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
- package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
- package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
- package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
- package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
- package/dist/.next/server/functions-config-manifest.json +0 -4
- package/dist/.next/server/middleware-build-manifest.js +0 -20
- package/dist/.next/server/middleware-manifest.json +0 -6
- package/dist/.next/server/next-font-manifest.js +0 -1
- package/dist/.next/server/next-font-manifest.json +0 -13
- package/dist/.next/server/pages/404.html +0 -1
- package/dist/.next/server/pages/500.html +0 -1
- package/dist/.next/server/pages-manifest.json +0 -4
- package/dist/.next/server/prefetch-hints.json +0 -1
- package/dist/.next/server/server-reference-manifest.js +0 -1
- package/dist/.next/server/server-reference-manifest.json +0 -5
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
- package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
- package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
- package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
- package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
- package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
- package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
- package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
- package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
- package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
- package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
- package/dist/package.json +0 -88
- package/dist/server.js +0 -38
- /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
- /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
|
@@ -0,0 +1,2508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
11
|
+
|
|
12
|
+
from flowent import settings as settings_module
|
|
13
|
+
from flowent.events import event_bus
|
|
14
|
+
from flowent.models import (
|
|
15
|
+
AgentState,
|
|
16
|
+
EdgeKind,
|
|
17
|
+
Event,
|
|
18
|
+
EventType,
|
|
19
|
+
GraphEdge,
|
|
20
|
+
GraphNodeRecord,
|
|
21
|
+
Message,
|
|
22
|
+
NodeConfig,
|
|
23
|
+
NodeType,
|
|
24
|
+
PortDirection,
|
|
25
|
+
PortInboundEntry,
|
|
26
|
+
PortType,
|
|
27
|
+
ReceivedMessage,
|
|
28
|
+
Tab,
|
|
29
|
+
WorkflowActivationState,
|
|
30
|
+
WorkflowDefinition,
|
|
31
|
+
WorkflowNodeDefinition,
|
|
32
|
+
WorkflowNodeKind,
|
|
33
|
+
WorkflowPort,
|
|
34
|
+
content_parts_to_text,
|
|
35
|
+
deserialize_content_parts,
|
|
36
|
+
)
|
|
37
|
+
from flowent.registry import registry
|
|
38
|
+
from flowent.runtime import SYSTEM_NODE_TIMEOUT
|
|
39
|
+
from flowent.settings import (
|
|
40
|
+
CONDUCTOR_ROLE_INCLUDED_TOOLS,
|
|
41
|
+
CONDUCTOR_ROLE_NAME,
|
|
42
|
+
DESIGNER_ROLE_INCLUDED_TOOLS,
|
|
43
|
+
DESIGNER_ROLE_NAME,
|
|
44
|
+
STEWARD_ROLE_INCLUDED_TOOLS,
|
|
45
|
+
STEWARD_ROLE_NAME,
|
|
46
|
+
build_assistant_write_dirs,
|
|
47
|
+
find_provider,
|
|
48
|
+
find_role,
|
|
49
|
+
resolve_model_info,
|
|
50
|
+
resolve_path,
|
|
51
|
+
)
|
|
52
|
+
from flowent.tools import (
|
|
53
|
+
MINIMUM_TOOLS,
|
|
54
|
+
is_assistant_only_mcp_tool_name,
|
|
55
|
+
is_assistant_only_tool_name,
|
|
56
|
+
)
|
|
57
|
+
from flowent.workspace_store import workspace_store
|
|
58
|
+
|
|
59
|
+
LEADER_NODE_NAME = "Leader"
|
|
60
|
+
|
|
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
|
+
|
|
91
|
+
def build_tools_for_role(
|
|
92
|
+
role_name: str,
|
|
93
|
+
*,
|
|
94
|
+
requested_tools: list[str] | None = None,
|
|
95
|
+
settings=None,
|
|
96
|
+
assistant_boundary: bool = False,
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
current_settings = settings or settings_module.get_settings()
|
|
99
|
+
normalized_role_name = role_name.strip()
|
|
100
|
+
role = find_role(current_settings, normalized_role_name)
|
|
101
|
+
if role is None:
|
|
102
|
+
if normalized_role_name == CONDUCTOR_ROLE_NAME:
|
|
103
|
+
included_tools = list(CONDUCTOR_ROLE_INCLUDED_TOOLS)
|
|
104
|
+
elif normalized_role_name == DESIGNER_ROLE_NAME:
|
|
105
|
+
included_tools = list(DESIGNER_ROLE_INCLUDED_TOOLS)
|
|
106
|
+
elif normalized_role_name == STEWARD_ROLE_NAME:
|
|
107
|
+
included_tools = list(STEWARD_ROLE_INCLUDED_TOOLS)
|
|
108
|
+
else:
|
|
109
|
+
included_tools = []
|
|
110
|
+
excluded_tools: set[str] = set()
|
|
111
|
+
else:
|
|
112
|
+
included_tools = list(role.included_tools)
|
|
113
|
+
excluded_tools = set(role.excluded_tools)
|
|
114
|
+
|
|
115
|
+
final_tools: list[str] = []
|
|
116
|
+
seen_tools: set[str] = set()
|
|
117
|
+
for tool_name in [*MINIMUM_TOOLS, *included_tools, *(requested_tools or [])]:
|
|
118
|
+
if tool_name in seen_tools:
|
|
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
|
|
125
|
+
if tool_name in excluded_tools and tool_name not in MINIMUM_TOOLS:
|
|
126
|
+
continue
|
|
127
|
+
final_tools.append(tool_name)
|
|
128
|
+
seen_tools.add(tool_name)
|
|
129
|
+
return final_tools
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_assistant_tools(*, settings=None) -> list[str]:
|
|
133
|
+
current_settings = settings or settings_module.get_settings()
|
|
134
|
+
assistant_tools = build_tools_for_role(
|
|
135
|
+
current_settings.assistant.role_name,
|
|
136
|
+
settings=current_settings,
|
|
137
|
+
assistant_boundary=True,
|
|
138
|
+
)
|
|
139
|
+
final_tools: list[str] = []
|
|
140
|
+
seen_tools: set[str] = set()
|
|
141
|
+
for tool_name in [
|
|
142
|
+
*MINIMUM_TOOLS,
|
|
143
|
+
*STEWARD_ROLE_INCLUDED_TOOLS,
|
|
144
|
+
*assistant_tools,
|
|
145
|
+
]:
|
|
146
|
+
if tool_name in seen_tools:
|
|
147
|
+
continue
|
|
148
|
+
final_tools.append(tool_name)
|
|
149
|
+
seen_tools.add(tool_name)
|
|
150
|
+
return final_tools
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_leader_role_name(*, settings=None) -> str:
|
|
154
|
+
current_settings = settings or settings_module.get_settings()
|
|
155
|
+
configured_role_name = current_settings.leader.role_name.strip()
|
|
156
|
+
if configured_role_name and find_role(current_settings, configured_role_name):
|
|
157
|
+
return configured_role_name
|
|
158
|
+
return CONDUCTOR_ROLE_NAME
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_tab_leader_id(tab_id: str) -> str | None:
|
|
162
|
+
tab = workspace_store.get_tab(tab_id)
|
|
163
|
+
if tab is None:
|
|
164
|
+
return None
|
|
165
|
+
return tab.leader_id
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_tab_leader(*, node_id: str, tab_id: str | None = None) -> bool:
|
|
169
|
+
resolved_tab_id = tab_id
|
|
170
|
+
if resolved_tab_id is None:
|
|
171
|
+
record = workspace_store.get_node_record(node_id)
|
|
172
|
+
if record is not None:
|
|
173
|
+
resolved_tab_id = record.config.tab_id
|
|
174
|
+
else:
|
|
175
|
+
live_node = registry.get(node_id)
|
|
176
|
+
resolved_tab_id = live_node.config.tab_id if live_node is not None else None
|
|
177
|
+
if not resolved_tab_id:
|
|
178
|
+
return False
|
|
179
|
+
return get_tab_leader_id(resolved_tab_id) == node_id
|
|
180
|
+
|
|
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
|
+
|
|
254
|
+
def _default_ports(
|
|
255
|
+
node_kind: WorkflowNodeKind,
|
|
256
|
+
config: dict[str, object] | None = None,
|
|
257
|
+
) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
|
|
258
|
+
node_config = config or {}
|
|
259
|
+
if node_kind == WorkflowNodeKind.TRIGGER:
|
|
260
|
+
output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
|
|
261
|
+
return (
|
|
262
|
+
[],
|
|
263
|
+
[
|
|
264
|
+
WorkflowPort(
|
|
265
|
+
key="out",
|
|
266
|
+
direction=PortDirection.OUTPUT,
|
|
267
|
+
type=output_type,
|
|
268
|
+
multiple=True,
|
|
269
|
+
)
|
|
270
|
+
],
|
|
271
|
+
)
|
|
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)
|
|
275
|
+
return (
|
|
276
|
+
[
|
|
277
|
+
WorkflowPort(
|
|
278
|
+
key="in",
|
|
279
|
+
direction=PortDirection.INPUT,
|
|
280
|
+
type=input_type,
|
|
281
|
+
required=True,
|
|
282
|
+
)
|
|
283
|
+
],
|
|
284
|
+
[
|
|
285
|
+
WorkflowPort(
|
|
286
|
+
key="out",
|
|
287
|
+
direction=PortDirection.OUTPUT,
|
|
288
|
+
type=output_type,
|
|
289
|
+
multiple=True,
|
|
290
|
+
)
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
if node_kind == WorkflowNodeKind.CODE:
|
|
294
|
+
return _build_code_ports(node_config)
|
|
295
|
+
if node_kind == WorkflowNodeKind.IF:
|
|
296
|
+
input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
|
|
297
|
+
return (
|
|
298
|
+
[
|
|
299
|
+
WorkflowPort(
|
|
300
|
+
key="in",
|
|
301
|
+
direction=PortDirection.INPUT,
|
|
302
|
+
type=input_type,
|
|
303
|
+
required=True,
|
|
304
|
+
),
|
|
305
|
+
],
|
|
306
|
+
[
|
|
307
|
+
WorkflowPort(
|
|
308
|
+
key="then",
|
|
309
|
+
direction=PortDirection.OUTPUT,
|
|
310
|
+
type=input_type,
|
|
311
|
+
multiple=True,
|
|
312
|
+
),
|
|
313
|
+
WorkflowPort(
|
|
314
|
+
key="else",
|
|
315
|
+
direction=PortDirection.OUTPUT,
|
|
316
|
+
type=input_type,
|
|
317
|
+
multiple=True,
|
|
318
|
+
),
|
|
319
|
+
],
|
|
320
|
+
)
|
|
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
|
+
)
|
|
329
|
+
return (
|
|
330
|
+
[
|
|
331
|
+
WorkflowPort(
|
|
332
|
+
key="in",
|
|
333
|
+
direction=PortDirection.INPUT,
|
|
334
|
+
type=input_type,
|
|
335
|
+
required=True,
|
|
336
|
+
multiple=True,
|
|
337
|
+
)
|
|
338
|
+
],
|
|
339
|
+
[
|
|
340
|
+
WorkflowPort(
|
|
341
|
+
key="out",
|
|
342
|
+
direction=PortDirection.OUTPUT,
|
|
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,
|
|
363
|
+
multiple=True,
|
|
364
|
+
)
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
return (
|
|
368
|
+
[
|
|
369
|
+
WorkflowPort(
|
|
370
|
+
key="in",
|
|
371
|
+
direction=PortDirection.INPUT,
|
|
372
|
+
type=PortType.PARTS,
|
|
373
|
+
required=True,
|
|
374
|
+
)
|
|
375
|
+
],
|
|
376
|
+
[
|
|
377
|
+
WorkflowPort(
|
|
378
|
+
key="out",
|
|
379
|
+
direction=PortDirection.OUTPUT,
|
|
380
|
+
type=PortType.PARTS,
|
|
381
|
+
multiple=True,
|
|
382
|
+
)
|
|
383
|
+
],
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def build_workflow_node_definition(
|
|
388
|
+
*,
|
|
389
|
+
node_id: str,
|
|
390
|
+
node_kind: WorkflowNodeKind,
|
|
391
|
+
config: dict[str, object] | None = None,
|
|
392
|
+
) -> WorkflowNodeDefinition:
|
|
393
|
+
inputs, outputs = _default_ports(node_kind, config)
|
|
394
|
+
return WorkflowNodeDefinition(
|
|
395
|
+
id=node_id,
|
|
396
|
+
type=node_kind,
|
|
397
|
+
config=deepcopy(config or {}),
|
|
398
|
+
inputs=inputs,
|
|
399
|
+
outputs=outputs,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def list_workflow_nodes(tab_id: str) -> list[WorkflowNodeDefinition]:
|
|
404
|
+
tab = workspace_store.get_tab(tab_id)
|
|
405
|
+
if tab is None:
|
|
406
|
+
return []
|
|
407
|
+
return list(tab.definition.nodes)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_workflow_node(tab_id: str, node_id: str) -> WorkflowNodeDefinition | None:
|
|
411
|
+
tab = workspace_store.get_tab(tab_id)
|
|
412
|
+
if tab is None:
|
|
413
|
+
return None
|
|
414
|
+
return tab.definition.get_node(node_id)
|
|
415
|
+
|
|
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
|
+
|
|
787
|
+
def _sync_runtime_positions_into_definition(tab: Tab) -> bool:
|
|
788
|
+
changed = False
|
|
789
|
+
for record in workspace_store.list_node_records(tab.id):
|
|
790
|
+
if is_tab_leader(node_id=record.id, tab_id=tab.id):
|
|
791
|
+
continue
|
|
792
|
+
if record.position is None:
|
|
793
|
+
continue
|
|
794
|
+
current = tab.definition.view.positions.get(record.id)
|
|
795
|
+
if current == record.position:
|
|
796
|
+
continue
|
|
797
|
+
tab.definition.view.positions[record.id] = record.position
|
|
798
|
+
changed = True
|
|
799
|
+
return changed
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def serialize_tab_summary(tab: Tab) -> dict[str, object]:
|
|
803
|
+
if _sync_runtime_positions_into_definition(tab):
|
|
804
|
+
workspace_store.upsert_tab(tab)
|
|
805
|
+
return {
|
|
806
|
+
"id": tab.id,
|
|
807
|
+
"title": tab.title,
|
|
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),
|
|
812
|
+
"created_at": tab.created_at,
|
|
813
|
+
"updated_at": tab.updated_at,
|
|
814
|
+
"definition": tab.definition.serialize(),
|
|
815
|
+
"node_count": len(tab.definition.nodes),
|
|
816
|
+
"edge_count": len(tab.definition.edges),
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _build_leader_record(
|
|
821
|
+
*,
|
|
822
|
+
tab_id: str,
|
|
823
|
+
leader_id: str,
|
|
824
|
+
settings,
|
|
825
|
+
) -> GraphNodeRecord:
|
|
826
|
+
role_name = resolve_leader_role_name(settings=settings)
|
|
827
|
+
return GraphNodeRecord(
|
|
828
|
+
id=leader_id,
|
|
829
|
+
config=NodeConfig(
|
|
830
|
+
node_type=NodeType.AGENT,
|
|
831
|
+
role_name=role_name,
|
|
832
|
+
tab_id=tab_id,
|
|
833
|
+
name=LEADER_NODE_NAME,
|
|
834
|
+
tools=build_tools_for_role(role_name, settings=settings),
|
|
835
|
+
),
|
|
836
|
+
state=AgentState.INITIALIZING,
|
|
837
|
+
)
|
|
838
|
+
|
|
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
|
+
|
|
857
|
+
def _sync_leader_record(
|
|
858
|
+
*,
|
|
859
|
+
tab_id: str,
|
|
860
|
+
record: GraphNodeRecord,
|
|
861
|
+
settings,
|
|
862
|
+
) -> bool:
|
|
863
|
+
role_name = resolve_leader_role_name(settings=settings)
|
|
864
|
+
tools = build_tools_for_role(role_name, settings=settings)
|
|
865
|
+
changed = False
|
|
866
|
+
if record.config.node_type != NodeType.AGENT:
|
|
867
|
+
record.config.node_type = NodeType.AGENT
|
|
868
|
+
changed = True
|
|
869
|
+
if record.config.tab_id != tab_id:
|
|
870
|
+
record.config.tab_id = tab_id
|
|
871
|
+
changed = True
|
|
872
|
+
if record.config.role_name != role_name:
|
|
873
|
+
record.config.role_name = role_name
|
|
874
|
+
changed = True
|
|
875
|
+
if record.config.name != LEADER_NODE_NAME:
|
|
876
|
+
record.config.name = LEADER_NODE_NAME
|
|
877
|
+
changed = True
|
|
878
|
+
if record.config.tools != tools:
|
|
879
|
+
record.config.tools = tools
|
|
880
|
+
changed = True
|
|
881
|
+
return changed
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _start_persisted_agent(
|
|
885
|
+
*,
|
|
886
|
+
record: GraphNodeRecord,
|
|
887
|
+
) -> tuple[GraphNodeRecord | None, str | None]:
|
|
888
|
+
from flowent.agent import Agent
|
|
889
|
+
|
|
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
|
+
)
|
|
903
|
+
registry.register(node)
|
|
904
|
+
node.start()
|
|
905
|
+
return workspace_store.get_node_record(record.id), None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def ensure_tab_leaders(*, start_nodes: bool = False) -> bool:
|
|
909
|
+
settings = settings_module.get_settings()
|
|
910
|
+
changed = False
|
|
911
|
+
should_start_nodes = start_nodes and bool(registry.get_all())
|
|
912
|
+
|
|
913
|
+
for tab in workspace_store.list_tabs():
|
|
914
|
+
tab_nodes = list_tab_nodes(tab.id)
|
|
915
|
+
leader_record: GraphNodeRecord | None = None
|
|
916
|
+
|
|
917
|
+
if tab.leader_id:
|
|
918
|
+
current_leader = workspace_store.get_node_record(tab.leader_id)
|
|
919
|
+
if (
|
|
920
|
+
current_leader is not None
|
|
921
|
+
and current_leader.config.tab_id == tab.id
|
|
922
|
+
and current_leader.state != AgentState.TERMINATED
|
|
923
|
+
):
|
|
924
|
+
leader_record = current_leader
|
|
925
|
+
elif (
|
|
926
|
+
current_leader is not None
|
|
927
|
+
and current_leader.config.tab_id == tab.id
|
|
928
|
+
and current_leader.state == AgentState.TERMINATED
|
|
929
|
+
):
|
|
930
|
+
workspace_store.delete_node_record(current_leader.id)
|
|
931
|
+
changed = True
|
|
932
|
+
|
|
933
|
+
if leader_record is None:
|
|
934
|
+
conductor_candidates = sorted(
|
|
935
|
+
(
|
|
936
|
+
node
|
|
937
|
+
for node in tab_nodes
|
|
938
|
+
if node.state != AgentState.TERMINATED
|
|
939
|
+
and node.config.role_name == CONDUCTOR_ROLE_NAME
|
|
940
|
+
),
|
|
941
|
+
key=lambda node: (node.created_at, node.id),
|
|
942
|
+
)
|
|
943
|
+
if conductor_candidates:
|
|
944
|
+
leader_record = conductor_candidates[0]
|
|
945
|
+
else:
|
|
946
|
+
leader_record = _build_leader_record(
|
|
947
|
+
tab_id=tab.id,
|
|
948
|
+
leader_id=str(uuid.uuid4()),
|
|
949
|
+
settings=settings,
|
|
950
|
+
)
|
|
951
|
+
workspace_store.upsert_node_record(leader_record)
|
|
952
|
+
changed = True
|
|
953
|
+
|
|
954
|
+
if tab.leader_id != leader_record.id:
|
|
955
|
+
tab.leader_id = leader_record.id
|
|
956
|
+
workspace_store.upsert_tab(tab)
|
|
957
|
+
changed = True
|
|
958
|
+
|
|
959
|
+
if _sync_tab_permissions_from_legacy_leader(tab):
|
|
960
|
+
changed = True
|
|
961
|
+
|
|
962
|
+
if _sync_leader_record(tab_id=tab.id, record=leader_record, settings=settings):
|
|
963
|
+
workspace_store.upsert_node_record(leader_record)
|
|
964
|
+
changed = True
|
|
965
|
+
|
|
966
|
+
if should_start_nodes and registry.get(leader_record.id) is None:
|
|
967
|
+
_start_persisted_agent(record=leader_record)
|
|
968
|
+
|
|
969
|
+
return changed
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def sync_assistant_role(*, reason: str) -> None:
|
|
973
|
+
assistant = registry.get_assistant()
|
|
974
|
+
if assistant is None:
|
|
975
|
+
return
|
|
976
|
+
settings = settings_module.get_settings()
|
|
977
|
+
assistant.config.role_name = settings.assistant.role_name
|
|
978
|
+
assistant.config.tools = build_assistant_tools(settings=settings)
|
|
979
|
+
assistant.config.write_dirs = list(settings.assistant.write_dirs)
|
|
980
|
+
assistant.config.allow_network = settings.assistant.allow_network
|
|
981
|
+
assistant._sync_system_prompt_entry()
|
|
982
|
+
assistant.set_state(
|
|
983
|
+
assistant.state,
|
|
984
|
+
reason,
|
|
985
|
+
force_emit=True,
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def sync_tab_leaders(*, reason: str) -> None:
|
|
990
|
+
ensure_tab_leaders()
|
|
991
|
+
settings = settings_module.get_settings()
|
|
992
|
+
for tab in workspace_store.list_tabs():
|
|
993
|
+
if not tab.leader_id:
|
|
994
|
+
continue
|
|
995
|
+
record = workspace_store.get_node_record(tab.leader_id)
|
|
996
|
+
if record is None:
|
|
997
|
+
continue
|
|
998
|
+
if _sync_leader_record(tab_id=tab.id, record=record, settings=settings):
|
|
999
|
+
workspace_store.upsert_node_record(record)
|
|
1000
|
+
live_node = registry.get(record.id)
|
|
1001
|
+
if live_node is None:
|
|
1002
|
+
continue
|
|
1003
|
+
live_node.config.role_name = record.config.role_name
|
|
1004
|
+
live_node.config.name = record.config.name
|
|
1005
|
+
live_node.config.tools = list(record.config.tools)
|
|
1006
|
+
live_node._sync_system_prompt_entry()
|
|
1007
|
+
live_node.set_state(
|
|
1008
|
+
live_node.state,
|
|
1009
|
+
reason,
|
|
1010
|
+
force_emit=True,
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _emit_tab_updated(*, tab_id: str, agent_id: str) -> None:
|
|
1015
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1016
|
+
if tab is None:
|
|
1017
|
+
return
|
|
1018
|
+
event_bus.emit(
|
|
1019
|
+
Event(
|
|
1020
|
+
type=EventType.TAB_UPDATED,
|
|
1021
|
+
agent_id=agent_id,
|
|
1022
|
+
data=serialize_tab_summary(tab),
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _start_tab_runtime(tab_id: str) -> None:
|
|
1028
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1029
|
+
if tab is None:
|
|
1030
|
+
return
|
|
1031
|
+
ordered_records = sorted(
|
|
1032
|
+
list_tab_nodes(tab_id),
|
|
1033
|
+
key=lambda record: (
|
|
1034
|
+
record.id != tab.leader_id,
|
|
1035
|
+
record.created_at,
|
|
1036
|
+
record.id,
|
|
1037
|
+
),
|
|
1038
|
+
)
|
|
1039
|
+
for record in ordered_records:
|
|
1040
|
+
if registry.get(record.id) is not None:
|
|
1041
|
+
continue
|
|
1042
|
+
_start_persisted_agent(record=record)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def create_tab(
|
|
1046
|
+
*,
|
|
1047
|
+
title: str,
|
|
1048
|
+
allow_network: bool = False,
|
|
1049
|
+
write_dirs: list[str] | None = None,
|
|
1050
|
+
) -> Tab:
|
|
1051
|
+
settings = settings_module.get_settings()
|
|
1052
|
+
leader_id = str(uuid.uuid4())
|
|
1053
|
+
tab = Tab(
|
|
1054
|
+
id=str(uuid.uuid4()),
|
|
1055
|
+
title=title.strip(),
|
|
1056
|
+
leader_id=leader_id,
|
|
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,
|
|
1064
|
+
)
|
|
1065
|
+
workspace_store.upsert_tab(tab)
|
|
1066
|
+
leader_record = _build_leader_record(
|
|
1067
|
+
tab_id=tab.id,
|
|
1068
|
+
leader_id=leader_id,
|
|
1069
|
+
settings=settings,
|
|
1070
|
+
)
|
|
1071
|
+
workspace_store.upsert_node_record(leader_record)
|
|
1072
|
+
if registry.get_all():
|
|
1073
|
+
_start_tab_runtime(tab.id)
|
|
1074
|
+
event_bus.emit(
|
|
1075
|
+
Event(
|
|
1076
|
+
type=EventType.TAB_CREATED,
|
|
1077
|
+
agent_id="assistant",
|
|
1078
|
+
data=serialize_tab_summary(tab),
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
return tab
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def duplicate_tab(
|
|
1085
|
+
*,
|
|
1086
|
+
tab_id: str,
|
|
1087
|
+
) -> tuple[Tab | None, str | None]:
|
|
1088
|
+
source_tab = workspace_store.get_tab(tab_id)
|
|
1089
|
+
if source_tab is None:
|
|
1090
|
+
return None, f"Tab '{tab_id}' not found"
|
|
1091
|
+
|
|
1092
|
+
_sync_tab_permissions_from_legacy_leader(source_tab)
|
|
1093
|
+
duplicated_definition = WorkflowDefinition.from_mapping(
|
|
1094
|
+
source_tab.definition.serialize()
|
|
1095
|
+
)
|
|
1096
|
+
id_map: dict[str, str] = {}
|
|
1097
|
+
duplicated_nodes: list[WorkflowNodeDefinition] = []
|
|
1098
|
+
|
|
1099
|
+
for node in duplicated_definition.nodes:
|
|
1100
|
+
new_node_id = str(uuid.uuid4())
|
|
1101
|
+
id_map[node.id] = new_node_id
|
|
1102
|
+
duplicated_node = build_workflow_node_definition(
|
|
1103
|
+
node_id=new_node_id,
|
|
1104
|
+
node_kind=node.type,
|
|
1105
|
+
config=node.config,
|
|
1106
|
+
)
|
|
1107
|
+
duplicated_nodes.append(duplicated_node)
|
|
1108
|
+
|
|
1109
|
+
duplicated_edges = [
|
|
1110
|
+
GraphEdge(
|
|
1111
|
+
id=str(uuid.uuid4()),
|
|
1112
|
+
from_node_id=id_map.get(edge.from_node_id, edge.from_node_id),
|
|
1113
|
+
from_port_key=edge.from_port_key,
|
|
1114
|
+
to_node_id=id_map.get(edge.to_node_id, edge.to_node_id),
|
|
1115
|
+
to_port_key=edge.to_port_key,
|
|
1116
|
+
kind=edge.kind,
|
|
1117
|
+
)
|
|
1118
|
+
for edge in duplicated_definition.edges
|
|
1119
|
+
]
|
|
1120
|
+
duplicated_view_positions = {
|
|
1121
|
+
id_map.get(node_id, node_id): position
|
|
1122
|
+
for node_id, position in duplicated_definition.view.positions.items()
|
|
1123
|
+
if id_map.get(node_id, node_id) in id_map.values()
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
settings = settings_module.get_settings()
|
|
1127
|
+
new_tab = Tab(
|
|
1128
|
+
id=str(uuid.uuid4()),
|
|
1129
|
+
title=f"{source_tab.title} Copy",
|
|
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,
|
|
1134
|
+
definition=WorkflowDefinition(
|
|
1135
|
+
version=duplicated_definition.version,
|
|
1136
|
+
nodes=duplicated_nodes,
|
|
1137
|
+
edges=duplicated_edges,
|
|
1138
|
+
view=duplicated_definition.view.__class__(
|
|
1139
|
+
positions=duplicated_view_positions
|
|
1140
|
+
),
|
|
1141
|
+
),
|
|
1142
|
+
)
|
|
1143
|
+
assert new_tab.leader_id is not None
|
|
1144
|
+
workspace_store.upsert_tab(new_tab)
|
|
1145
|
+
workspace_store.upsert_node_record(
|
|
1146
|
+
_build_leader_record(
|
|
1147
|
+
tab_id=new_tab.id,
|
|
1148
|
+
leader_id=new_tab.leader_id,
|
|
1149
|
+
settings=settings,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
for node in source_tab.definition.nodes:
|
|
1154
|
+
if node.type != WorkflowNodeKind.AGENT:
|
|
1155
|
+
continue
|
|
1156
|
+
duplicated_node_id = id_map.get(node.id)
|
|
1157
|
+
if duplicated_node_id is None:
|
|
1158
|
+
continue
|
|
1159
|
+
config, error = build_node_config(
|
|
1160
|
+
role_name=str(node.config.get("role_name", "")),
|
|
1161
|
+
tab_id=new_tab.id,
|
|
1162
|
+
name=str(node.config["name"])
|
|
1163
|
+
if isinstance(node.config.get("name"), str)
|
|
1164
|
+
else None,
|
|
1165
|
+
)
|
|
1166
|
+
if error is not None or config is None:
|
|
1167
|
+
return None, error or "Failed to duplicate workflow"
|
|
1168
|
+
workspace_store.upsert_node_record(
|
|
1169
|
+
GraphNodeRecord(
|
|
1170
|
+
id=duplicated_node_id,
|
|
1171
|
+
config=config,
|
|
1172
|
+
state=AgentState.INITIALIZING,
|
|
1173
|
+
position=duplicated_view_positions.get(duplicated_node_id),
|
|
1174
|
+
)
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
if registry.get_all():
|
|
1178
|
+
_start_tab_runtime(new_tab.id)
|
|
1179
|
+
event_bus.emit(
|
|
1180
|
+
Event(
|
|
1181
|
+
type=EventType.TAB_CREATED,
|
|
1182
|
+
agent_id="assistant",
|
|
1183
|
+
data=serialize_tab_summary(new_tab),
|
|
1184
|
+
)
|
|
1185
|
+
)
|
|
1186
|
+
return new_tab, None
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _is_path_within_boundary(path: str, boundary_dirs: list[str]) -> bool:
|
|
1190
|
+
resolved_path = resolve_path(path)
|
|
1191
|
+
return any(
|
|
1192
|
+
resolved_path.is_relative_to(resolve_path(boundary_dir))
|
|
1193
|
+
for boundary_dir in boundary_dirs
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
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)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def set_tab_permissions(
|
|
1224
|
+
*,
|
|
1225
|
+
tab_id: str,
|
|
1226
|
+
allow_network: bool | None = None,
|
|
1227
|
+
write_dirs: list[str] | None = None,
|
|
1228
|
+
caller_allow_network: bool,
|
|
1229
|
+
caller_write_dirs: list[str],
|
|
1230
|
+
actor_id: str,
|
|
1231
|
+
) -> tuple[dict[str, object] | None, str | None]:
|
|
1232
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1233
|
+
if tab is None:
|
|
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")
|
|
1238
|
+
|
|
1239
|
+
leader_id = get_tab_leader_id(tab_id)
|
|
1240
|
+
if not leader_id:
|
|
1241
|
+
return None, f"Tab '{tab_id}' does not have a bound Leader"
|
|
1242
|
+
|
|
1243
|
+
leader_record = workspace_store.get_node_record(leader_id)
|
|
1244
|
+
if leader_record is None:
|
|
1245
|
+
return None, f"Leader '{leader_id}' not found"
|
|
1246
|
+
|
|
1247
|
+
if allow_network is not None and allow_network and not caller_allow_network:
|
|
1248
|
+
return (
|
|
1249
|
+
None,
|
|
1250
|
+
"allow_network boundary exceeded: caller disallows network access",
|
|
1251
|
+
)
|
|
1252
|
+
if write_dirs is not None:
|
|
1253
|
+
invalid_write_dirs = sorted(
|
|
1254
|
+
path
|
|
1255
|
+
for path in write_dirs
|
|
1256
|
+
if not _is_path_within_boundary(path, caller_write_dirs)
|
|
1257
|
+
)
|
|
1258
|
+
if invalid_write_dirs:
|
|
1259
|
+
return (
|
|
1260
|
+
None,
|
|
1261
|
+
"write_dirs boundary exceeded: " + ", ".join(invalid_write_dirs),
|
|
1262
|
+
)
|
|
1263
|
+
|
|
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)
|
|
1266
|
+
|
|
1267
|
+
changed_node_ids: list[str] = []
|
|
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)
|
|
1280
|
+
if live_node is not None:
|
|
1281
|
+
live_node.set_state(
|
|
1282
|
+
live_node.state,
|
|
1283
|
+
"tab_permissions_updated",
|
|
1284
|
+
force_emit=True,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
updated_tab = workspace_store.get_tab(tab_id)
|
|
1288
|
+
if updated_tab is not None:
|
|
1289
|
+
event_bus.emit(
|
|
1290
|
+
Event(
|
|
1291
|
+
type=EventType.TAB_UPDATED,
|
|
1292
|
+
agent_id=actor_id,
|
|
1293
|
+
data=serialize_tab_summary(updated_tab),
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
return (
|
|
1298
|
+
{
|
|
1299
|
+
"tab_id": tab_id,
|
|
1300
|
+
"leader_id": leader_id,
|
|
1301
|
+
"allow_network": next_allow_network,
|
|
1302
|
+
"write_dirs": list(next_write_dirs),
|
|
1303
|
+
"updated_node_ids": changed_node_ids,
|
|
1304
|
+
},
|
|
1305
|
+
None,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def delete_tab(
|
|
1310
|
+
*,
|
|
1311
|
+
tab_id: str,
|
|
1312
|
+
timeout: float = SYSTEM_NODE_TIMEOUT,
|
|
1313
|
+
) -> tuple[dict[str, object] | None, str | None]:
|
|
1314
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1315
|
+
if tab is None:
|
|
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"
|
|
1328
|
+
|
|
1329
|
+
stored_nodes = list_tab_nodes(tab_id)
|
|
1330
|
+
live_nodes = [node for node in registry.get_all() if node.config.tab_id == tab_id]
|
|
1331
|
+
|
|
1332
|
+
removed_node_ids = list(
|
|
1333
|
+
dict.fromkeys(
|
|
1334
|
+
[
|
|
1335
|
+
*(node.id for node in stored_nodes),
|
|
1336
|
+
*(node.uuid for node in live_nodes),
|
|
1337
|
+
*(node.id for node in tab.definition.nodes),
|
|
1338
|
+
]
|
|
1339
|
+
)
|
|
1340
|
+
)
|
|
1341
|
+
removed_edge_ids = [edge.id for edge in tab.definition.edges]
|
|
1342
|
+
|
|
1343
|
+
for node in live_nodes:
|
|
1344
|
+
node.request_termination("tab_deleted")
|
|
1345
|
+
|
|
1346
|
+
lingering_node_ids: list[str] = []
|
|
1347
|
+
for node in live_nodes:
|
|
1348
|
+
if not node.wait_for_termination(timeout=timeout):
|
|
1349
|
+
lingering_node_ids.append(node.uuid)
|
|
1350
|
+
|
|
1351
|
+
if lingering_node_ids:
|
|
1352
|
+
return (
|
|
1353
|
+
None,
|
|
1354
|
+
"Failed to delete workflow because some nodes did not terminate: "
|
|
1355
|
+
+ ", ".join(node_id[:8] for node_id in lingering_node_ids),
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
workspace_store.delete_tab(tab_id)
|
|
1359
|
+
payload = {
|
|
1360
|
+
**tab.serialize(),
|
|
1361
|
+
"removed_node_ids": removed_node_ids,
|
|
1362
|
+
"removed_edge_ids": removed_edge_ids,
|
|
1363
|
+
}
|
|
1364
|
+
event_bus.emit(
|
|
1365
|
+
Event(
|
|
1366
|
+
type=EventType.TAB_DELETED,
|
|
1367
|
+
agent_id="assistant",
|
|
1368
|
+
data=payload,
|
|
1369
|
+
)
|
|
1370
|
+
)
|
|
1371
|
+
return payload, None
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def build_node_config(
|
|
1375
|
+
*,
|
|
1376
|
+
role_name: str,
|
|
1377
|
+
tab_id: str,
|
|
1378
|
+
name: str | None = None,
|
|
1379
|
+
tools: list[str] | None = None,
|
|
1380
|
+
) -> tuple[NodeConfig | None, str | None]:
|
|
1381
|
+
settings = settings_module.get_settings()
|
|
1382
|
+
role = find_role(settings, role_name.strip())
|
|
1383
|
+
if role is None:
|
|
1384
|
+
return None, f"Role '{role_name.strip()}' not found"
|
|
1385
|
+
|
|
1386
|
+
requested_tools = tools or []
|
|
1387
|
+
if not all(isinstance(item, str) for item in requested_tools):
|
|
1388
|
+
return None, "tools must be an array of strings"
|
|
1389
|
+
|
|
1390
|
+
return (
|
|
1391
|
+
NodeConfig(
|
|
1392
|
+
node_type=NodeType.AGENT,
|
|
1393
|
+
role_name=role.name,
|
|
1394
|
+
tab_id=tab_id,
|
|
1395
|
+
name=name.strip() if isinstance(name, str) and name.strip() else None,
|
|
1396
|
+
tools=build_tools_for_role(
|
|
1397
|
+
role.name,
|
|
1398
|
+
requested_tools=requested_tools,
|
|
1399
|
+
settings=settings,
|
|
1400
|
+
),
|
|
1401
|
+
),
|
|
1402
|
+
None,
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _persist_tab(tab: Tab, *, actor_id: str) -> Tab:
|
|
1407
|
+
workspace_store.upsert_tab(tab)
|
|
1408
|
+
_emit_tab_updated(tab_id=tab.id, agent_id=actor_id)
|
|
1409
|
+
return tab
|
|
1410
|
+
|
|
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
|
+
|
|
1420
|
+
def create_graph_node(
|
|
1421
|
+
*,
|
|
1422
|
+
tab_id: str,
|
|
1423
|
+
node_type: WorkflowNodeKind,
|
|
1424
|
+
config: dict[str, object] | None = None,
|
|
1425
|
+
actor_id: str,
|
|
1426
|
+
) -> tuple[WorkflowNodeDefinition | None, str | None]:
|
|
1427
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1428
|
+
if tab is None:
|
|
1429
|
+
return None, f"Tab '{tab_id}' not found"
|
|
1430
|
+
if _is_active(tab):
|
|
1431
|
+
return None, _active_edit_error("nodes")
|
|
1432
|
+
node_id = str(uuid.uuid4())
|
|
1433
|
+
node = build_workflow_node_definition(
|
|
1434
|
+
node_id=node_id,
|
|
1435
|
+
node_kind=node_type,
|
|
1436
|
+
config=config,
|
|
1437
|
+
)
|
|
1438
|
+
tab.definition.nodes.append(node)
|
|
1439
|
+
_persist_tab(tab, actor_id=actor_id)
|
|
1440
|
+
return node, None
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def create_agent_node(
|
|
1444
|
+
*,
|
|
1445
|
+
role_name: str,
|
|
1446
|
+
tab_id: str,
|
|
1447
|
+
name: str | None = None,
|
|
1448
|
+
tools: list[str] | None = None,
|
|
1449
|
+
creator_node_id: str | None = None,
|
|
1450
|
+
connect_to_creator: bool | None = None,
|
|
1451
|
+
) -> tuple[GraphNodeRecord | None, str | None]:
|
|
1452
|
+
del creator_node_id, connect_to_creator
|
|
1453
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1454
|
+
if tab is None:
|
|
1455
|
+
return None, f"Tab '{tab_id}' not found"
|
|
1456
|
+
if _is_active(tab):
|
|
1457
|
+
return None, _active_edit_error("nodes")
|
|
1458
|
+
|
|
1459
|
+
config, error = build_node_config(
|
|
1460
|
+
role_name=role_name,
|
|
1461
|
+
tab_id=tab_id,
|
|
1462
|
+
name=name,
|
|
1463
|
+
tools=tools,
|
|
1464
|
+
)
|
|
1465
|
+
if error is not None or config is None:
|
|
1466
|
+
return None, error
|
|
1467
|
+
if config.role_name == CONDUCTOR_ROLE_NAME:
|
|
1468
|
+
return None, f"Role '{CONDUCTOR_ROLE_NAME}' is reserved for a workflow Leader"
|
|
1469
|
+
|
|
1470
|
+
node_id = str(uuid.uuid4())
|
|
1471
|
+
record = GraphNodeRecord(
|
|
1472
|
+
id=node_id,
|
|
1473
|
+
config=config,
|
|
1474
|
+
state=AgentState.INITIALIZING,
|
|
1475
|
+
)
|
|
1476
|
+
workspace_store.upsert_node_record(record)
|
|
1477
|
+
tab.definition.nodes.append(
|
|
1478
|
+
build_workflow_node_definition(
|
|
1479
|
+
node_id=node_id,
|
|
1480
|
+
node_kind=WorkflowNodeKind.AGENT,
|
|
1481
|
+
config={
|
|
1482
|
+
"role_name": config.role_name or "",
|
|
1483
|
+
**({"name": config.name} if config.name else {}),
|
|
1484
|
+
},
|
|
1485
|
+
)
|
|
1486
|
+
)
|
|
1487
|
+
workspace_store.upsert_tab(tab)
|
|
1488
|
+
started_record, start_error = _start_persisted_agent(record=record)
|
|
1489
|
+
if start_error is not None or started_record is None:
|
|
1490
|
+
return None, start_error or "Failed to create agent"
|
|
1491
|
+
_emit_tab_updated(tab_id=tab_id, agent_id=node_id)
|
|
1492
|
+
return started_record, None
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def update_tab_definition(
|
|
1496
|
+
*,
|
|
1497
|
+
tab_id: str,
|
|
1498
|
+
definition_payload: dict[str, object],
|
|
1499
|
+
actor_id: str,
|
|
1500
|
+
) -> tuple[Tab | None, str | None]:
|
|
1501
|
+
tab = workspace_store.get_tab(tab_id)
|
|
1502
|
+
if tab is None:
|
|
1503
|
+
return None, f"Tab '{tab_id}' not found"
|
|
1504
|
+
next_definition = WorkflowDefinition.from_mapping(definition_payload)
|
|
1505
|
+
node_ids = [node.id for node in next_definition.nodes]
|
|
1506
|
+
if len(node_ids) != len(set(node_ids)):
|
|
1507
|
+
return None, "Workflow definition contains duplicate node ids"
|
|
1508
|
+
edge_ids = [edge.id for edge in next_definition.edges]
|
|
1509
|
+
if len(edge_ids) != len(set(edge_ids)):
|
|
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")
|
|
1515
|
+
|
|
1516
|
+
current_agent_ids = {
|
|
1517
|
+
node.id for node in tab.definition.nodes if node.type == WorkflowNodeKind.AGENT
|
|
1518
|
+
}
|
|
1519
|
+
next_agent_ids = {
|
|
1520
|
+
node.id for node in next_definition.nodes if node.type == WorkflowNodeKind.AGENT
|
|
1521
|
+
}
|
|
1522
|
+
if current_agent_ids != next_agent_ids:
|
|
1523
|
+
return None, "Agent nodes must be created or deleted through workflow node APIs"
|
|
1524
|
+
|
|
1525
|
+
current_records = {
|
|
1526
|
+
record.id: record
|
|
1527
|
+
for record in list_tab_nodes(tab_id)
|
|
1528
|
+
if not is_tab_leader(node_id=record.id, tab_id=tab_id)
|
|
1529
|
+
}
|
|
1530
|
+
for node in next_definition.nodes:
|
|
1531
|
+
if node.type != WorkflowNodeKind.AGENT:
|
|
1532
|
+
continue
|
|
1533
|
+
role_name = node.config.get("role_name")
|
|
1534
|
+
if not isinstance(role_name, str) or not role_name.strip():
|
|
1535
|
+
return None, f"Agent node '{node.id}' requires role_name"
|
|
1536
|
+
record = current_records.get(node.id)
|
|
1537
|
+
if record is None:
|
|
1538
|
+
return None, f"Runtime agent '{node.id}' was not found"
|
|
1539
|
+
config, error = build_node_config(
|
|
1540
|
+
role_name=role_name,
|
|
1541
|
+
tab_id=tab_id,
|
|
1542
|
+
name=str(node.config["name"])
|
|
1543
|
+
if isinstance(node.config.get("name"), str)
|
|
1544
|
+
else None,
|
|
1545
|
+
)
|
|
1546
|
+
if error is not None or config is None:
|
|
1547
|
+
return None, error or f"Failed to validate agent node '{node.id}'"
|
|
1548
|
+
record.config.role_name = config.role_name
|
|
1549
|
+
record.config.name = config.name
|
|
1550
|
+
record.config.tools = config.tools
|
|
1551
|
+
workspace_store.upsert_node_record(record)
|
|
1552
|
+
live_node = registry.get(node.id)
|
|
1553
|
+
if live_node is not None:
|
|
1554
|
+
live_node.config.role_name = record.config.role_name
|
|
1555
|
+
live_node.config.name = record.config.name
|
|
1556
|
+
live_node.config.tools = list(record.config.tools)
|
|
1557
|
+
live_node._sync_system_prompt_entry()
|
|
1558
|
+
live_node.set_state(
|
|
1559
|
+
live_node.state,
|
|
1560
|
+
"workflow_definition_updated",
|
|
1561
|
+
force_emit=True,
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
seen_target_ports: set[tuple[str, str]] = set()
|
|
1565
|
+
for edge in next_definition.edges:
|
|
1566
|
+
source_node = next_definition.get_node(edge.from_node_id)
|
|
1567
|
+
target_node = next_definition.get_node(edge.to_node_id)
|
|
1568
|
+
if source_node is None:
|
|
1569
|
+
return None, f"Edge source node '{edge.from_node_id}' does not exist"
|
|
1570
|
+
if target_node is None:
|
|
1571
|
+
return None, f"Edge target node '{edge.to_node_id}' does not exist"
|
|
1572
|
+
source_port = _port_matches(
|
|
1573
|
+
source_node.outputs,
|
|
1574
|
+
port_key=edge.from_port_key,
|
|
1575
|
+
direction=PortDirection.OUTPUT,
|
|
1576
|
+
)
|
|
1577
|
+
if source_port is None:
|
|
1578
|
+
return None, f"Output port '{edge.from_port_key}' is invalid"
|
|
1579
|
+
target_port = _port_matches(
|
|
1580
|
+
target_node.inputs,
|
|
1581
|
+
port_key=edge.to_port_key,
|
|
1582
|
+
direction=PortDirection.INPUT,
|
|
1583
|
+
)
|
|
1584
|
+
if target_port is None:
|
|
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
|
+
)
|
|
1592
|
+
target_key = (edge.to_node_id, edge.to_port_key)
|
|
1593
|
+
if target_key in seen_target_ports and not target_port.multiple:
|
|
1594
|
+
return None, f"Input port '{edge.to_port_key}' already has an incoming edge"
|
|
1595
|
+
seen_target_ports.add(target_key)
|
|
1596
|
+
|
|
1597
|
+
tab.definition = next_definition
|
|
1598
|
+
_persist_tab(tab, actor_id=actor_id)
|
|
1599
|
+
return tab, None
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _port_matches(
|
|
1603
|
+
ports: list[WorkflowPort],
|
|
1604
|
+
*,
|
|
1605
|
+
port_key: str,
|
|
1606
|
+
direction: PortDirection,
|
|
1607
|
+
) -> WorkflowPort | None:
|
|
1608
|
+
return next(
|
|
1609
|
+
(
|
|
1610
|
+
port
|
|
1611
|
+
for port in ports
|
|
1612
|
+
if port.key == port_key and port.direction == direction
|
|
1613
|
+
),
|
|
1614
|
+
None,
|
|
1615
|
+
)
|
|
1616
|
+
|
|
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
|
+
|
|
2241
|
+
def create_edge(
|
|
2242
|
+
*,
|
|
2243
|
+
tab_id: str | None = None,
|
|
2244
|
+
from_node_id: str,
|
|
2245
|
+
to_node_id: str,
|
|
2246
|
+
from_port_key: str = "out",
|
|
2247
|
+
to_port_key: str = "in",
|
|
2248
|
+
kind: EdgeKind | str = EdgeKind.CONTROL,
|
|
2249
|
+
) -> tuple[GraphEdge | None, str | None]:
|
|
2250
|
+
del kind
|
|
2251
|
+
resolved_tab_id = tab_id
|
|
2252
|
+
if resolved_tab_id is None:
|
|
2253
|
+
source_record = workspace_store.get_node_record(from_node_id)
|
|
2254
|
+
target_record = workspace_store.get_node_record(to_node_id)
|
|
2255
|
+
if source_record is not None and source_record.config.tab_id:
|
|
2256
|
+
resolved_tab_id = source_record.config.tab_id
|
|
2257
|
+
elif target_record is not None and target_record.config.tab_id:
|
|
2258
|
+
resolved_tab_id = target_record.config.tab_id
|
|
2259
|
+
if resolved_tab_id is None:
|
|
2260
|
+
return None, "tab_id is required"
|
|
2261
|
+
tab = workspace_store.get_tab(resolved_tab_id)
|
|
2262
|
+
if tab is None:
|
|
2263
|
+
return None, f"Tab '{resolved_tab_id}' not found"
|
|
2264
|
+
if _is_active(tab):
|
|
2265
|
+
return None, _active_edit_error("edges")
|
|
2266
|
+
if is_tab_leader(node_id=from_node_id, tab_id=resolved_tab_id) or is_tab_leader(
|
|
2267
|
+
node_id=to_node_id,
|
|
2268
|
+
tab_id=resolved_tab_id,
|
|
2269
|
+
):
|
|
2270
|
+
return None, "Workflow Leader does not participate in Workflow Graph edges"
|
|
2271
|
+
if from_node_id == to_node_id:
|
|
2272
|
+
return None, "Self-loop edges are not allowed"
|
|
2273
|
+
source_node = tab.definition.get_node(from_node_id)
|
|
2274
|
+
target_node = tab.definition.get_node(to_node_id)
|
|
2275
|
+
if source_node is None:
|
|
2276
|
+
return None, f"Node '{from_node_id}' not found"
|
|
2277
|
+
if target_node is None:
|
|
2278
|
+
return None, f"Node '{to_node_id}' not found"
|
|
2279
|
+
source_port = _port_matches(
|
|
2280
|
+
source_node.outputs,
|
|
2281
|
+
port_key=from_port_key,
|
|
2282
|
+
direction=PortDirection.OUTPUT,
|
|
2283
|
+
)
|
|
2284
|
+
if source_port is None:
|
|
2285
|
+
return None, f"Output port '{from_port_key}' is invalid"
|
|
2286
|
+
target_port = _port_matches(
|
|
2287
|
+
target_node.inputs,
|
|
2288
|
+
port_key=to_port_key,
|
|
2289
|
+
direction=PortDirection.INPUT,
|
|
2290
|
+
)
|
|
2291
|
+
if target_port is None:
|
|
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
|
+
)
|
|
2299
|
+
if any(
|
|
2300
|
+
edge.from_node_id == from_node_id
|
|
2301
|
+
and edge.from_port_key == from_port_key
|
|
2302
|
+
and edge.to_node_id == to_node_id
|
|
2303
|
+
and edge.to_port_key == to_port_key
|
|
2304
|
+
for edge in tab.definition.edges
|
|
2305
|
+
):
|
|
2306
|
+
return None, "Duplicate edges are not allowed"
|
|
2307
|
+
if not target_port.multiple and any(
|
|
2308
|
+
edge.to_node_id == to_node_id and edge.to_port_key == to_port_key
|
|
2309
|
+
for edge in tab.definition.edges
|
|
2310
|
+
):
|
|
2311
|
+
return None, f"Input port '{to_port_key}' already has an incoming edge"
|
|
2312
|
+
|
|
2313
|
+
edge = GraphEdge(
|
|
2314
|
+
id=str(uuid.uuid4()),
|
|
2315
|
+
tab_id=resolved_tab_id,
|
|
2316
|
+
from_node_id=from_node_id,
|
|
2317
|
+
from_port_key=from_port_key,
|
|
2318
|
+
to_node_id=to_node_id,
|
|
2319
|
+
to_port_key=to_port_key,
|
|
2320
|
+
)
|
|
2321
|
+
tab.definition.edges.append(edge)
|
|
2322
|
+
_persist_tab(tab, actor_id=from_node_id)
|
|
2323
|
+
return edge, None
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def delete_edge(
|
|
2327
|
+
*,
|
|
2328
|
+
tab_id: str,
|
|
2329
|
+
edge_id: str | None = None,
|
|
2330
|
+
from_node_id: str | None = None,
|
|
2331
|
+
to_node_id: str | None = None,
|
|
2332
|
+
from_port_key: str | None = None,
|
|
2333
|
+
to_port_key: str | None = None,
|
|
2334
|
+
) -> tuple[dict[str, object] | None, str | None]:
|
|
2335
|
+
tab = workspace_store.get_tab(tab_id)
|
|
2336
|
+
if tab is None:
|
|
2337
|
+
return None, f"Tab '{tab_id}' not found"
|
|
2338
|
+
if _is_active(tab):
|
|
2339
|
+
return None, _active_edit_error("edges")
|
|
2340
|
+
|
|
2341
|
+
matched_edge: GraphEdge | None = None
|
|
2342
|
+
for edge in tab.definition.edges:
|
|
2343
|
+
if edge_id is not None and edge.id == edge_id:
|
|
2344
|
+
matched_edge = edge
|
|
2345
|
+
break
|
|
2346
|
+
if (
|
|
2347
|
+
from_node_id is not None
|
|
2348
|
+
and to_node_id is not None
|
|
2349
|
+
and edge.from_node_id == from_node_id
|
|
2350
|
+
and edge.to_node_id == to_node_id
|
|
2351
|
+
and (from_port_key is None or edge.from_port_key == from_port_key)
|
|
2352
|
+
and (to_port_key is None or edge.to_port_key == to_port_key)
|
|
2353
|
+
):
|
|
2354
|
+
matched_edge = edge
|
|
2355
|
+
break
|
|
2356
|
+
if matched_edge is None:
|
|
2357
|
+
return None, "Edge not found"
|
|
2358
|
+
|
|
2359
|
+
tab.definition.edges = [
|
|
2360
|
+
edge for edge in tab.definition.edges if edge.id != matched_edge.id
|
|
2361
|
+
]
|
|
2362
|
+
_persist_tab(tab, actor_id=matched_edge.from_node_id)
|
|
2363
|
+
return matched_edge.serialize(), None
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
def delete_agent_node(
|
|
2367
|
+
*,
|
|
2368
|
+
tab_id: str,
|
|
2369
|
+
node_id: str,
|
|
2370
|
+
timeout: float = SYSTEM_NODE_TIMEOUT,
|
|
2371
|
+
) -> tuple[dict[str, object] | None, str | None]:
|
|
2372
|
+
tab = workspace_store.get_tab(tab_id)
|
|
2373
|
+
if tab is None:
|
|
2374
|
+
return None, f"Tab '{tab_id}' not found"
|
|
2375
|
+
if _is_active(tab):
|
|
2376
|
+
return None, _active_edit_error("nodes")
|
|
2377
|
+
|
|
2378
|
+
node_definition = tab.definition.get_node(node_id)
|
|
2379
|
+
if node_definition is None:
|
|
2380
|
+
return None, f"Node '{node_id}' not found"
|
|
2381
|
+
if is_tab_leader(node_id=node_id, tab_id=tab_id):
|
|
2382
|
+
return None, "Workflow Leader cannot be deleted from the graph"
|
|
2383
|
+
|
|
2384
|
+
related_edges = [
|
|
2385
|
+
edge
|
|
2386
|
+
for edge in tab.definition.edges
|
|
2387
|
+
if edge.from_node_id == node_id or edge.to_node_id == node_id
|
|
2388
|
+
]
|
|
2389
|
+
live_node = registry.get(node_id)
|
|
2390
|
+
record = workspace_store.get_node_record(node_id)
|
|
2391
|
+
|
|
2392
|
+
if live_node is not None:
|
|
2393
|
+
live_node.request_termination("graph_deleted")
|
|
2394
|
+
if not live_node.wait_for_termination(timeout=timeout):
|
|
2395
|
+
return (
|
|
2396
|
+
None,
|
|
2397
|
+
f"Failed to delete node '{node_id}' because it did not terminate",
|
|
2398
|
+
)
|
|
2399
|
+
|
|
2400
|
+
if record is not None:
|
|
2401
|
+
workspace_store.delete_node_record(node_id)
|
|
2402
|
+
|
|
2403
|
+
tab.definition.nodes = [node for node in tab.definition.nodes if node.id != node_id]
|
|
2404
|
+
tab.definition.edges = [
|
|
2405
|
+
edge
|
|
2406
|
+
for edge in tab.definition.edges
|
|
2407
|
+
if edge.id not in {item.id for item in related_edges}
|
|
2408
|
+
]
|
|
2409
|
+
tab.definition.view.positions.pop(node_id, None)
|
|
2410
|
+
workspace_store.upsert_tab(tab)
|
|
2411
|
+
payload: dict[str, object] = {
|
|
2412
|
+
"id": node_id,
|
|
2413
|
+
"tab_id": tab_id,
|
|
2414
|
+
"removed_edge_ids": [edge.id for edge in related_edges],
|
|
2415
|
+
}
|
|
2416
|
+
event_bus.emit(
|
|
2417
|
+
Event(
|
|
2418
|
+
type=EventType.NODE_DELETED,
|
|
2419
|
+
agent_id=node_id,
|
|
2420
|
+
data=payload,
|
|
2421
|
+
)
|
|
2422
|
+
)
|
|
2423
|
+
_emit_tab_updated(
|
|
2424
|
+
tab_id=tab_id,
|
|
2425
|
+
agent_id=node_id,
|
|
2426
|
+
)
|
|
2427
|
+
return payload, None
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def dispatch_node_message(
|
|
2431
|
+
*,
|
|
2432
|
+
node_id: str,
|
|
2433
|
+
content: str,
|
|
2434
|
+
parts: list | None = None,
|
|
2435
|
+
from_id: str = "human",
|
|
2436
|
+
) -> tuple[str | None, str | None]:
|
|
2437
|
+
target = registry.get(node_id)
|
|
2438
|
+
if target is None:
|
|
2439
|
+
return f"Node '{node_id}' is not active", None
|
|
2440
|
+
message_id = str(uuid.uuid4())
|
|
2441
|
+
normalized_parts = list(parts or [])
|
|
2442
|
+
target._append_history(
|
|
2443
|
+
ReceivedMessage(
|
|
2444
|
+
from_id=from_id,
|
|
2445
|
+
parts=normalized_parts,
|
|
2446
|
+
content=content,
|
|
2447
|
+
message_id=message_id,
|
|
2448
|
+
)
|
|
2449
|
+
)
|
|
2450
|
+
target.enqueue_message(
|
|
2451
|
+
Message(
|
|
2452
|
+
from_id=from_id,
|
|
2453
|
+
to_id=node_id,
|
|
2454
|
+
parts=normalized_parts,
|
|
2455
|
+
content=content,
|
|
2456
|
+
message_id=message_id,
|
|
2457
|
+
history_recorded=True,
|
|
2458
|
+
)
|
|
2459
|
+
)
|
|
2460
|
+
return None, message_id
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
def list_tab_nodes(tab_id: str) -> list[GraphNodeRecord]:
|
|
2464
|
+
return sorted(
|
|
2465
|
+
workspace_store.list_node_records(tab_id),
|
|
2466
|
+
key=lambda record: (record.created_at, record.id),
|
|
2467
|
+
)
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
def list_tab_edges(tab_id: str) -> list[GraphEdge]:
|
|
2471
|
+
tab = workspace_store.get_tab(tab_id)
|
|
2472
|
+
if tab is None:
|
|
2473
|
+
return []
|
|
2474
|
+
return sorted(
|
|
2475
|
+
[
|
|
2476
|
+
GraphEdge(
|
|
2477
|
+
id=edge.id,
|
|
2478
|
+
tab_id=tab_id,
|
|
2479
|
+
from_node_id=edge.from_node_id,
|
|
2480
|
+
from_port_key=edge.from_port_key,
|
|
2481
|
+
to_node_id=edge.to_node_id,
|
|
2482
|
+
to_port_key=edge.to_port_key,
|
|
2483
|
+
kind=edge.kind,
|
|
2484
|
+
created_at=edge.created_at,
|
|
2485
|
+
)
|
|
2486
|
+
for edge in tab.definition.edges
|
|
2487
|
+
],
|
|
2488
|
+
key=lambda edge: (edge.created_at, edge.id),
|
|
2489
|
+
)
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
def list_node_connection_ids(*, tab_id: str, node_id: str) -> list[str]:
|
|
2493
|
+
if is_tab_leader(node_id=node_id, tab_id=tab_id):
|
|
2494
|
+
return []
|
|
2495
|
+
|
|
2496
|
+
connection_ids: list[str] = []
|
|
2497
|
+
seen_node_ids: set[str] = set()
|
|
2498
|
+
for edge in list_tab_edges(tab_id):
|
|
2499
|
+
other_node_id: str | None = None
|
|
2500
|
+
if edge.from_node_id == node_id:
|
|
2501
|
+
other_node_id = edge.to_node_id
|
|
2502
|
+
elif edge.to_node_id == node_id:
|
|
2503
|
+
other_node_id = edge.from_node_id
|
|
2504
|
+
if other_node_id is None or other_node_id in seen_node_ids:
|
|
2505
|
+
continue
|
|
2506
|
+
seen_node_ids.add(other_node_id)
|
|
2507
|
+
connection_ids.append(other_node_id)
|
|
2508
|
+
return connection_ids
|