flowent 0.0.1 → 0.0.4
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 +19 -8
- package/backend/.python-version +1 -0
- package/backend/pyproject.toml +57 -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__/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__/stats_service.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 +2808 -0
- package/backend/src/flowent/assistant_commands.py +106 -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 +1346 -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 +1904 -0
- package/backend/src/flowent/model_metadata.py +98 -0
- package/backend/src/flowent/models/__init__.py +121 -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 +33 -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 +437 -0
- package/backend/src/flowent/models/history.py +214 -0
- package/backend/src/flowent/models/llm.py +61 -0
- package/backend/src/flowent/models/message.py +27 -0
- package/backend/src/flowent/models/tab.py +48 -0
- package/backend/src/flowent/models/todo.py +10 -0
- package/backend/src/flowent/network.py +146 -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 +182 -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 +255 -0
- package/backend/src/flowent/routes/__init__.py +30 -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__/stats.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 +365 -0
- package/backend/src/flowent/routes/prompts.py +46 -0
- package/backend/src/flowent/routes/providers_route.py +364 -0
- package/backend/src/flowent/routes/roles.py +207 -0
- package/backend/src/flowent/routes/settings.py +324 -0
- package/backend/src/flowent/routes/stats.py +229 -0
- package/backend/src/flowent/routes/tabs.py +292 -0
- package/backend/src/flowent/routes/ws.py +33 -0
- package/backend/src/flowent/runtime.py +188 -0
- package/backend/src/flowent/sandbox.py +45 -0
- package/backend/src/flowent/security.py +42 -0
- package/backend/src/flowent/settings.py +2467 -0
- package/backend/src/flowent/settings_management.py +286 -0
- package/backend/src/flowent/state_db.py +120 -0
- package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
- package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
- package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
- package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
- package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
- package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
- package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
- package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
- package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
- package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
- package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
- package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
- package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
- package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
- package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
- package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
- package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
- package/backend/src/flowent/static/assets/dialog-BeGSweF6.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-BHC1Vhy8.css +1 -0
- package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
- package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
- package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
- package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
- package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
- package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
- package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
- package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
- package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
- package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
- package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
- package/backend/src/flowent/static/index.html +36 -0
- package/backend/src/flowent/stats_service.py +218 -0
- package/backend/src/flowent/tools/__init__.py +201 -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 +156 -0
- package/backend/src/flowent/tools/contacts.py +22 -0
- package/backend/src/flowent/tools/create_agent.py +270 -0
- package/backend/src/flowent/tools/create_tab.py +59 -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 +117 -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 +50 -0
- package/backend/src/flowent/tools/list_tabs.py +96 -0
- package/backend/src/flowent/tools/list_tools.py +24 -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 +346 -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 +50 -0
- package/backend/src/flowent/tools/set_permissions.py +84 -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 +486 -0
- package/backend/tests/integration/api/test_prompts_api.py +47 -0
- package/backend/tests/integration/api/test_roles_api.py +227 -0
- package/backend/tests/integration/api/test_tabs_api.py +501 -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 +746 -0
- package/backend/tests/unit/agent/test_agent_runtime.py +2726 -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 +569 -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/__pycache__/test_stats_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 +368 -0
- package/backend/tests/unit/routes/test_roles_routes.py +426 -0
- package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
- package/backend/tests/unit/routes/test_stats_routes.py +149 -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 +1012 -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 +110 -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 +711 -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 +93 -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 +229 -0
- package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
- package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -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 +458 -0
- package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
- package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
- package/backend/tests/unit/tools/test_read_tool.py +33 -0
- package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
- package/backend/tests/unit/tools/test_todo_tool.py +37 -0
- package/backend/tests/unit/tools/test_tool_registry.py +91 -0
- package/backend/uv.lock +1144 -0
- package/bin/flowent.mjs +62 -36
- package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
- package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
- package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
- package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
- package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
- package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
- package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
- package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
- package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
- package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
- package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
- package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
- package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
- package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
- package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
- package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
- package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
- package/dist/frontend/assets/dialog-BeGSweF6.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-BHC1Vhy8.css +1 -0
- package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
- package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
- package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
- package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
- package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
- package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
- package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
- package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
- package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
- package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
- package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
- package/dist/frontend/index.html +36 -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,2726 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from flowent.agent import (
|
|
9
|
+
Agent,
|
|
10
|
+
ContextPreflight,
|
|
11
|
+
InterruptRequestedError,
|
|
12
|
+
PreparedLLMContext,
|
|
13
|
+
WakeSignal,
|
|
14
|
+
)
|
|
15
|
+
from flowent.events import event_bus
|
|
16
|
+
from flowent.models import (
|
|
17
|
+
AgentState,
|
|
18
|
+
AssistantText,
|
|
19
|
+
AssistantThinking,
|
|
20
|
+
CommandResultEntry,
|
|
21
|
+
ErrorEntry,
|
|
22
|
+
EventType,
|
|
23
|
+
ImagePart,
|
|
24
|
+
LLMResponse,
|
|
25
|
+
LLMUsage,
|
|
26
|
+
Message,
|
|
27
|
+
NodeConfig,
|
|
28
|
+
NodeType,
|
|
29
|
+
ReceivedMessage,
|
|
30
|
+
SentMessage,
|
|
31
|
+
StateEntry,
|
|
32
|
+
SystemEntry,
|
|
33
|
+
Tab,
|
|
34
|
+
TodoItem,
|
|
35
|
+
ToolCall,
|
|
36
|
+
ToolCallResult,
|
|
37
|
+
)
|
|
38
|
+
from flowent.providers.errors import LLMProviderError
|
|
39
|
+
from flowent.registry import registry
|
|
40
|
+
from flowent.settings import ModelSettings, ProviderConfig, Settings
|
|
41
|
+
from flowent.stats_service import stats_store
|
|
42
|
+
from flowent.workspace_store import workspace_store
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(autouse=True)
|
|
46
|
+
def reset_runtime_state(monkeypatch, tmp_path):
|
|
47
|
+
import flowent.settings as settings_module
|
|
48
|
+
|
|
49
|
+
settings_file = tmp_path / "settings.json"
|
|
50
|
+
settings_file.write_text("{}", encoding="utf-8")
|
|
51
|
+
monkeypatch.setattr(settings_module, "_SETTINGS_FILE", settings_file)
|
|
52
|
+
monkeypatch.setattr(settings_module, "_cached_settings", None)
|
|
53
|
+
registry.reset()
|
|
54
|
+
workspace_store.reset_cache()
|
|
55
|
+
stats_store.reset()
|
|
56
|
+
yield
|
|
57
|
+
registry.reset()
|
|
58
|
+
workspace_store.reset_cache()
|
|
59
|
+
stats_store.reset()
|
|
60
|
+
monkeypatch.setattr(settings_module, "_cached_settings", None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _register_tab_leader(*, tab_id: str = "tab-1", leader_id: str = "leader") -> Agent:
|
|
64
|
+
workspace_store.upsert_tab(Tab(id=tab_id, title="Task", leader_id=leader_id))
|
|
65
|
+
leader = Agent(
|
|
66
|
+
NodeConfig(
|
|
67
|
+
node_type=NodeType.AGENT,
|
|
68
|
+
role_name="Conductor",
|
|
69
|
+
name="Leader",
|
|
70
|
+
tab_id=tab_id,
|
|
71
|
+
),
|
|
72
|
+
uuid=leader_id,
|
|
73
|
+
)
|
|
74
|
+
registry.register(leader)
|
|
75
|
+
return leader
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_agent_keeps_running_after_pure_text_response(monkeypatch):
|
|
79
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
80
|
+
wait_calls = 0
|
|
81
|
+
llm_messages: list[list[dict]] = []
|
|
82
|
+
responses = iter([LLMResponse(content="working through the task"), LLMResponse()])
|
|
83
|
+
|
|
84
|
+
def fake_wait_for_input() -> None:
|
|
85
|
+
nonlocal wait_calls
|
|
86
|
+
wait_calls += 1
|
|
87
|
+
if wait_calls == 1:
|
|
88
|
+
agent._append_history(
|
|
89
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
90
|
+
)
|
|
91
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
92
|
+
return
|
|
93
|
+
raise AssertionError("agent should not auto-idle after pure assistant text")
|
|
94
|
+
|
|
95
|
+
def fake_chat(
|
|
96
|
+
messages,
|
|
97
|
+
tools=None,
|
|
98
|
+
on_chunk=None,
|
|
99
|
+
register_interrupt=None,
|
|
100
|
+
role_name=None,
|
|
101
|
+
):
|
|
102
|
+
llm_messages.append(messages)
|
|
103
|
+
if len(llm_messages) == 2:
|
|
104
|
+
agent.request_termination("done")
|
|
105
|
+
return next(responses)
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
108
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
109
|
+
|
|
110
|
+
agent._run()
|
|
111
|
+
|
|
112
|
+
assert wait_calls == 1
|
|
113
|
+
assert len(llm_messages) == 2
|
|
114
|
+
assert agent.state == AgentState.TERMINATED
|
|
115
|
+
assert any(
|
|
116
|
+
isinstance(entry, AssistantText) and entry.content == "working through the task"
|
|
117
|
+
for entry in agent.get_history_snapshot()
|
|
118
|
+
)
|
|
119
|
+
assert any(
|
|
120
|
+
msg.get("role") == "assistant"
|
|
121
|
+
and msg.get("content") == "working through the task"
|
|
122
|
+
for msg in llm_messages[1]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_agent_retries_transient_llm_errors_before_succeeding(monkeypatch):
|
|
127
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
128
|
+
wait_calls = 0
|
|
129
|
+
llm_calls = 0
|
|
130
|
+
|
|
131
|
+
def fake_wait_for_input() -> None:
|
|
132
|
+
nonlocal wait_calls
|
|
133
|
+
wait_calls += 1
|
|
134
|
+
if wait_calls == 1:
|
|
135
|
+
agent._append_history(
|
|
136
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
137
|
+
)
|
|
138
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
139
|
+
return
|
|
140
|
+
raise AssertionError("agent should not return to idle while retrying")
|
|
141
|
+
|
|
142
|
+
def fake_chat(
|
|
143
|
+
messages,
|
|
144
|
+
tools=None,
|
|
145
|
+
on_chunk=None,
|
|
146
|
+
register_interrupt=None,
|
|
147
|
+
role_name=None,
|
|
148
|
+
):
|
|
149
|
+
nonlocal llm_calls
|
|
150
|
+
llm_calls += 1
|
|
151
|
+
if llm_calls < 3:
|
|
152
|
+
raise LLMProviderError(
|
|
153
|
+
f"temporary failure {llm_calls}",
|
|
154
|
+
transient=True,
|
|
155
|
+
status_code=429,
|
|
156
|
+
)
|
|
157
|
+
if llm_calls == 4:
|
|
158
|
+
agent.request_termination("done")
|
|
159
|
+
return LLMResponse()
|
|
160
|
+
if llm_calls == 3:
|
|
161
|
+
return LLMResponse(content="Recovered answer")
|
|
162
|
+
raise AssertionError("unexpected extra LLM call")
|
|
163
|
+
|
|
164
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
165
|
+
monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
|
|
166
|
+
monkeypatch.setattr(
|
|
167
|
+
"flowent.agent.get_settings",
|
|
168
|
+
lambda: Settings(model=ModelSettings(max_retries=2)),
|
|
169
|
+
)
|
|
170
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
171
|
+
|
|
172
|
+
agent._run()
|
|
173
|
+
|
|
174
|
+
assert wait_calls == 1
|
|
175
|
+
assert llm_calls == 4
|
|
176
|
+
assert agent.state == AgentState.TERMINATED
|
|
177
|
+
assert not any(
|
|
178
|
+
isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
|
|
179
|
+
)
|
|
180
|
+
assert any(
|
|
181
|
+
isinstance(entry, AssistantText) and entry.content == "Recovered answer"
|
|
182
|
+
for entry in agent.get_history_snapshot()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_chat_with_retries_records_single_request_stat(monkeypatch):
|
|
187
|
+
workspace_store.upsert_tab(Tab(id="tab-1", title="Task", leader_id="leader-1"))
|
|
188
|
+
agent = Agent(
|
|
189
|
+
NodeConfig(
|
|
190
|
+
node_type=NodeType.AGENT,
|
|
191
|
+
role_name="Worker",
|
|
192
|
+
name="Planner",
|
|
193
|
+
tab_id="tab-1",
|
|
194
|
+
),
|
|
195
|
+
uuid="agent-1",
|
|
196
|
+
)
|
|
197
|
+
settings = Settings(
|
|
198
|
+
model=ModelSettings(
|
|
199
|
+
active_provider_id="provider-1",
|
|
200
|
+
active_model="gpt-5.2",
|
|
201
|
+
retry_policy="limited",
|
|
202
|
+
max_retries=2,
|
|
203
|
+
),
|
|
204
|
+
providers=[
|
|
205
|
+
ProviderConfig(
|
|
206
|
+
id="provider-1",
|
|
207
|
+
name="Primary",
|
|
208
|
+
type="openai_responses",
|
|
209
|
+
base_url="https://api.example.com/v1",
|
|
210
|
+
api_key="secret",
|
|
211
|
+
)
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
|
|
215
|
+
monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
|
|
216
|
+
|
|
217
|
+
llm_calls = 0
|
|
218
|
+
|
|
219
|
+
def fake_chat(
|
|
220
|
+
messages,
|
|
221
|
+
tools=None,
|
|
222
|
+
on_chunk=None,
|
|
223
|
+
register_interrupt=None,
|
|
224
|
+
role_name=None,
|
|
225
|
+
):
|
|
226
|
+
nonlocal llm_calls
|
|
227
|
+
llm_calls += 1
|
|
228
|
+
if llm_calls == 1:
|
|
229
|
+
raise LLMProviderError(
|
|
230
|
+
"temporary failure",
|
|
231
|
+
transient=True,
|
|
232
|
+
status_code=429,
|
|
233
|
+
)
|
|
234
|
+
return LLMResponse(
|
|
235
|
+
content="Done",
|
|
236
|
+
usage=LLMUsage(
|
|
237
|
+
total_tokens=120,
|
|
238
|
+
input_tokens=90,
|
|
239
|
+
output_tokens=30,
|
|
240
|
+
cache_read_tokens=12,
|
|
241
|
+
),
|
|
242
|
+
raw_usage={"total_tokens": 120, "input_tokens": 90},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
246
|
+
|
|
247
|
+
response, _ = agent._chat_with_retries(
|
|
248
|
+
prepared_context=PreparedLLMContext(
|
|
249
|
+
messages=[{"role": "user", "content": "hello"}],
|
|
250
|
+
system_messages=[],
|
|
251
|
+
execution_context_messages=[],
|
|
252
|
+
runtime_tail_messages=[],
|
|
253
|
+
),
|
|
254
|
+
tools_schema=None,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
records = stats_store.list_requests(since=0)
|
|
258
|
+
|
|
259
|
+
assert response.content == "Done"
|
|
260
|
+
assert len(records) == 1
|
|
261
|
+
assert records[0]["node_id"] == "agent-1"
|
|
262
|
+
assert records[0]["node_label"] == "Planner"
|
|
263
|
+
assert records[0]["tab_title"] == "Task"
|
|
264
|
+
assert records[0]["provider_id"] == "provider-1"
|
|
265
|
+
assert records[0]["model"] == "gpt-5.2"
|
|
266
|
+
assert records[0]["retry_count"] == 1
|
|
267
|
+
assert records[0]["result"] == "success"
|
|
268
|
+
assert records[0]["normalized_usage"]["cache_read_tokens"] == 12
|
|
269
|
+
assert records[0]["raw_usage"] == {"total_tokens": 120, "input_tokens": 90}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_agent_does_not_retry_transient_llm_errors_when_retry_policy_is_no_retry(
|
|
273
|
+
monkeypatch,
|
|
274
|
+
):
|
|
275
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
276
|
+
wait_calls = 0
|
|
277
|
+
llm_calls = 0
|
|
278
|
+
|
|
279
|
+
def fake_wait_for_input() -> None:
|
|
280
|
+
nonlocal wait_calls
|
|
281
|
+
wait_calls += 1
|
|
282
|
+
if wait_calls == 1:
|
|
283
|
+
agent._append_history(
|
|
284
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
285
|
+
)
|
|
286
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
287
|
+
return
|
|
288
|
+
agent.request_termination("done")
|
|
289
|
+
|
|
290
|
+
def fake_chat(
|
|
291
|
+
messages,
|
|
292
|
+
tools=None,
|
|
293
|
+
on_chunk=None,
|
|
294
|
+
register_interrupt=None,
|
|
295
|
+
role_name=None,
|
|
296
|
+
):
|
|
297
|
+
nonlocal llm_calls
|
|
298
|
+
llm_calls += 1
|
|
299
|
+
raise LLMProviderError(
|
|
300
|
+
"temporary failure",
|
|
301
|
+
transient=True,
|
|
302
|
+
status_code=429,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
306
|
+
monkeypatch.setattr(
|
|
307
|
+
"flowent.agent.get_settings",
|
|
308
|
+
lambda: Settings(
|
|
309
|
+
model=ModelSettings(
|
|
310
|
+
retry_policy="no_retry",
|
|
311
|
+
max_retries=5,
|
|
312
|
+
)
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
316
|
+
|
|
317
|
+
agent._run()
|
|
318
|
+
|
|
319
|
+
assert llm_calls == 1
|
|
320
|
+
assert wait_calls == 2
|
|
321
|
+
assert any(
|
|
322
|
+
isinstance(entry, ErrorEntry) and entry.content == "temporary failure"
|
|
323
|
+
for entry in agent.get_history_snapshot()
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_agent_does_not_retry_non_transient_llm_errors(monkeypatch):
|
|
328
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
329
|
+
wait_calls = 0
|
|
330
|
+
llm_calls = 0
|
|
331
|
+
error_summary = (
|
|
332
|
+
"LLM API error\n"
|
|
333
|
+
"Provider: Test Provider\n"
|
|
334
|
+
"Type: openai\n"
|
|
335
|
+
"Model: gpt-5.2\n"
|
|
336
|
+
"Base URL: http://example.invalid\n"
|
|
337
|
+
"Status: 401\n"
|
|
338
|
+
"Detail: Invalid API key"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def fake_wait_for_input() -> None:
|
|
342
|
+
nonlocal wait_calls
|
|
343
|
+
wait_calls += 1
|
|
344
|
+
if wait_calls == 1:
|
|
345
|
+
agent._append_history(
|
|
346
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
347
|
+
)
|
|
348
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
349
|
+
return
|
|
350
|
+
agent.request_termination("done")
|
|
351
|
+
|
|
352
|
+
def fake_chat(
|
|
353
|
+
messages,
|
|
354
|
+
tools=None,
|
|
355
|
+
on_chunk=None,
|
|
356
|
+
register_interrupt=None,
|
|
357
|
+
role_name=None,
|
|
358
|
+
):
|
|
359
|
+
nonlocal llm_calls
|
|
360
|
+
llm_calls += 1
|
|
361
|
+
raise LLMProviderError(
|
|
362
|
+
error_summary,
|
|
363
|
+
transient=False,
|
|
364
|
+
status_code=401,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
368
|
+
monkeypatch.setattr(
|
|
369
|
+
"flowent.agent.get_settings",
|
|
370
|
+
lambda: Settings(model=ModelSettings(max_retries=5)),
|
|
371
|
+
)
|
|
372
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
373
|
+
|
|
374
|
+
agent._run()
|
|
375
|
+
|
|
376
|
+
assert llm_calls == 1
|
|
377
|
+
assert wait_calls == 2
|
|
378
|
+
assert any(
|
|
379
|
+
isinstance(entry, ErrorEntry) and entry.content == error_summary
|
|
380
|
+
for entry in agent.get_history_snapshot()
|
|
381
|
+
)
|
|
382
|
+
assert any(
|
|
383
|
+
isinstance(entry, StateEntry)
|
|
384
|
+
and entry.state == AgentState.ERROR.value
|
|
385
|
+
and entry.reason == error_summary
|
|
386
|
+
for entry in agent.get_history_snapshot()
|
|
387
|
+
)
|
|
388
|
+
assert not any(
|
|
389
|
+
isinstance(entry, ErrorEntry)
|
|
390
|
+
and (
|
|
391
|
+
"traceback" in entry.content.lower() or "LLMProviderError:" in entry.content
|
|
392
|
+
)
|
|
393
|
+
for entry in agent.get_history_snapshot()
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_agent_interrupt_stops_retry_backoff(monkeypatch):
|
|
398
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
399
|
+
wait_calls = 0
|
|
400
|
+
llm_calls = 0
|
|
401
|
+
interrupter: threading.Thread | None = None
|
|
402
|
+
|
|
403
|
+
def fake_wait_for_input() -> None:
|
|
404
|
+
nonlocal wait_calls
|
|
405
|
+
wait_calls += 1
|
|
406
|
+
if wait_calls == 1:
|
|
407
|
+
agent._append_history(
|
|
408
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
409
|
+
)
|
|
410
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
411
|
+
return
|
|
412
|
+
agent.request_termination("done")
|
|
413
|
+
|
|
414
|
+
def fake_chat(
|
|
415
|
+
messages,
|
|
416
|
+
tools=None,
|
|
417
|
+
on_chunk=None,
|
|
418
|
+
register_interrupt=None,
|
|
419
|
+
role_name=None,
|
|
420
|
+
):
|
|
421
|
+
nonlocal llm_calls, interrupter
|
|
422
|
+
llm_calls += 1
|
|
423
|
+
if llm_calls == 1:
|
|
424
|
+
interrupter = threading.Thread(
|
|
425
|
+
target=lambda: (time.sleep(0.01), agent.request_interrupt())
|
|
426
|
+
)
|
|
427
|
+
interrupter.start()
|
|
428
|
+
raise LLMProviderError(
|
|
429
|
+
"temporary failure",
|
|
430
|
+
transient=True,
|
|
431
|
+
status_code=429,
|
|
432
|
+
)
|
|
433
|
+
raise AssertionError("interrupt should stop retry before next attempt")
|
|
434
|
+
|
|
435
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
436
|
+
monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 1.0)
|
|
437
|
+
monkeypatch.setattr(
|
|
438
|
+
"flowent.agent.get_settings",
|
|
439
|
+
lambda: Settings(model=ModelSettings(max_retries=5)),
|
|
440
|
+
)
|
|
441
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
442
|
+
|
|
443
|
+
agent._run()
|
|
444
|
+
if interrupter is not None:
|
|
445
|
+
interrupter.join(timeout=1.0)
|
|
446
|
+
|
|
447
|
+
assert llm_calls == 1
|
|
448
|
+
assert wait_calls == 2
|
|
449
|
+
assert not any(
|
|
450
|
+
isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_agent_retries_transient_errors_when_retry_policy_is_unlimited(monkeypatch):
|
|
455
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
456
|
+
wait_calls = 0
|
|
457
|
+
llm_calls = 0
|
|
458
|
+
|
|
459
|
+
def fake_wait_for_input() -> None:
|
|
460
|
+
nonlocal wait_calls
|
|
461
|
+
wait_calls += 1
|
|
462
|
+
if wait_calls == 1:
|
|
463
|
+
agent._append_history(
|
|
464
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
465
|
+
)
|
|
466
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
467
|
+
return
|
|
468
|
+
raise AssertionError("agent should not return to idle while retrying")
|
|
469
|
+
|
|
470
|
+
def fake_chat(
|
|
471
|
+
messages,
|
|
472
|
+
tools=None,
|
|
473
|
+
on_chunk=None,
|
|
474
|
+
register_interrupt=None,
|
|
475
|
+
role_name=None,
|
|
476
|
+
):
|
|
477
|
+
nonlocal llm_calls
|
|
478
|
+
llm_calls += 1
|
|
479
|
+
if llm_calls < 4:
|
|
480
|
+
raise LLMProviderError(
|
|
481
|
+
f"temporary failure {llm_calls}",
|
|
482
|
+
transient=True,
|
|
483
|
+
status_code=429,
|
|
484
|
+
)
|
|
485
|
+
if llm_calls == 5:
|
|
486
|
+
agent.request_termination("done")
|
|
487
|
+
return LLMResponse()
|
|
488
|
+
if llm_calls == 4:
|
|
489
|
+
return LLMResponse(content="Recovered after unlimited retries")
|
|
490
|
+
raise AssertionError("unexpected extra LLM call")
|
|
491
|
+
|
|
492
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
493
|
+
monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
|
|
494
|
+
monkeypatch.setattr(
|
|
495
|
+
"flowent.agent.get_settings",
|
|
496
|
+
lambda: Settings(
|
|
497
|
+
model=ModelSettings(
|
|
498
|
+
retry_policy="unlimited",
|
|
499
|
+
max_retries=1,
|
|
500
|
+
)
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
504
|
+
|
|
505
|
+
agent._run()
|
|
506
|
+
|
|
507
|
+
assert wait_calls == 1
|
|
508
|
+
assert llm_calls == 5
|
|
509
|
+
assert agent.state == AgentState.TERMINATED
|
|
510
|
+
assert any(
|
|
511
|
+
isinstance(entry, AssistantText)
|
|
512
|
+
and entry.content == "Recovered after unlimited retries"
|
|
513
|
+
for entry in agent.get_history_snapshot()
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_get_llm_retry_delay_uses_configured_backoff_settings(monkeypatch):
|
|
518
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT))
|
|
519
|
+
|
|
520
|
+
monkeypatch.setattr(
|
|
521
|
+
"flowent.agent.get_settings",
|
|
522
|
+
lambda: Settings(
|
|
523
|
+
model=ModelSettings(
|
|
524
|
+
retry_initial_delay_seconds=0.75,
|
|
525
|
+
retry_max_delay_seconds=5.0,
|
|
526
|
+
retry_backoff_cap_retries=3,
|
|
527
|
+
)
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
assert agent._get_llm_retry_delay(1) == 0.75
|
|
532
|
+
assert agent._get_llm_retry_delay(2) == 1.5
|
|
533
|
+
assert agent._get_llm_retry_delay(3) == 3.0
|
|
534
|
+
assert agent._get_llm_retry_delay(4) == 3.0
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_get_llm_retry_429_delay_uses_active_provider_only_for_429(monkeypatch):
|
|
538
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT, role_name="Worker"))
|
|
539
|
+
|
|
540
|
+
monkeypatch.setattr(
|
|
541
|
+
"flowent.agent.get_settings",
|
|
542
|
+
lambda: Settings(
|
|
543
|
+
model=ModelSettings(
|
|
544
|
+
active_provider_id="provider-1",
|
|
545
|
+
active_model="gpt-test",
|
|
546
|
+
),
|
|
547
|
+
providers=[
|
|
548
|
+
ProviderConfig(
|
|
549
|
+
id="provider-1",
|
|
550
|
+
name="Primary",
|
|
551
|
+
type="openai_compatible",
|
|
552
|
+
base_url="https://api.example.com/v1",
|
|
553
|
+
api_key="secret",
|
|
554
|
+
retry_429_delay_seconds=4,
|
|
555
|
+
)
|
|
556
|
+
],
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
assert agent._get_llm_retry_429_delay(429) == 4.0
|
|
561
|
+
assert agent._get_llm_retry_429_delay(500) == 0.0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_prepare_messages_records_auto_compact_stat(monkeypatch):
|
|
565
|
+
workspace_store.upsert_tab(Tab(id="tab-1", title="Task", leader_id="leader-1"))
|
|
566
|
+
agent = Agent(
|
|
567
|
+
NodeConfig(
|
|
568
|
+
node_type=NodeType.AGENT,
|
|
569
|
+
role_name="Worker",
|
|
570
|
+
name="Planner",
|
|
571
|
+
tab_id="tab-1",
|
|
572
|
+
),
|
|
573
|
+
uuid="agent-1",
|
|
574
|
+
)
|
|
575
|
+
settings = Settings(
|
|
576
|
+
model=ModelSettings(
|
|
577
|
+
active_provider_id="provider-1",
|
|
578
|
+
active_model="gpt-5.2",
|
|
579
|
+
),
|
|
580
|
+
providers=[
|
|
581
|
+
ProviderConfig(
|
|
582
|
+
id="provider-1",
|
|
583
|
+
name="Primary",
|
|
584
|
+
type="openai_responses",
|
|
585
|
+
base_url="https://api.example.com/v1",
|
|
586
|
+
api_key="secret",
|
|
587
|
+
)
|
|
588
|
+
],
|
|
589
|
+
)
|
|
590
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
|
|
591
|
+
|
|
592
|
+
prepared_context = PreparedLLMContext(
|
|
593
|
+
messages=[{"role": "user", "content": "hello"}],
|
|
594
|
+
system_messages=[],
|
|
595
|
+
execution_context_messages=[],
|
|
596
|
+
runtime_tail_messages=[],
|
|
597
|
+
)
|
|
598
|
+
preflights = iter(
|
|
599
|
+
[
|
|
600
|
+
ContextPreflight(
|
|
601
|
+
estimated_total_tokens=50,
|
|
602
|
+
auto_compact_token_limit=10,
|
|
603
|
+
),
|
|
604
|
+
ContextPreflight(
|
|
605
|
+
estimated_total_tokens=2,
|
|
606
|
+
auto_compact_token_limit=10,
|
|
607
|
+
),
|
|
608
|
+
]
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
monkeypatch.setattr(agent, "_build_prepared_llm_context", lambda: prepared_context)
|
|
612
|
+
monkeypatch.setattr(
|
|
613
|
+
agent,
|
|
614
|
+
"_compute_context_preflight",
|
|
615
|
+
lambda context: next(preflights),
|
|
616
|
+
)
|
|
617
|
+
compact_calls: list[str | None] = []
|
|
618
|
+
monkeypatch.setattr(
|
|
619
|
+
agent,
|
|
620
|
+
"_compact_execution_context",
|
|
621
|
+
lambda focus=None: compact_calls.append(focus) or "",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
result = agent._prepare_messages_for_llm()
|
|
625
|
+
records = stats_store.list_compacts(since=0)
|
|
626
|
+
|
|
627
|
+
assert result == prepared_context
|
|
628
|
+
assert compact_calls == [None]
|
|
629
|
+
assert len(records) == 1
|
|
630
|
+
assert records[0]["trigger_type"] == "auto"
|
|
631
|
+
assert records[0]["result"] == "success"
|
|
632
|
+
assert records[0]["provider_id"] == "provider-1"
|
|
633
|
+
assert records[0]["model"] == "gpt-5.2"
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_clear_assistant_chat_history_drops_conversation_entries():
|
|
637
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
638
|
+
assistant.history.extend(
|
|
639
|
+
[
|
|
640
|
+
SystemEntry(content="system prompt"),
|
|
641
|
+
ReceivedMessage(content="hello", from_id="human"),
|
|
642
|
+
AssistantThinking(content="planning"),
|
|
643
|
+
AssistantText(content="hi"),
|
|
644
|
+
ToolCall(
|
|
645
|
+
tool_name="idle",
|
|
646
|
+
tool_call_id="tool-1",
|
|
647
|
+
arguments={},
|
|
648
|
+
result="idle 1.00s",
|
|
649
|
+
),
|
|
650
|
+
ErrorEntry(content="boom"),
|
|
651
|
+
]
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
assistant.clear_chat_history()
|
|
655
|
+
|
|
656
|
+
assert all(
|
|
657
|
+
isinstance(entry, (SystemEntry, StateEntry))
|
|
658
|
+
for entry in assistant.get_history_snapshot()
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@pytest.mark.parametrize(
|
|
663
|
+
("state", "reason"),
|
|
664
|
+
[
|
|
665
|
+
(AgentState.RUNNING, "processing"),
|
|
666
|
+
(AgentState.SLEEPING, "waiting for reply"),
|
|
667
|
+
],
|
|
668
|
+
)
|
|
669
|
+
def test_clear_assistant_chat_history_interrupts_active_agent(
|
|
670
|
+
monkeypatch,
|
|
671
|
+
state,
|
|
672
|
+
reason,
|
|
673
|
+
):
|
|
674
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
675
|
+
assistant.set_state(state, reason)
|
|
676
|
+
assistant.history.append(ReceivedMessage(content="hello", from_id="human"))
|
|
677
|
+
interrupt_thread = threading.Thread(
|
|
678
|
+
target=assistant._handle_interrupt,
|
|
679
|
+
args=(None,),
|
|
680
|
+
daemon=True,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def fake_request_interrupt() -> bool:
|
|
684
|
+
if not interrupt_thread.is_alive():
|
|
685
|
+
interrupt_thread.start()
|
|
686
|
+
return True
|
|
687
|
+
|
|
688
|
+
monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
|
|
689
|
+
|
|
690
|
+
assistant.clear_chat_history()
|
|
691
|
+
|
|
692
|
+
assert assistant.state == AgentState.IDLE
|
|
693
|
+
assert not any(
|
|
694
|
+
isinstance(entry, ReceivedMessage) for entry in assistant.get_history_snapshot()
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
assistant.request_termination("done")
|
|
698
|
+
interrupt_thread.join(timeout=1.0)
|
|
699
|
+
assert interrupt_thread.is_alive() is False
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_clear_assistant_chat_history_drops_queued_messages_after_interrupt(
|
|
703
|
+
monkeypatch,
|
|
704
|
+
):
|
|
705
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
706
|
+
assistant.set_state(AgentState.RUNNING, "processing")
|
|
707
|
+
assistant._wake_queue.put(
|
|
708
|
+
WakeSignal(
|
|
709
|
+
reason="message",
|
|
710
|
+
payload={"message": {"content": "queued message", "from": "human"}},
|
|
711
|
+
resume_reason="received message from human",
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
interrupt_thread = threading.Thread(
|
|
716
|
+
target=assistant._handle_interrupt,
|
|
717
|
+
args=(None,),
|
|
718
|
+
daemon=True,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def fake_request_interrupt() -> bool:
|
|
722
|
+
if not interrupt_thread.is_alive():
|
|
723
|
+
interrupt_thread.start()
|
|
724
|
+
return True
|
|
725
|
+
|
|
726
|
+
monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
|
|
727
|
+
assistant.clear_chat_history()
|
|
728
|
+
|
|
729
|
+
assert assistant.state == AgentState.IDLE
|
|
730
|
+
assert not any(
|
|
731
|
+
isinstance(entry, ReceivedMessage) and entry.content == "queued message"
|
|
732
|
+
for entry in assistant.get_history_snapshot()
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
assistant.request_termination("done")
|
|
736
|
+
interrupt_thread.join(timeout=1.0)
|
|
737
|
+
assert interrupt_thread.is_alive() is False
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def test_execute_clear_command_does_not_append_visible_feedback():
|
|
741
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
742
|
+
assistant.history.extend(
|
|
743
|
+
[
|
|
744
|
+
ReceivedMessage(content="hello", from_id="human"),
|
|
745
|
+
AssistantText(content="hi"),
|
|
746
|
+
]
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
entry = assistant.execute_assistant_command(command_name="/clear")
|
|
750
|
+
history = assistant.get_history_snapshot()
|
|
751
|
+
|
|
752
|
+
assert isinstance(entry, CommandResultEntry)
|
|
753
|
+
assert entry.command_name == "/clear"
|
|
754
|
+
assert entry.include_in_context is False
|
|
755
|
+
assert not any(isinstance(item, ReceivedMessage) for item in history)
|
|
756
|
+
assert not any(isinstance(item, AssistantText) for item in history)
|
|
757
|
+
assert not any(
|
|
758
|
+
isinstance(item, CommandResultEntry) and item.command_name == "/clear"
|
|
759
|
+
for item in history
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def test_retry_human_message_replaces_selected_tail_and_requeues_input(monkeypatch):
|
|
764
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
765
|
+
queued_messages: list[Message] = []
|
|
766
|
+
monkeypatch.setattr(
|
|
767
|
+
assistant,
|
|
768
|
+
"enqueue_message",
|
|
769
|
+
lambda message: queued_messages.append(message),
|
|
770
|
+
)
|
|
771
|
+
assistant.history.extend(
|
|
772
|
+
[
|
|
773
|
+
ReceivedMessage(
|
|
774
|
+
content="Keep this message",
|
|
775
|
+
from_id="human",
|
|
776
|
+
message_id="msg-1",
|
|
777
|
+
),
|
|
778
|
+
AssistantText(content="Keep this reply"),
|
|
779
|
+
ReceivedMessage(
|
|
780
|
+
content="Retry this request",
|
|
781
|
+
from_id="human",
|
|
782
|
+
message_id="msg-2",
|
|
783
|
+
),
|
|
784
|
+
AssistantThinking(content="Old thinking"),
|
|
785
|
+
AssistantText(content="Discard this reply"),
|
|
786
|
+
ErrorEntry(content="Old failure"),
|
|
787
|
+
]
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
retried_message_id = assistant.retry_human_message(message_id="msg-2")
|
|
791
|
+
history = assistant.get_history_snapshot()
|
|
792
|
+
|
|
793
|
+
assert retried_message_id != "msg-2"
|
|
794
|
+
assert any(
|
|
795
|
+
isinstance(entry, ReceivedMessage)
|
|
796
|
+
and entry.message_id == "msg-1"
|
|
797
|
+
and entry.content == "Keep this message"
|
|
798
|
+
for entry in history
|
|
799
|
+
)
|
|
800
|
+
assert any(
|
|
801
|
+
isinstance(entry, AssistantText) and entry.content == "Keep this reply"
|
|
802
|
+
for entry in history
|
|
803
|
+
)
|
|
804
|
+
assert not any(
|
|
805
|
+
isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
|
|
806
|
+
for entry in history
|
|
807
|
+
)
|
|
808
|
+
assert not any(
|
|
809
|
+
isinstance(entry, AssistantText) and entry.content == "Discard this reply"
|
|
810
|
+
for entry in history
|
|
811
|
+
)
|
|
812
|
+
assert not any(
|
|
813
|
+
isinstance(entry, ErrorEntry) and entry.content == "Old failure"
|
|
814
|
+
for entry in history
|
|
815
|
+
)
|
|
816
|
+
assert any(
|
|
817
|
+
isinstance(entry, ReceivedMessage)
|
|
818
|
+
and entry.message_id == retried_message_id
|
|
819
|
+
and entry.content == "Retry this request"
|
|
820
|
+
for entry in history
|
|
821
|
+
)
|
|
822
|
+
assert len(queued_messages) == 1
|
|
823
|
+
assert queued_messages[0].message_id == retried_message_id
|
|
824
|
+
assert queued_messages[0].history_recorded is True
|
|
825
|
+
assert queued_messages[0].content == "Retry this request"
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def test_retry_human_message_reuses_image_parts(monkeypatch):
|
|
829
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
830
|
+
queued_messages: list[Message] = []
|
|
831
|
+
monkeypatch.setattr(assistant, "supports_input_image", lambda: True)
|
|
832
|
+
monkeypatch.setattr(
|
|
833
|
+
"flowent.agent.require_image_asset",
|
|
834
|
+
lambda asset_id: object() if asset_id == "asset-1" else None,
|
|
835
|
+
)
|
|
836
|
+
monkeypatch.setattr(
|
|
837
|
+
assistant,
|
|
838
|
+
"enqueue_message",
|
|
839
|
+
lambda message: queued_messages.append(message),
|
|
840
|
+
)
|
|
841
|
+
assistant.history.extend(
|
|
842
|
+
[
|
|
843
|
+
ReceivedMessage(
|
|
844
|
+
from_id="human",
|
|
845
|
+
parts=[
|
|
846
|
+
ImagePart(
|
|
847
|
+
asset_id="asset-1",
|
|
848
|
+
mime_type="image/png",
|
|
849
|
+
width=1,
|
|
850
|
+
height=1,
|
|
851
|
+
)
|
|
852
|
+
],
|
|
853
|
+
message_id="msg-image",
|
|
854
|
+
),
|
|
855
|
+
AssistantText(content="Old image reply"),
|
|
856
|
+
]
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
retried_message_id = assistant.retry_human_message(message_id="msg-image")
|
|
860
|
+
|
|
861
|
+
assert retried_message_id
|
|
862
|
+
assert len(queued_messages) == 1
|
|
863
|
+
assert len(queued_messages[0].parts) == 1
|
|
864
|
+
assert isinstance(queued_messages[0].parts[0], ImagePart)
|
|
865
|
+
assert queued_messages[0].parts[0].asset_id == "asset-1"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_retry_human_message_missing_anchor_keeps_queued_messages():
|
|
869
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
870
|
+
assistant._wake_queue.put(
|
|
871
|
+
WakeSignal(
|
|
872
|
+
reason="message",
|
|
873
|
+
payload={"message": {"content": "queued message", "from": "human"}},
|
|
874
|
+
resume_reason="received message from human",
|
|
875
|
+
)
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
with pytest.raises(LookupError):
|
|
879
|
+
assistant.retry_human_message(message_id="missing")
|
|
880
|
+
|
|
881
|
+
signal = assistant._wake_queue.get_nowait()
|
|
882
|
+
assert signal.reason == "message"
|
|
883
|
+
assert signal.payload["message"]["content"] == "queued message"
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_retry_human_message_rolls_back_when_persist_fails(monkeypatch):
|
|
887
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
888
|
+
assistant.history.extend(
|
|
889
|
+
[
|
|
890
|
+
ReceivedMessage(
|
|
891
|
+
content="Retry this request",
|
|
892
|
+
from_id="human",
|
|
893
|
+
message_id="msg-2",
|
|
894
|
+
),
|
|
895
|
+
AssistantText(content="Discard this reply"),
|
|
896
|
+
]
|
|
897
|
+
)
|
|
898
|
+
persist_calls = 0
|
|
899
|
+
|
|
900
|
+
def fake_persist() -> None:
|
|
901
|
+
nonlocal persist_calls
|
|
902
|
+
persist_calls += 1
|
|
903
|
+
if persist_calls == 1:
|
|
904
|
+
raise RuntimeError("persist failed")
|
|
905
|
+
|
|
906
|
+
monkeypatch.setattr(assistant, "_persist_workspace_node", fake_persist)
|
|
907
|
+
|
|
908
|
+
with pytest.raises(RuntimeError, match="persist failed"):
|
|
909
|
+
assistant.retry_human_message(message_id="msg-2")
|
|
910
|
+
|
|
911
|
+
history = assistant.get_history_snapshot()
|
|
912
|
+
assert any(
|
|
913
|
+
isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
|
|
914
|
+
for entry in history
|
|
915
|
+
)
|
|
916
|
+
assert any(
|
|
917
|
+
isinstance(entry, AssistantText) and entry.content == "Discard this reply"
|
|
918
|
+
for entry in history
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def test_leader_retry_received_message_requires_human_anchor(monkeypatch):
|
|
923
|
+
leader = _register_tab_leader()
|
|
924
|
+
leader.history.extend(
|
|
925
|
+
[
|
|
926
|
+
ReceivedMessage(
|
|
927
|
+
content="Assistant brief",
|
|
928
|
+
from_id="assistant",
|
|
929
|
+
message_id="brief-1",
|
|
930
|
+
),
|
|
931
|
+
ReceivedMessage(
|
|
932
|
+
content="Human follow-up",
|
|
933
|
+
from_id="human",
|
|
934
|
+
message_id="msg-human",
|
|
935
|
+
),
|
|
936
|
+
]
|
|
937
|
+
)
|
|
938
|
+
monkeypatch.setattr(
|
|
939
|
+
"flowent.graph_service.is_tab_leader",
|
|
940
|
+
lambda **kwargs: kwargs["node_id"] == leader.uuid,
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
with pytest.raises(
|
|
944
|
+
LookupError,
|
|
945
|
+
match=r"Leader human message `brief-1` was not found\.",
|
|
946
|
+
):
|
|
947
|
+
leader.retry_received_message(message_id="brief-1")
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def test_execute_compact_command_replaces_history_with_summary(monkeypatch):
|
|
951
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
952
|
+
assistant.history.extend(
|
|
953
|
+
[
|
|
954
|
+
ReceivedMessage(content="Summarize the rollout", from_id="human"),
|
|
955
|
+
AssistantText(content="Working through the changes."),
|
|
956
|
+
ErrorEntry(content="temporary failure"),
|
|
957
|
+
]
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
monkeypatch.setattr(
|
|
961
|
+
"flowent.agent.gateway.chat",
|
|
962
|
+
lambda *args, **kwargs: LLMResponse(
|
|
963
|
+
content=(
|
|
964
|
+
"## Current Goal\nShip the command layer.\n\n"
|
|
965
|
+
"## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
|
|
966
|
+
"## Key Constraints\nPreserve persistence.\n\n"
|
|
967
|
+
"## Confirmed Decisions\nUse built-in commands only.\n\n"
|
|
968
|
+
"## Open Questions\nNone.\n\n"
|
|
969
|
+
"## Next Actions\nFinish the UI."
|
|
970
|
+
)
|
|
971
|
+
),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
entry = assistant.execute_assistant_command(
|
|
975
|
+
command_name="/compact",
|
|
976
|
+
argument="slash rollout",
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
history = assistant.get_history_snapshot()
|
|
980
|
+
|
|
981
|
+
assert isinstance(entry, CommandResultEntry)
|
|
982
|
+
assert entry.include_in_context is False
|
|
983
|
+
assert history[-1] == entry
|
|
984
|
+
assert any(
|
|
985
|
+
isinstance(item, ReceivedMessage) and item.content == "Summarize the rollout"
|
|
986
|
+
for item in history
|
|
987
|
+
)
|
|
988
|
+
assert any(
|
|
989
|
+
isinstance(item, AssistantText)
|
|
990
|
+
and item.content == "Working through the changes."
|
|
991
|
+
for item in history
|
|
992
|
+
)
|
|
993
|
+
assert (
|
|
994
|
+
assistant.get_execution_context_summary().startswith("## Current Goal\n")
|
|
995
|
+
is True
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
messages = assistant._build_messages()
|
|
999
|
+
serialized = json.dumps(messages)
|
|
1000
|
+
|
|
1001
|
+
assert "Summarize the rollout" not in serialized
|
|
1002
|
+
assert "Compacted execution context" in serialized
|
|
1003
|
+
assert "Ship the command layer." in serialized
|
|
1004
|
+
assert "Compacted the current Assistant execution context." not in serialized
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def test_compact_command_excludes_queued_messages_from_summary(monkeypatch):
|
|
1008
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1009
|
+
assistant.history.extend(
|
|
1010
|
+
[
|
|
1011
|
+
ReceivedMessage(content="Existing history", from_id="human"),
|
|
1012
|
+
AssistantText(content="Existing reply"),
|
|
1013
|
+
]
|
|
1014
|
+
)
|
|
1015
|
+
assistant.set_state(AgentState.RUNNING, "processing")
|
|
1016
|
+
assistant._wake_queue.put(
|
|
1017
|
+
WakeSignal(
|
|
1018
|
+
reason="message",
|
|
1019
|
+
payload={"message": {"content": "queued message", "from": "human"}},
|
|
1020
|
+
resume_reason="received message from human",
|
|
1021
|
+
)
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
captured_messages: list[list[dict]] = []
|
|
1025
|
+
|
|
1026
|
+
def fake_chat(*, messages, **kwargs):
|
|
1027
|
+
captured_messages.append(messages)
|
|
1028
|
+
return LLMResponse(
|
|
1029
|
+
content=(
|
|
1030
|
+
"## Current Goal\nShip the command layer.\n\n"
|
|
1031
|
+
"## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
|
|
1032
|
+
"## Key Constraints\nPreserve persistence.\n\n"
|
|
1033
|
+
"## Confirmed Decisions\nUse built-in commands only.\n\n"
|
|
1034
|
+
"## Open Questions\nNone.\n\n"
|
|
1035
|
+
"## Next Actions\nFinish the UI."
|
|
1036
|
+
)
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
interrupt_thread = threading.Thread(
|
|
1040
|
+
target=assistant._handle_interrupt,
|
|
1041
|
+
args=(None,),
|
|
1042
|
+
daemon=True,
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
def fake_request_interrupt() -> bool:
|
|
1046
|
+
if not interrupt_thread.is_alive():
|
|
1047
|
+
interrupt_thread.start()
|
|
1048
|
+
return True
|
|
1049
|
+
|
|
1050
|
+
monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
|
|
1051
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1052
|
+
|
|
1053
|
+
assistant.compact_chat_history()
|
|
1054
|
+
|
|
1055
|
+
assert captured_messages
|
|
1056
|
+
assert "queued message" not in json.dumps(captured_messages[0])
|
|
1057
|
+
|
|
1058
|
+
assistant.request_termination("done")
|
|
1059
|
+
interrupt_thread.join(timeout=1.0)
|
|
1060
|
+
assert interrupt_thread.is_alive() is False
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def test_help_command_result_does_not_reenter_model_context():
|
|
1064
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1065
|
+
|
|
1066
|
+
entry = assistant.execute_assistant_command(command_name="/help")
|
|
1067
|
+
messages = assistant._build_messages()
|
|
1068
|
+
serialized = json.dumps(messages)
|
|
1069
|
+
|
|
1070
|
+
assert isinstance(entry, CommandResultEntry)
|
|
1071
|
+
assert entry.include_in_context is False
|
|
1072
|
+
assert "/compact" in entry.content
|
|
1073
|
+
assert "Built-in Assistant commands" not in serialized
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def test_agent_normalizes_think_tags_in_final_content(monkeypatch):
|
|
1077
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1078
|
+
wait_calls = 0
|
|
1079
|
+
llm_calls = 0
|
|
1080
|
+
|
|
1081
|
+
def fake_wait_for_input() -> None:
|
|
1082
|
+
nonlocal wait_calls
|
|
1083
|
+
wait_calls += 1
|
|
1084
|
+
if wait_calls == 1:
|
|
1085
|
+
agent._append_history(
|
|
1086
|
+
ReceivedMessage(content="reply to me", from_id="human")
|
|
1087
|
+
)
|
|
1088
|
+
agent.set_state(AgentState.RUNNING, "received message from human")
|
|
1089
|
+
return
|
|
1090
|
+
agent.request_termination("done")
|
|
1091
|
+
|
|
1092
|
+
def fake_chat(
|
|
1093
|
+
messages,
|
|
1094
|
+
tools=None,
|
|
1095
|
+
on_chunk=None,
|
|
1096
|
+
register_interrupt=None,
|
|
1097
|
+
role_name=None,
|
|
1098
|
+
):
|
|
1099
|
+
nonlocal llm_calls
|
|
1100
|
+
llm_calls += 1
|
|
1101
|
+
if llm_calls == 2:
|
|
1102
|
+
agent.request_termination("done")
|
|
1103
|
+
return LLMResponse()
|
|
1104
|
+
return LLMResponse(content="<think>Drafting plan</think>\nHello there")
|
|
1105
|
+
|
|
1106
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
1107
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1108
|
+
|
|
1109
|
+
agent._run()
|
|
1110
|
+
|
|
1111
|
+
history = agent.get_history_snapshot()
|
|
1112
|
+
|
|
1113
|
+
assert any(
|
|
1114
|
+
isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
|
|
1115
|
+
for entry in history
|
|
1116
|
+
)
|
|
1117
|
+
assert any(
|
|
1118
|
+
isinstance(entry, AssistantText) and entry.content == "Hello there"
|
|
1119
|
+
for entry in history
|
|
1120
|
+
)
|
|
1121
|
+
assert not any(
|
|
1122
|
+
isinstance(entry, AssistantText) and "<think>" in entry.content
|
|
1123
|
+
for entry in history
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def test_agent_dedupes_structured_thinking_and_raw_think_tags(monkeypatch):
|
|
1128
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1129
|
+
wait_calls = 0
|
|
1130
|
+
llm_calls = 0
|
|
1131
|
+
|
|
1132
|
+
def fake_wait_for_input() -> None:
|
|
1133
|
+
nonlocal wait_calls
|
|
1134
|
+
wait_calls += 1
|
|
1135
|
+
if wait_calls == 1:
|
|
1136
|
+
agent._append_history(
|
|
1137
|
+
ReceivedMessage(content="reply to me", from_id="human")
|
|
1138
|
+
)
|
|
1139
|
+
agent.set_state(AgentState.RUNNING, "received message from human")
|
|
1140
|
+
return
|
|
1141
|
+
agent.request_termination("done")
|
|
1142
|
+
|
|
1143
|
+
def fake_chat(
|
|
1144
|
+
messages,
|
|
1145
|
+
tools=None,
|
|
1146
|
+
on_chunk=None,
|
|
1147
|
+
register_interrupt=None,
|
|
1148
|
+
role_name=None,
|
|
1149
|
+
):
|
|
1150
|
+
nonlocal llm_calls
|
|
1151
|
+
llm_calls += 1
|
|
1152
|
+
if llm_calls == 2:
|
|
1153
|
+
agent.request_termination("done")
|
|
1154
|
+
return LLMResponse()
|
|
1155
|
+
return LLMResponse(
|
|
1156
|
+
content="<think>Drafting plan</think>\nHello there",
|
|
1157
|
+
thinking="Drafting plan",
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
1161
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1162
|
+
|
|
1163
|
+
agent._run()
|
|
1164
|
+
|
|
1165
|
+
thinking_entries = [
|
|
1166
|
+
entry.content
|
|
1167
|
+
for entry in agent.get_history_snapshot()
|
|
1168
|
+
if isinstance(entry, AssistantThinking)
|
|
1169
|
+
]
|
|
1170
|
+
|
|
1171
|
+
assert thinking_entries == ["Drafting plan"]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def test_agent_unregisters_from_registry_after_termination_request(monkeypatch):
|
|
1175
|
+
registry.reset()
|
|
1176
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-x")
|
|
1177
|
+
registry.register(agent)
|
|
1178
|
+
events = []
|
|
1179
|
+
|
|
1180
|
+
def fake_wait_for_input() -> None:
|
|
1181
|
+
agent._append_history(
|
|
1182
|
+
ReceivedMessage(content="finish the task", from_id="tester")
|
|
1183
|
+
)
|
|
1184
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
1185
|
+
|
|
1186
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
1187
|
+
|
|
1188
|
+
def fake_chat(
|
|
1189
|
+
messages,
|
|
1190
|
+
tools=None,
|
|
1191
|
+
on_chunk=None,
|
|
1192
|
+
register_interrupt=None,
|
|
1193
|
+
role_name=None,
|
|
1194
|
+
):
|
|
1195
|
+
agent.request_termination("done")
|
|
1196
|
+
return LLMResponse()
|
|
1197
|
+
|
|
1198
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1199
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1200
|
+
|
|
1201
|
+
agent._run()
|
|
1202
|
+
|
|
1203
|
+
assert agent.state == AgentState.TERMINATED
|
|
1204
|
+
assert registry.get(agent.uuid) is None
|
|
1205
|
+
assert [event.type for event in events[-2:]] == [
|
|
1206
|
+
EventType.NODE_STATE_CHANGED,
|
|
1207
|
+
EventType.NODE_TERMINATED,
|
|
1208
|
+
]
|
|
1209
|
+
assert events[-1].data == {"reason": "done"}
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def test_finalize_termination_removes_bidirectional_connections():
|
|
1213
|
+
registry.reset()
|
|
1214
|
+
try:
|
|
1215
|
+
assistant = Agent(
|
|
1216
|
+
NodeConfig(node_type=NodeType.ASSISTANT),
|
|
1217
|
+
)
|
|
1218
|
+
worker = Agent(
|
|
1219
|
+
NodeConfig(node_type=NodeType.AGENT),
|
|
1220
|
+
uuid="worker",
|
|
1221
|
+
)
|
|
1222
|
+
registry.register(assistant)
|
|
1223
|
+
registry.register(worker)
|
|
1224
|
+
assistant.add_connection(worker.uuid)
|
|
1225
|
+
worker.add_connection(assistant.uuid)
|
|
1226
|
+
|
|
1227
|
+
worker._finalize_termination("done")
|
|
1228
|
+
|
|
1229
|
+
assert registry.get(worker.uuid) is None
|
|
1230
|
+
assert assistant.get_connections_snapshot() == []
|
|
1231
|
+
assert worker.get_connections_snapshot() == []
|
|
1232
|
+
finally:
|
|
1233
|
+
registry.reset()
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def test_agent_interrupts_streaming_response_and_returns_to_idle(monkeypatch):
|
|
1237
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
1238
|
+
registry.reset()
|
|
1239
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1240
|
+
registry.register(assistant)
|
|
1241
|
+
events = []
|
|
1242
|
+
wait_calls = 0
|
|
1243
|
+
|
|
1244
|
+
def fake_wait_for_input() -> None:
|
|
1245
|
+
nonlocal wait_calls
|
|
1246
|
+
wait_calls += 1
|
|
1247
|
+
if wait_calls == 1:
|
|
1248
|
+
assistant._append_history(
|
|
1249
|
+
ReceivedMessage(content="start working", from_id="human")
|
|
1250
|
+
)
|
|
1251
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
1252
|
+
return
|
|
1253
|
+
assistant.request_termination("done")
|
|
1254
|
+
|
|
1255
|
+
def fake_chat(
|
|
1256
|
+
messages,
|
|
1257
|
+
tools=None,
|
|
1258
|
+
on_chunk=None,
|
|
1259
|
+
register_interrupt=None,
|
|
1260
|
+
role_name=None,
|
|
1261
|
+
):
|
|
1262
|
+
assert on_chunk is not None
|
|
1263
|
+
on_chunk("thinking", "Drafting plan")
|
|
1264
|
+
on_chunk("content", "Working")
|
|
1265
|
+
assert assistant.request_interrupt() is True
|
|
1266
|
+
on_chunk("content", " on the task")
|
|
1267
|
+
raise AssertionError("interrupt should stop streaming before completion")
|
|
1268
|
+
|
|
1269
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
1270
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1271
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1272
|
+
|
|
1273
|
+
try:
|
|
1274
|
+
assistant._run()
|
|
1275
|
+
finally:
|
|
1276
|
+
registry.reset()
|
|
1277
|
+
|
|
1278
|
+
history = assistant.get_history_snapshot()
|
|
1279
|
+
|
|
1280
|
+
assert any(
|
|
1281
|
+
isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
|
|
1282
|
+
for entry in history
|
|
1283
|
+
)
|
|
1284
|
+
assert any(
|
|
1285
|
+
isinstance(entry, AssistantText) and entry.content == "Working"
|
|
1286
|
+
for entry in history
|
|
1287
|
+
)
|
|
1288
|
+
assert any(
|
|
1289
|
+
event.type == EventType.NODE_STATE_CHANGED
|
|
1290
|
+
and event.data.get("new_state") == "idle"
|
|
1291
|
+
for event in events
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def test_agent_normalizes_think_tags_in_streaming_content(monkeypatch):
|
|
1296
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1297
|
+
wait_calls = 0
|
|
1298
|
+
events = []
|
|
1299
|
+
llm_calls = 0
|
|
1300
|
+
|
|
1301
|
+
def fake_wait_for_input() -> None:
|
|
1302
|
+
nonlocal wait_calls
|
|
1303
|
+
wait_calls += 1
|
|
1304
|
+
if wait_calls == 1:
|
|
1305
|
+
assistant._append_history(
|
|
1306
|
+
ReceivedMessage(content="reply to me", from_id="human")
|
|
1307
|
+
)
|
|
1308
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
1309
|
+
return
|
|
1310
|
+
assistant.request_termination("done")
|
|
1311
|
+
|
|
1312
|
+
def fake_chat(
|
|
1313
|
+
messages,
|
|
1314
|
+
tools=None,
|
|
1315
|
+
on_chunk=None,
|
|
1316
|
+
register_interrupt=None,
|
|
1317
|
+
role_name=None,
|
|
1318
|
+
):
|
|
1319
|
+
nonlocal llm_calls
|
|
1320
|
+
llm_calls += 1
|
|
1321
|
+
if llm_calls == 2:
|
|
1322
|
+
assistant.request_termination("done")
|
|
1323
|
+
return LLMResponse()
|
|
1324
|
+
assert on_chunk is not None
|
|
1325
|
+
on_chunk("content", "<think>Drafting plan</think>\nHello there")
|
|
1326
|
+
return LLMResponse(content="<think>Drafting plan</think>\nHello there")
|
|
1327
|
+
|
|
1328
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
1329
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1330
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1331
|
+
|
|
1332
|
+
assistant._run()
|
|
1333
|
+
|
|
1334
|
+
history = assistant.get_history_snapshot()
|
|
1335
|
+
assistant_content_events = [
|
|
1336
|
+
event.data.get("content")
|
|
1337
|
+
for event in events
|
|
1338
|
+
if event.type == EventType.ASSISTANT_CONTENT
|
|
1339
|
+
]
|
|
1340
|
+
|
|
1341
|
+
assert any(
|
|
1342
|
+
isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
|
|
1343
|
+
for entry in history
|
|
1344
|
+
)
|
|
1345
|
+
assert any(
|
|
1346
|
+
isinstance(entry, AssistantText) and entry.content == "Hello there"
|
|
1347
|
+
for entry in history
|
|
1348
|
+
)
|
|
1349
|
+
assert assistant_content_events == ["Hello there"]
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def test_agent_does_not_duplicate_thinking_when_provider_returns_both(monkeypatch):
|
|
1353
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1354
|
+
wait_calls = 0
|
|
1355
|
+
llm_calls = 0
|
|
1356
|
+
|
|
1357
|
+
def fake_wait_for_input() -> None:
|
|
1358
|
+
nonlocal wait_calls
|
|
1359
|
+
wait_calls += 1
|
|
1360
|
+
if wait_calls == 1:
|
|
1361
|
+
assistant._append_history(
|
|
1362
|
+
ReceivedMessage(content="reply to me", from_id="human")
|
|
1363
|
+
)
|
|
1364
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
1365
|
+
return
|
|
1366
|
+
assistant.request_termination("done")
|
|
1367
|
+
|
|
1368
|
+
def fake_chat(
|
|
1369
|
+
messages,
|
|
1370
|
+
tools=None,
|
|
1371
|
+
on_chunk=None,
|
|
1372
|
+
register_interrupt=None,
|
|
1373
|
+
role_name=None,
|
|
1374
|
+
):
|
|
1375
|
+
nonlocal llm_calls
|
|
1376
|
+
llm_calls += 1
|
|
1377
|
+
if llm_calls == 2:
|
|
1378
|
+
assistant.request_termination("done")
|
|
1379
|
+
return LLMResponse()
|
|
1380
|
+
return LLMResponse(
|
|
1381
|
+
content="<think>Drafting plan</think>\nHello there",
|
|
1382
|
+
thinking="Drafting plan",
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
1386
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1387
|
+
|
|
1388
|
+
assistant._run()
|
|
1389
|
+
|
|
1390
|
+
thinking_entries = [
|
|
1391
|
+
entry
|
|
1392
|
+
for entry in assistant.get_history_snapshot()
|
|
1393
|
+
if isinstance(entry, AssistantThinking)
|
|
1394
|
+
]
|
|
1395
|
+
|
|
1396
|
+
assert [entry.content for entry in thinking_entries] == ["Drafting plan"]
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def test_request_sleep_raises_interrupt_when_running_agent_is_interrupted():
|
|
1400
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
|
|
1401
|
+
agent.set_state(AgentState.RUNNING, "sleeping")
|
|
1402
|
+
assert agent.request_interrupt() is True
|
|
1403
|
+
|
|
1404
|
+
try:
|
|
1405
|
+
agent.request_sleep(seconds=0.2)
|
|
1406
|
+
except InterruptRequestedError:
|
|
1407
|
+
pass
|
|
1408
|
+
else:
|
|
1409
|
+
raise AssertionError("expected interrupt during sleep")
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def test_request_sleep_wakes_early_when_new_message_arrives():
|
|
1413
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
|
|
1414
|
+
agent.set_state(AgentState.RUNNING, "processing")
|
|
1415
|
+
|
|
1416
|
+
def enqueue_message() -> None:
|
|
1417
|
+
time.sleep(0.02)
|
|
1418
|
+
agent.enqueue_message(
|
|
1419
|
+
Message(from_id="tester", to_id=agent.uuid, content="wake up")
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
wake_thread = threading.Thread(target=enqueue_message, daemon=True)
|
|
1423
|
+
wake_thread.start()
|
|
1424
|
+
|
|
1425
|
+
result = agent.request_sleep(seconds=0.3)
|
|
1426
|
+
|
|
1427
|
+
wake_thread.join(timeout=1.0)
|
|
1428
|
+
|
|
1429
|
+
assert result.startswith("woken by message after ")
|
|
1430
|
+
assert agent.state == AgentState.RUNNING
|
|
1431
|
+
received_entries = [
|
|
1432
|
+
entry
|
|
1433
|
+
for entry in agent.get_history_snapshot()
|
|
1434
|
+
if isinstance(entry, ReceivedMessage)
|
|
1435
|
+
]
|
|
1436
|
+
assert len(received_entries) == 1
|
|
1437
|
+
assert received_entries[0].content == "wake up"
|
|
1438
|
+
assert [
|
|
1439
|
+
entry.state
|
|
1440
|
+
for entry in agent.get_history_snapshot()
|
|
1441
|
+
if isinstance(entry, StateEntry)
|
|
1442
|
+
][-2:] == ["sleeping", "running"]
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def test_request_sleep_timeout_queues_deadline_notice():
|
|
1446
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
|
|
1447
|
+
agent.set_state(AgentState.RUNNING, "processing")
|
|
1448
|
+
|
|
1449
|
+
result = agent.request_sleep(seconds=0.01)
|
|
1450
|
+
|
|
1451
|
+
assert result.startswith("slept ")
|
|
1452
|
+
assert agent.state == AgentState.RUNNING
|
|
1453
|
+
assert agent._consume_runtime_notices() == [agent._build_sleep_deadline_notice()]
|
|
1454
|
+
assert [
|
|
1455
|
+
entry.state
|
|
1456
|
+
for entry in agent.get_history_snapshot()
|
|
1457
|
+
if isinstance(entry, StateEntry)
|
|
1458
|
+
][-2:] == ["sleeping", "running"]
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def test_agent_interrupts_blocked_provider_without_streaming_output(monkeypatch):
|
|
1462
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
1463
|
+
registry.reset()
|
|
1464
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1465
|
+
registry.register(assistant)
|
|
1466
|
+
events = []
|
|
1467
|
+
wait_calls = 0
|
|
1468
|
+
provider_started = threading.Event()
|
|
1469
|
+
|
|
1470
|
+
def fake_wait_for_input() -> None:
|
|
1471
|
+
nonlocal wait_calls
|
|
1472
|
+
wait_calls += 1
|
|
1473
|
+
if wait_calls == 1:
|
|
1474
|
+
assistant._append_history(
|
|
1475
|
+
ReceivedMessage(content="start working", from_id="human")
|
|
1476
|
+
)
|
|
1477
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
1478
|
+
return
|
|
1479
|
+
assistant.request_termination("done")
|
|
1480
|
+
|
|
1481
|
+
def fake_chat(
|
|
1482
|
+
messages,
|
|
1483
|
+
tools=None,
|
|
1484
|
+
on_chunk=None,
|
|
1485
|
+
register_interrupt=None,
|
|
1486
|
+
role_name=None,
|
|
1487
|
+
):
|
|
1488
|
+
closed = threading.Event()
|
|
1489
|
+
assert register_interrupt is not None
|
|
1490
|
+
register_interrupt(closed.set)
|
|
1491
|
+
provider_started.set()
|
|
1492
|
+
while not closed.wait(0.01):
|
|
1493
|
+
continue
|
|
1494
|
+
raise RuntimeError("stream closed")
|
|
1495
|
+
|
|
1496
|
+
def request_interrupt() -> None:
|
|
1497
|
+
provider_started.wait(timeout=1.0)
|
|
1498
|
+
assistant.request_interrupt()
|
|
1499
|
+
|
|
1500
|
+
interrupter = threading.Thread(target=request_interrupt, daemon=True)
|
|
1501
|
+
interrupter.start()
|
|
1502
|
+
|
|
1503
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
1504
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1505
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1506
|
+
|
|
1507
|
+
try:
|
|
1508
|
+
assistant._run()
|
|
1509
|
+
finally:
|
|
1510
|
+
registry.reset()
|
|
1511
|
+
|
|
1512
|
+
interrupter.join(timeout=1.0)
|
|
1513
|
+
|
|
1514
|
+
assert any(
|
|
1515
|
+
event.type == EventType.NODE_STATE_CHANGED
|
|
1516
|
+
and event.data.get("new_state") == "idle"
|
|
1517
|
+
for event in events
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def test_provider_resolution_error_is_recorded_in_history(monkeypatch):
|
|
1522
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-y")
|
|
1523
|
+
wait_calls = 0
|
|
1524
|
+
|
|
1525
|
+
def fake_wait_for_input() -> None:
|
|
1526
|
+
nonlocal wait_calls
|
|
1527
|
+
wait_calls += 1
|
|
1528
|
+
if wait_calls == 1:
|
|
1529
|
+
agent._append_history(
|
|
1530
|
+
ReceivedMessage(content="do the task", from_id="tester")
|
|
1531
|
+
)
|
|
1532
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
1533
|
+
return
|
|
1534
|
+
agent.request_termination("stop")
|
|
1535
|
+
|
|
1536
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
1537
|
+
monkeypatch.setattr(
|
|
1538
|
+
"flowent.agent.gateway.chat",
|
|
1539
|
+
lambda messages, tools=None, on_chunk=None, register_interrupt=None, role_name=None: (
|
|
1540
|
+
(_ for _ in ()).throw(RuntimeError("No active provider configured"))
|
|
1541
|
+
),
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
agent._run()
|
|
1545
|
+
|
|
1546
|
+
assert wait_calls == 2
|
|
1547
|
+
assert agent.state == AgentState.TERMINATED
|
|
1548
|
+
assert any(
|
|
1549
|
+
isinstance(entry, ErrorEntry)
|
|
1550
|
+
and "No active provider configured" in entry.content
|
|
1551
|
+
for entry in agent.get_history_snapshot()
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def test_assistant_content_streams_even_when_response_has_tool_calls(monkeypatch):
|
|
1556
|
+
registry.reset()
|
|
1557
|
+
assistant = Agent(
|
|
1558
|
+
NodeConfig(node_type=NodeType.ASSISTANT, tools=["idle"]),
|
|
1559
|
+
)
|
|
1560
|
+
registry.register(assistant)
|
|
1561
|
+
events = []
|
|
1562
|
+
|
|
1563
|
+
def fake_wait_for_input() -> None:
|
|
1564
|
+
assistant._append_history(
|
|
1565
|
+
ReceivedMessage(content="report progress", from_id="human")
|
|
1566
|
+
)
|
|
1567
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
1568
|
+
|
|
1569
|
+
def fake_chat(
|
|
1570
|
+
messages,
|
|
1571
|
+
tools=None,
|
|
1572
|
+
on_chunk=None,
|
|
1573
|
+
register_interrupt=None,
|
|
1574
|
+
role_name=None,
|
|
1575
|
+
):
|
|
1576
|
+
if on_chunk is not None:
|
|
1577
|
+
on_chunk("content", "Working on it")
|
|
1578
|
+
return LLMResponse(
|
|
1579
|
+
content="Working on it",
|
|
1580
|
+
tool_calls=[
|
|
1581
|
+
ToolCallResult(
|
|
1582
|
+
id="call-idle",
|
|
1583
|
+
name="idle",
|
|
1584
|
+
arguments={},
|
|
1585
|
+
)
|
|
1586
|
+
],
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
1590
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1591
|
+
monkeypatch.setattr(
|
|
1592
|
+
assistant,
|
|
1593
|
+
"_handle_tool_call",
|
|
1594
|
+
lambda name, arguments, call_id: assistant.request_termination("done"),
|
|
1595
|
+
)
|
|
1596
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1597
|
+
|
|
1598
|
+
assistant._run()
|
|
1599
|
+
|
|
1600
|
+
assistant_events = [
|
|
1601
|
+
event for event in events if event.type == EventType.ASSISTANT_CONTENT
|
|
1602
|
+
]
|
|
1603
|
+
assert [event.data for event in assistant_events] == [{"content": "Working on it"}]
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def test_send_message_delivers_to_single_contact_and_records_histories(monkeypatch):
|
|
1607
|
+
registry.reset()
|
|
1608
|
+
leader = _register_tab_leader()
|
|
1609
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1610
|
+
registry.register(child)
|
|
1611
|
+
events = []
|
|
1612
|
+
|
|
1613
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1614
|
+
|
|
1615
|
+
try:
|
|
1616
|
+
result = json.loads(
|
|
1617
|
+
child.send_message(
|
|
1618
|
+
target_ref="leader",
|
|
1619
|
+
raw_parts=[{"type": "text", "text": "investigate the error"}],
|
|
1620
|
+
)
|
|
1621
|
+
)
|
|
1622
|
+
finally:
|
|
1623
|
+
registry.reset()
|
|
1624
|
+
|
|
1625
|
+
sent_entry = next(
|
|
1626
|
+
entry
|
|
1627
|
+
for entry in child.get_history_snapshot()
|
|
1628
|
+
if isinstance(entry, SentMessage)
|
|
1629
|
+
)
|
|
1630
|
+
received_entry = next(
|
|
1631
|
+
entry
|
|
1632
|
+
for entry in leader.get_history_snapshot()
|
|
1633
|
+
if isinstance(entry, ReceivedMessage)
|
|
1634
|
+
)
|
|
1635
|
+
signal = leader._wake_queue.get_nowait()
|
|
1636
|
+
|
|
1637
|
+
assert result == {"status": "sent", "target_id": "leader"}
|
|
1638
|
+
assert sent_entry.to_id == "leader"
|
|
1639
|
+
assert sent_entry.content == "investigate the error"
|
|
1640
|
+
assert received_entry.from_id == "child"
|
|
1641
|
+
assert received_entry.content == "investigate the error"
|
|
1642
|
+
assert sent_entry.message_id == received_entry.message_id
|
|
1643
|
+
assert signal.payload == {
|
|
1644
|
+
"message": {
|
|
1645
|
+
"from": "child",
|
|
1646
|
+
"content": "investigate the error",
|
|
1647
|
+
"parts": [{"type": "text", "text": "investigate the error"}],
|
|
1648
|
+
"history_recorded": True,
|
|
1649
|
+
"message_id": sent_entry.message_id,
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
assert [event.data for event in events if event.type == EventType.NODE_MESSAGE] == [
|
|
1653
|
+
{
|
|
1654
|
+
"to_id": "leader",
|
|
1655
|
+
"content": "investigate the error",
|
|
1656
|
+
"message_id": sent_entry.message_id,
|
|
1657
|
+
}
|
|
1658
|
+
]
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def test_send_message_reports_error_when_target_is_not_in_contacts():
|
|
1662
|
+
registry.reset()
|
|
1663
|
+
_register_tab_leader()
|
|
1664
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1665
|
+
peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
|
|
1666
|
+
registry.register(child)
|
|
1667
|
+
registry.register(peer)
|
|
1668
|
+
|
|
1669
|
+
try:
|
|
1670
|
+
with pytest.raises(
|
|
1671
|
+
ValueError,
|
|
1672
|
+
match=r"Send failed: target `peer` is not in contacts\.",
|
|
1673
|
+
):
|
|
1674
|
+
child.send_message(
|
|
1675
|
+
target_ref="peer",
|
|
1676
|
+
raw_parts=[{"type": "text", "text": "reply with the findings"}],
|
|
1677
|
+
)
|
|
1678
|
+
finally:
|
|
1679
|
+
registry.reset()
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def test_send_message_validates_target_before_image_capability():
|
|
1683
|
+
registry.reset()
|
|
1684
|
+
_register_tab_leader()
|
|
1685
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1686
|
+
peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
|
|
1687
|
+
registry.register(child)
|
|
1688
|
+
registry.register(peer)
|
|
1689
|
+
|
|
1690
|
+
try:
|
|
1691
|
+
with pytest.raises(
|
|
1692
|
+
ValueError,
|
|
1693
|
+
match=r"Send failed: target `peer` is not in contacts\.",
|
|
1694
|
+
):
|
|
1695
|
+
child.send_message(
|
|
1696
|
+
target_ref="peer",
|
|
1697
|
+
raw_parts=[{"type": "image", "asset_id": "asset-1"}],
|
|
1698
|
+
)
|
|
1699
|
+
finally:
|
|
1700
|
+
registry.reset()
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def test_send_message_reports_error_when_target_lacks_input_image_support():
|
|
1704
|
+
registry.reset()
|
|
1705
|
+
_register_tab_leader()
|
|
1706
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1707
|
+
registry.register(child)
|
|
1708
|
+
|
|
1709
|
+
try:
|
|
1710
|
+
with pytest.raises(
|
|
1711
|
+
ValueError,
|
|
1712
|
+
match=r"Send failed: target `leader` does not support `input_image`\.",
|
|
1713
|
+
):
|
|
1714
|
+
child.send_message(
|
|
1715
|
+
target_ref="leader",
|
|
1716
|
+
raw_parts=[{"type": "image", "asset_id": "asset-1"}],
|
|
1717
|
+
)
|
|
1718
|
+
finally:
|
|
1719
|
+
registry.reset()
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def test_record_content_output_treats_target_like_text_as_plain_output(monkeypatch):
|
|
1723
|
+
registry.reset()
|
|
1724
|
+
assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1725
|
+
registry.register(assistant)
|
|
1726
|
+
events = []
|
|
1727
|
+
|
|
1728
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
1729
|
+
|
|
1730
|
+
try:
|
|
1731
|
+
assistant._record_content_output(
|
|
1732
|
+
"@worker: do the follow-up task",
|
|
1733
|
+
emitted_human_content=False,
|
|
1734
|
+
)
|
|
1735
|
+
finally:
|
|
1736
|
+
registry.reset()
|
|
1737
|
+
|
|
1738
|
+
history = assistant.get_history_snapshot()
|
|
1739
|
+
assert isinstance(history[-1], AssistantText)
|
|
1740
|
+
assert history[-1].content == "@worker: do the follow-up task"
|
|
1741
|
+
assert not any(isinstance(entry, SentMessage) for entry in history)
|
|
1742
|
+
assert any(
|
|
1743
|
+
event.type == EventType.ASSISTANT_CONTENT
|
|
1744
|
+
and event.data == {"content": "@worker: do the follow-up task"}
|
|
1745
|
+
for event in events
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def test_handle_tool_call_send_success_omits_toolcall_history(monkeypatch):
|
|
1750
|
+
registry.reset()
|
|
1751
|
+
_register_tab_leader()
|
|
1752
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1753
|
+
registry.register(child)
|
|
1754
|
+
|
|
1755
|
+
try:
|
|
1756
|
+
result = child._handle_tool_call(
|
|
1757
|
+
"send",
|
|
1758
|
+
{
|
|
1759
|
+
"target": "leader",
|
|
1760
|
+
"parts": [{"type": "text", "text": "reply with the findings"}],
|
|
1761
|
+
},
|
|
1762
|
+
"call-send",
|
|
1763
|
+
)
|
|
1764
|
+
finally:
|
|
1765
|
+
registry.reset()
|
|
1766
|
+
|
|
1767
|
+
assert json.loads(result) == {"status": "sent", "target_id": "leader"}
|
|
1768
|
+
assert not any(
|
|
1769
|
+
isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
|
|
1770
|
+
for entry in child.get_history_snapshot()
|
|
1771
|
+
)
|
|
1772
|
+
assert any(isinstance(entry, SentMessage) for entry in child.get_history_snapshot())
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def test_handle_tool_call_send_failure_records_error_without_toolcall():
|
|
1776
|
+
registry.reset()
|
|
1777
|
+
_register_tab_leader()
|
|
1778
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1779
|
+
peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
|
|
1780
|
+
registry.register(child)
|
|
1781
|
+
registry.register(peer)
|
|
1782
|
+
|
|
1783
|
+
try:
|
|
1784
|
+
result = child._handle_tool_call(
|
|
1785
|
+
"send",
|
|
1786
|
+
{
|
|
1787
|
+
"target": "peer",
|
|
1788
|
+
"parts": [{"type": "text", "text": "reply with the findings"}],
|
|
1789
|
+
},
|
|
1790
|
+
"call-send",
|
|
1791
|
+
)
|
|
1792
|
+
finally:
|
|
1793
|
+
registry.reset()
|
|
1794
|
+
|
|
1795
|
+
assert json.loads(result) == {
|
|
1796
|
+
"error": "Send failed: target `peer` is not in contacts."
|
|
1797
|
+
}
|
|
1798
|
+
assert not any(
|
|
1799
|
+
isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
|
|
1800
|
+
for entry in child.get_history_snapshot()
|
|
1801
|
+
)
|
|
1802
|
+
assert any(
|
|
1803
|
+
isinstance(entry, ErrorEntry)
|
|
1804
|
+
and entry.content == "Send failed: target `peer` is not in contacts."
|
|
1805
|
+
for entry in child.get_history_snapshot()
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def test_multiple_send_tool_calls_stop_after_first_failure(monkeypatch):
|
|
1810
|
+
registry.reset()
|
|
1811
|
+
leader = _register_tab_leader()
|
|
1812
|
+
child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
|
|
1813
|
+
helper = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="helper")
|
|
1814
|
+
registry.register(child)
|
|
1815
|
+
registry.register(helper)
|
|
1816
|
+
|
|
1817
|
+
wait_calls = 0
|
|
1818
|
+
chat_calls = 0
|
|
1819
|
+
|
|
1820
|
+
def fake_wait_for_input() -> None:
|
|
1821
|
+
nonlocal wait_calls
|
|
1822
|
+
wait_calls += 1
|
|
1823
|
+
if wait_calls == 1:
|
|
1824
|
+
child._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
1825
|
+
child.set_state(AgentState.RUNNING, "received message from human")
|
|
1826
|
+
return
|
|
1827
|
+
child.request_termination("done")
|
|
1828
|
+
|
|
1829
|
+
def fake_chat(
|
|
1830
|
+
messages,
|
|
1831
|
+
tools=None,
|
|
1832
|
+
on_chunk=None,
|
|
1833
|
+
register_interrupt=None,
|
|
1834
|
+
role_name=None,
|
|
1835
|
+
):
|
|
1836
|
+
nonlocal chat_calls
|
|
1837
|
+
chat_calls += 1
|
|
1838
|
+
if chat_calls == 1:
|
|
1839
|
+
return LLMResponse(
|
|
1840
|
+
tool_calls=[
|
|
1841
|
+
ToolCallResult(
|
|
1842
|
+
id="call-send-1",
|
|
1843
|
+
name="send",
|
|
1844
|
+
arguments={
|
|
1845
|
+
"target": "leader",
|
|
1846
|
+
"parts": [{"type": "text", "text": "first"}],
|
|
1847
|
+
},
|
|
1848
|
+
),
|
|
1849
|
+
ToolCallResult(
|
|
1850
|
+
id="call-send-2",
|
|
1851
|
+
name="send",
|
|
1852
|
+
arguments={
|
|
1853
|
+
"target": "helper",
|
|
1854
|
+
"parts": [{"type": "text", "text": "second"}],
|
|
1855
|
+
},
|
|
1856
|
+
),
|
|
1857
|
+
ToolCallResult(
|
|
1858
|
+
id="call-send-3",
|
|
1859
|
+
name="send",
|
|
1860
|
+
arguments={
|
|
1861
|
+
"target": "leader",
|
|
1862
|
+
"parts": [{"type": "text", "text": "third"}],
|
|
1863
|
+
},
|
|
1864
|
+
),
|
|
1865
|
+
]
|
|
1866
|
+
)
|
|
1867
|
+
child.request_termination("done")
|
|
1868
|
+
return LLMResponse()
|
|
1869
|
+
|
|
1870
|
+
monkeypatch.setattr(child, "_wait_for_input", fake_wait_for_input)
|
|
1871
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
1872
|
+
|
|
1873
|
+
try:
|
|
1874
|
+
child._run()
|
|
1875
|
+
finally:
|
|
1876
|
+
registry.reset()
|
|
1877
|
+
|
|
1878
|
+
sent_entries = [
|
|
1879
|
+
entry
|
|
1880
|
+
for entry in child.get_history_snapshot()
|
|
1881
|
+
if isinstance(entry, SentMessage)
|
|
1882
|
+
]
|
|
1883
|
+
error_entries = [
|
|
1884
|
+
entry for entry in child.get_history_snapshot() if isinstance(entry, ErrorEntry)
|
|
1885
|
+
]
|
|
1886
|
+
|
|
1887
|
+
assert [entry.content for entry in sent_entries] == ["first"]
|
|
1888
|
+
assert [
|
|
1889
|
+
entry.content
|
|
1890
|
+
for entry in leader.get_history_snapshot()
|
|
1891
|
+
if isinstance(entry, ReceivedMessage)
|
|
1892
|
+
] == ["first"]
|
|
1893
|
+
assert helper._wake_queue.empty()
|
|
1894
|
+
assert any(
|
|
1895
|
+
entry.content == "Send failed: target `helper` is not in contacts."
|
|
1896
|
+
for entry in error_entries
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def test_build_messages_replays_sent_messages_as_message_to_context(monkeypatch):
|
|
1901
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
1902
|
+
|
|
1903
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
1904
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
1905
|
+
agent._append_history(SentMessage(content="to peer", to_id="peer"))
|
|
1906
|
+
agent._append_history(AssistantText(content="final answer"))
|
|
1907
|
+
|
|
1908
|
+
messages = agent._build_messages()
|
|
1909
|
+
|
|
1910
|
+
assert messages == [
|
|
1911
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
1912
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
1913
|
+
{"role": "assistant", "content": '<message to="peer">to peer</message>'},
|
|
1914
|
+
{"role": "assistant", "content": "final answer"},
|
|
1915
|
+
{
|
|
1916
|
+
"role": "user",
|
|
1917
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
|
|
1918
|
+
},
|
|
1919
|
+
]
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
def test_context_preflight_prefers_usage_baseline_and_estimates_only_new_tail(
|
|
1923
|
+
monkeypatch,
|
|
1924
|
+
):
|
|
1925
|
+
monkeypatch.setattr(
|
|
1926
|
+
"flowent.agent.get_settings",
|
|
1927
|
+
lambda: Settings(
|
|
1928
|
+
providers=[
|
|
1929
|
+
ProviderConfig(
|
|
1930
|
+
id="provider-1",
|
|
1931
|
+
name="Primary",
|
|
1932
|
+
type="openai_responses",
|
|
1933
|
+
base_url="https://api.example.com/v1",
|
|
1934
|
+
api_key="secret",
|
|
1935
|
+
)
|
|
1936
|
+
],
|
|
1937
|
+
model=ModelSettings(
|
|
1938
|
+
active_provider_id="provider-1",
|
|
1939
|
+
active_model="gpt-5.2",
|
|
1940
|
+
auto_compact_token_limit=48_000,
|
|
1941
|
+
),
|
|
1942
|
+
),
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
1946
|
+
agent._append_history(ReceivedMessage(content="first", from_id="human"))
|
|
1947
|
+
|
|
1948
|
+
baseline_context = agent._build_prepared_llm_context()
|
|
1949
|
+
agent._record_context_token_usage_baseline(
|
|
1950
|
+
prepared_context=baseline_context,
|
|
1951
|
+
usage=LLMUsage(
|
|
1952
|
+
total_tokens=4_200,
|
|
1953
|
+
input_tokens=3_000,
|
|
1954
|
+
output_tokens=1_200,
|
|
1955
|
+
),
|
|
1956
|
+
)
|
|
1957
|
+
|
|
1958
|
+
agent._append_history(
|
|
1959
|
+
ToolCall(
|
|
1960
|
+
tool_name="read",
|
|
1961
|
+
tool_call_id="call-read",
|
|
1962
|
+
arguments={"path": "README.md"},
|
|
1963
|
+
result="done",
|
|
1964
|
+
)
|
|
1965
|
+
)
|
|
1966
|
+
next_context = agent._build_prepared_llm_context()
|
|
1967
|
+
preflight = agent._compute_context_preflight(next_context)
|
|
1968
|
+
|
|
1969
|
+
expected_tail_tokens = agent._estimate_input_tokens(
|
|
1970
|
+
next_context.execution_context_messages[
|
|
1971
|
+
len(baseline_context.execution_context_messages) :
|
|
1972
|
+
]
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1975
|
+
assert preflight.estimated_total_tokens == 4_200 + expected_tail_tokens
|
|
1976
|
+
assert preflight.auto_compact_token_limit == 48_000
|
|
1977
|
+
assert preflight.context_window_tokens == 128_000
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def test_context_preflight_bootstraps_again_when_runtime_tail_changes(monkeypatch):
|
|
1981
|
+
monkeypatch.setattr(
|
|
1982
|
+
"flowent.agent.get_settings",
|
|
1983
|
+
lambda: Settings(
|
|
1984
|
+
providers=[
|
|
1985
|
+
ProviderConfig(
|
|
1986
|
+
id="provider-1",
|
|
1987
|
+
name="Primary",
|
|
1988
|
+
type="openai_responses",
|
|
1989
|
+
base_url="https://api.example.com/v1",
|
|
1990
|
+
api_key="secret",
|
|
1991
|
+
)
|
|
1992
|
+
],
|
|
1993
|
+
model=ModelSettings(
|
|
1994
|
+
active_provider_id="provider-1",
|
|
1995
|
+
active_model="gpt-5.2",
|
|
1996
|
+
auto_compact_token_limit=48_000,
|
|
1997
|
+
),
|
|
1998
|
+
),
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
2002
|
+
agent._append_history(ReceivedMessage(content="first", from_id="human"))
|
|
2003
|
+
|
|
2004
|
+
baseline_context = agent._build_prepared_llm_context()
|
|
2005
|
+
agent._record_context_token_usage_baseline(
|
|
2006
|
+
prepared_context=baseline_context,
|
|
2007
|
+
usage=LLMUsage(total_tokens=4_200),
|
|
2008
|
+
)
|
|
2009
|
+
|
|
2010
|
+
agent.set_todos([TodoItem(text="Inspect files")])
|
|
2011
|
+
next_context = agent._build_prepared_llm_context()
|
|
2012
|
+
preflight = agent._compute_context_preflight(next_context)
|
|
2013
|
+
|
|
2014
|
+
assert preflight.estimated_total_tokens == agent._estimate_input_tokens(
|
|
2015
|
+
next_context.messages
|
|
2016
|
+
)
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def test_prepare_messages_for_llm_uses_token_limit_even_without_context_window(
|
|
2020
|
+
monkeypatch,
|
|
2021
|
+
):
|
|
2022
|
+
monkeypatch.setattr(
|
|
2023
|
+
"flowent.agent.get_settings",
|
|
2024
|
+
lambda: Settings(
|
|
2025
|
+
providers=[
|
|
2026
|
+
ProviderConfig(
|
|
2027
|
+
id="provider-1",
|
|
2028
|
+
name="Primary",
|
|
2029
|
+
type="openai_compatible",
|
|
2030
|
+
base_url="https://api.example.com/v1",
|
|
2031
|
+
api_key="secret",
|
|
2032
|
+
)
|
|
2033
|
+
],
|
|
2034
|
+
model=ModelSettings(
|
|
2035
|
+
active_provider_id="provider-1",
|
|
2036
|
+
active_model="custom-model",
|
|
2037
|
+
auto_compact_token_limit=1,
|
|
2038
|
+
),
|
|
2039
|
+
),
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
2043
|
+
agent._append_history(ReceivedMessage(content="hello", from_id="human"))
|
|
2044
|
+
compact_calls: list[str] = []
|
|
2045
|
+
|
|
2046
|
+
monkeypatch.setattr(
|
|
2047
|
+
agent,
|
|
2048
|
+
"_compact_execution_context",
|
|
2049
|
+
lambda focus=None: compact_calls.append("compact") or "",
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
prepared_context = agent._prepare_messages_for_llm()
|
|
2053
|
+
|
|
2054
|
+
assert compact_calls == ["compact"]
|
|
2055
|
+
assert len(prepared_context.messages) > 0
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def test_idle_is_blocked_when_fresh_input_has_no_progress(monkeypatch):
|
|
2059
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2060
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
2061
|
+
agent.set_state(AgentState.RUNNING, "processing")
|
|
2062
|
+
agent._turn_started_with_pending_input = True
|
|
2063
|
+
agent._turn_made_progress = False
|
|
2064
|
+
|
|
2065
|
+
idle_result = agent.request_idle()
|
|
2066
|
+
messages = agent._build_messages()
|
|
2067
|
+
|
|
2068
|
+
reminder = (
|
|
2069
|
+
"<system>Idle reminder: you received a new message this turn, but this "
|
|
2070
|
+
"response did not send a reply, call `send`, or use any non-idle "
|
|
2071
|
+
"tool. Do not call `idle` yet. First reply to the Human, dispatch/"
|
|
2072
|
+
"delegate work, or take another concrete step.</system>"
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
assert idle_result == ""
|
|
2076
|
+
assert agent.state == AgentState.RUNNING
|
|
2077
|
+
assert any(msg.get("content") == reminder for msg in messages)
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def test_idle_is_blocked_when_first_todo_is_actionable(monkeypatch):
|
|
2081
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2082
|
+
agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
|
|
2083
|
+
agent.set_state(AgentState.RUNNING, "processing")
|
|
2084
|
+
agent.set_todos(
|
|
2085
|
+
[
|
|
2086
|
+
TodoItem(text="Forward pyproject summary to Project Synthesizer"),
|
|
2087
|
+
TodoItem(text="Wait for final synthesis"),
|
|
2088
|
+
]
|
|
2089
|
+
)
|
|
2090
|
+
|
|
2091
|
+
idle_result = agent.request_idle()
|
|
2092
|
+
messages = agent._build_messages()
|
|
2093
|
+
|
|
2094
|
+
reminder = (
|
|
2095
|
+
"<system>Idle reminder: your first remaining TODO still looks actionable "
|
|
2096
|
+
"(`Forward pyproject summary to Project Synthesizer`). Do that next, or "
|
|
2097
|
+
"update the TODO list so the first remaining item is the actual waiting "
|
|
2098
|
+
"step, before calling `idle`.</system>"
|
|
2099
|
+
)
|
|
2100
|
+
|
|
2101
|
+
assert idle_result == ""
|
|
2102
|
+
assert agent.state == AgentState.RUNNING
|
|
2103
|
+
assert any(msg.get("content") == reminder for msg in messages)
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def test_build_messages_appends_runtime_todo_context_without_history_entry(monkeypatch):
|
|
2107
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2108
|
+
|
|
2109
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2110
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2111
|
+
agent.set_todos([TodoItem(text="Inspect files"), TodoItem(text="Report results")])
|
|
2112
|
+
|
|
2113
|
+
messages = agent._build_messages()
|
|
2114
|
+
history = agent.get_history_snapshot()
|
|
2115
|
+
|
|
2116
|
+
received_entries = [
|
|
2117
|
+
entry for entry in history if isinstance(entry, ReceivedMessage)
|
|
2118
|
+
]
|
|
2119
|
+
assert len(received_entries) == 1
|
|
2120
|
+
assert messages == [
|
|
2121
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
2122
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
2123
|
+
{
|
|
2124
|
+
"role": "user",
|
|
2125
|
+
"content": "<system>Current TODO list:\n - Inspect files\n - Report results</system>",
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
"role": "user",
|
|
2129
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items.</system>",
|
|
2130
|
+
},
|
|
2131
|
+
]
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
def test_build_messages_appends_runtime_post_prompt_and_idle_guidance(monkeypatch):
|
|
2135
|
+
monkeypatch.setattr(
|
|
2136
|
+
"flowent.agent.get_settings",
|
|
2137
|
+
lambda: Settings(custom_post_prompt="Append this after history."),
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2141
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2142
|
+
agent.set_todos([TodoItem(text="Inspect files")])
|
|
2143
|
+
agent.set_todos([])
|
|
2144
|
+
|
|
2145
|
+
messages = agent._build_messages()
|
|
2146
|
+
history = agent.get_history_snapshot()
|
|
2147
|
+
|
|
2148
|
+
received_entries = [
|
|
2149
|
+
entry for entry in history if isinstance(entry, ReceivedMessage)
|
|
2150
|
+
]
|
|
2151
|
+
assert len(received_entries) == 1
|
|
2152
|
+
assert messages == [
|
|
2153
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
2154
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
2155
|
+
{
|
|
2156
|
+
"role": "user",
|
|
2157
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
"role": "user",
|
|
2161
|
+
"content": "<system>Append this after history.</system>",
|
|
2162
|
+
},
|
|
2163
|
+
]
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
def test_build_messages_warns_about_newly_created_agents_waiting_for_first_task(
|
|
2167
|
+
monkeypatch,
|
|
2168
|
+
):
|
|
2169
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2170
|
+
|
|
2171
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2172
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2173
|
+
agent._append_history(
|
|
2174
|
+
ToolCall(
|
|
2175
|
+
tool_name="create_agent",
|
|
2176
|
+
tool_call_id="call-create-agent",
|
|
2177
|
+
arguments={
|
|
2178
|
+
"role_name": "Worker",
|
|
2179
|
+
"name": "Directory Worker",
|
|
2180
|
+
},
|
|
2181
|
+
result=json.dumps(
|
|
2182
|
+
{
|
|
2183
|
+
"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
|
|
2184
|
+
"config": {
|
|
2185
|
+
"node_type": "agent",
|
|
2186
|
+
"role_name": "Worker",
|
|
2187
|
+
"tab_id": "tab-1",
|
|
2188
|
+
"name": "Directory Worker",
|
|
2189
|
+
"tools": ["idle", "sleep", "todo", "contacts", "read"],
|
|
2190
|
+
"write_dirs": [],
|
|
2191
|
+
"allow_network": False,
|
|
2192
|
+
},
|
|
2193
|
+
"state": "initializing",
|
|
2194
|
+
"todos": [],
|
|
2195
|
+
"history": [],
|
|
2196
|
+
"position": None,
|
|
2197
|
+
"created_at": 1.0,
|
|
2198
|
+
"updated_at": 1.0,
|
|
2199
|
+
}
|
|
2200
|
+
),
|
|
2201
|
+
)
|
|
2202
|
+
)
|
|
2203
|
+
|
|
2204
|
+
messages = agent._build_messages()
|
|
2205
|
+
|
|
2206
|
+
assert messages == [
|
|
2207
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
2208
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
2209
|
+
{
|
|
2210
|
+
"role": "assistant",
|
|
2211
|
+
"tool_calls": [
|
|
2212
|
+
{
|
|
2213
|
+
"id": "call-create-agent",
|
|
2214
|
+
"type": "function",
|
|
2215
|
+
"function": {
|
|
2216
|
+
"name": "create_agent",
|
|
2217
|
+
"arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
|
|
2218
|
+
},
|
|
2219
|
+
}
|
|
2220
|
+
],
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
"role": "tool",
|
|
2224
|
+
"tool_call_id": "call-create-agent",
|
|
2225
|
+
"content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
"role": "user",
|
|
2229
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Directory Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
|
|
2230
|
+
},
|
|
2231
|
+
]
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def test_build_messages_uses_role_name_when_created_agent_has_no_explicit_name(
|
|
2235
|
+
monkeypatch,
|
|
2236
|
+
):
|
|
2237
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2238
|
+
|
|
2239
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2240
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2241
|
+
agent._append_history(
|
|
2242
|
+
ToolCall(
|
|
2243
|
+
tool_name="create_agent",
|
|
2244
|
+
tool_call_id="call-create-agent",
|
|
2245
|
+
arguments={"role_name": "Worker"},
|
|
2246
|
+
result=json.dumps(
|
|
2247
|
+
{
|
|
2248
|
+
"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
|
|
2249
|
+
"config": {
|
|
2250
|
+
"node_type": "agent",
|
|
2251
|
+
"role_name": "Worker",
|
|
2252
|
+
"tab_id": "tab-1",
|
|
2253
|
+
"name": None,
|
|
2254
|
+
"tools": ["idle", "sleep", "todo", "contacts", "read"],
|
|
2255
|
+
"write_dirs": [],
|
|
2256
|
+
"allow_network": False,
|
|
2257
|
+
},
|
|
2258
|
+
"state": "initializing",
|
|
2259
|
+
"todos": [],
|
|
2260
|
+
"history": [],
|
|
2261
|
+
"position": None,
|
|
2262
|
+
"created_at": 1.0,
|
|
2263
|
+
"updated_at": 1.0,
|
|
2264
|
+
}
|
|
2265
|
+
),
|
|
2266
|
+
)
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
messages = agent._build_messages()
|
|
2270
|
+
|
|
2271
|
+
assert messages == [
|
|
2272
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
2273
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
2274
|
+
{
|
|
2275
|
+
"role": "assistant",
|
|
2276
|
+
"tool_calls": [
|
|
2277
|
+
{
|
|
2278
|
+
"id": "call-create-agent",
|
|
2279
|
+
"type": "function",
|
|
2280
|
+
"function": {
|
|
2281
|
+
"name": "create_agent",
|
|
2282
|
+
"arguments": '{"role_name": "Worker"}',
|
|
2283
|
+
},
|
|
2284
|
+
}
|
|
2285
|
+
],
|
|
2286
|
+
},
|
|
2287
|
+
{
|
|
2288
|
+
"role": "tool",
|
|
2289
|
+
"tool_call_id": "call-create-agent",
|
|
2290
|
+
"content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": null, "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
|
|
2291
|
+
},
|
|
2292
|
+
{
|
|
2293
|
+
"role": "user",
|
|
2294
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
|
|
2295
|
+
},
|
|
2296
|
+
]
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def test_build_messages_clears_new_agent_warning_after_first_sent_message(monkeypatch):
|
|
2300
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2301
|
+
|
|
2302
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2303
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2304
|
+
agent._append_history(
|
|
2305
|
+
ToolCall(
|
|
2306
|
+
tool_name="create_agent",
|
|
2307
|
+
tool_call_id="call-create-agent",
|
|
2308
|
+
arguments={
|
|
2309
|
+
"role_name": "Worker",
|
|
2310
|
+
"name": "Directory Worker",
|
|
2311
|
+
},
|
|
2312
|
+
result=json.dumps(
|
|
2313
|
+
{
|
|
2314
|
+
"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
|
|
2315
|
+
"config": {
|
|
2316
|
+
"node_type": "agent",
|
|
2317
|
+
"role_name": "Worker",
|
|
2318
|
+
"tab_id": "tab-1",
|
|
2319
|
+
"name": "Directory Worker",
|
|
2320
|
+
"tools": ["idle", "sleep", "todo", "contacts", "read"],
|
|
2321
|
+
"write_dirs": [],
|
|
2322
|
+
"allow_network": False,
|
|
2323
|
+
},
|
|
2324
|
+
"state": "initializing",
|
|
2325
|
+
"todos": [],
|
|
2326
|
+
"history": [],
|
|
2327
|
+
"position": None,
|
|
2328
|
+
"created_at": 1.0,
|
|
2329
|
+
"updated_at": 1.0,
|
|
2330
|
+
}
|
|
2331
|
+
),
|
|
2332
|
+
)
|
|
2333
|
+
)
|
|
2334
|
+
agent._append_history(
|
|
2335
|
+
SentMessage(
|
|
2336
|
+
content="inspect the current directory",
|
|
2337
|
+
to_id="12345678-aaaa-bbbb-cccc-ddddeeeeffff",
|
|
2338
|
+
)
|
|
2339
|
+
)
|
|
2340
|
+
|
|
2341
|
+
messages = agent._build_messages()
|
|
2342
|
+
|
|
2343
|
+
assert messages == [
|
|
2344
|
+
{"role": "system", "content": messages[0]["content"]},
|
|
2345
|
+
{"role": "user", "content": '<message from="human">begin</message>'},
|
|
2346
|
+
{
|
|
2347
|
+
"role": "assistant",
|
|
2348
|
+
"tool_calls": [
|
|
2349
|
+
{
|
|
2350
|
+
"id": "call-create-agent",
|
|
2351
|
+
"type": "function",
|
|
2352
|
+
"function": {
|
|
2353
|
+
"name": "create_agent",
|
|
2354
|
+
"arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
|
|
2355
|
+
},
|
|
2356
|
+
}
|
|
2357
|
+
],
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
"role": "tool",
|
|
2361
|
+
"tool_call_id": "call-create-agent",
|
|
2362
|
+
"content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
"role": "assistant",
|
|
2366
|
+
"content": '<message to="12345678-aaaa-bbbb-cccc-ddddeeeeffff">inspect the current directory</message>',
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
"role": "user",
|
|
2370
|
+
"content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
|
|
2371
|
+
},
|
|
2372
|
+
]
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
def test_build_messages_keeps_sleep_tool_results_in_context(monkeypatch):
|
|
2376
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2377
|
+
|
|
2378
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2379
|
+
agent._append_history(
|
|
2380
|
+
ReceivedMessage(content="pause before continuing", from_id="human")
|
|
2381
|
+
)
|
|
2382
|
+
agent._append_history(
|
|
2383
|
+
ToolCall(
|
|
2384
|
+
tool_name="sleep",
|
|
2385
|
+
tool_call_id="call-sleep",
|
|
2386
|
+
arguments={"seconds": 0.5},
|
|
2387
|
+
result="slept 0.50s",
|
|
2388
|
+
)
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
messages = agent._build_messages()
|
|
2392
|
+
|
|
2393
|
+
assert any(
|
|
2394
|
+
msg.get("role") == "assistant"
|
|
2395
|
+
and msg.get("tool_calls")
|
|
2396
|
+
== [
|
|
2397
|
+
{
|
|
2398
|
+
"id": "call-sleep",
|
|
2399
|
+
"type": "function",
|
|
2400
|
+
"function": {
|
|
2401
|
+
"name": "sleep",
|
|
2402
|
+
"arguments": '{"seconds": 0.5}',
|
|
2403
|
+
},
|
|
2404
|
+
}
|
|
2405
|
+
]
|
|
2406
|
+
for msg in messages
|
|
2407
|
+
)
|
|
2408
|
+
assert any(
|
|
2409
|
+
msg.get("role") == "tool"
|
|
2410
|
+
and msg.get("tool_call_id") == "call-sleep"
|
|
2411
|
+
and msg.get("content") == "slept 0.50s"
|
|
2412
|
+
for msg in messages
|
|
2413
|
+
)
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def test_build_messages_keeps_idle_tool_results_in_context(monkeypatch):
|
|
2417
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2418
|
+
|
|
2419
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2420
|
+
agent._append_history(ReceivedMessage(content="resume after wait", from_id="human"))
|
|
2421
|
+
agent._append_history(
|
|
2422
|
+
ToolCall(
|
|
2423
|
+
tool_name="idle",
|
|
2424
|
+
tool_call_id="call-idle",
|
|
2425
|
+
arguments={},
|
|
2426
|
+
result="idle 1.25s",
|
|
2427
|
+
)
|
|
2428
|
+
)
|
|
2429
|
+
|
|
2430
|
+
messages = agent._build_messages()
|
|
2431
|
+
|
|
2432
|
+
assert any(
|
|
2433
|
+
msg.get("role") == "assistant"
|
|
2434
|
+
and msg.get("tool_calls")
|
|
2435
|
+
== [
|
|
2436
|
+
{
|
|
2437
|
+
"id": "call-idle",
|
|
2438
|
+
"type": "function",
|
|
2439
|
+
"function": {
|
|
2440
|
+
"name": "idle",
|
|
2441
|
+
"arguments": "{}",
|
|
2442
|
+
},
|
|
2443
|
+
}
|
|
2444
|
+
]
|
|
2445
|
+
for msg in messages
|
|
2446
|
+
)
|
|
2447
|
+
assert any(
|
|
2448
|
+
msg.get("role") == "tool"
|
|
2449
|
+
and msg.get("tool_call_id") == "call-idle"
|
|
2450
|
+
and msg.get("content") == "idle 1.25s"
|
|
2451
|
+
for msg in messages
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
def test_build_messages_keeps_error_entries_in_context(monkeypatch):
|
|
2456
|
+
monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
|
|
2457
|
+
|
|
2458
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
|
|
2459
|
+
agent._append_history(ReceivedMessage(content="begin", from_id="human"))
|
|
2460
|
+
agent._append_history(ErrorEntry(content="RuntimeError: boom\n\ntraceback"))
|
|
2461
|
+
|
|
2462
|
+
messages = agent._build_messages()
|
|
2463
|
+
|
|
2464
|
+
assert any(
|
|
2465
|
+
msg.get("role") == "user"
|
|
2466
|
+
and msg.get("content")
|
|
2467
|
+
== "<system>Previous runtime error:\nRuntimeError: boom\n\ntraceback</system>"
|
|
2468
|
+
for msg in messages
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def test_assistant_emits_human_content_for_plain_text_with_target_like_prefix(
|
|
2473
|
+
monkeypatch,
|
|
2474
|
+
):
|
|
2475
|
+
registry.reset()
|
|
2476
|
+
_register_tab_leader()
|
|
2477
|
+
assistant = Agent(
|
|
2478
|
+
NodeConfig(node_type=NodeType.ASSISTANT),
|
|
2479
|
+
uuid="assistant",
|
|
2480
|
+
)
|
|
2481
|
+
registry.register(assistant)
|
|
2482
|
+
events = []
|
|
2483
|
+
responses = iter(
|
|
2484
|
+
[LLMResponse(content="@leader: investigate the error"), LLMResponse()]
|
|
2485
|
+
)
|
|
2486
|
+
|
|
2487
|
+
def fake_wait_for_input() -> None:
|
|
2488
|
+
assistant._append_history(
|
|
2489
|
+
ReceivedMessage(content="please investigate", from_id="human")
|
|
2490
|
+
)
|
|
2491
|
+
assistant.set_state(AgentState.RUNNING, "received message from human")
|
|
2492
|
+
|
|
2493
|
+
def fake_chat(
|
|
2494
|
+
messages,
|
|
2495
|
+
tools=None,
|
|
2496
|
+
on_chunk=None,
|
|
2497
|
+
register_interrupt=None,
|
|
2498
|
+
role_name=None,
|
|
2499
|
+
):
|
|
2500
|
+
response = next(responses)
|
|
2501
|
+
if response.content and on_chunk is not None:
|
|
2502
|
+
on_chunk("content", response.content)
|
|
2503
|
+
if response.content is None:
|
|
2504
|
+
assistant.request_termination("done")
|
|
2505
|
+
return response
|
|
2506
|
+
|
|
2507
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
2508
|
+
monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
|
|
2509
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
2510
|
+
|
|
2511
|
+
try:
|
|
2512
|
+
assistant._run()
|
|
2513
|
+
finally:
|
|
2514
|
+
registry.reset()
|
|
2515
|
+
|
|
2516
|
+
assert any(event.type == EventType.ASSISTANT_CONTENT for event in events)
|
|
2517
|
+
assert not any(
|
|
2518
|
+
isinstance(entry, SentMessage) for entry in assistant.get_history_snapshot()
|
|
2519
|
+
)
|
|
2520
|
+
assert any(
|
|
2521
|
+
isinstance(entry, AssistantText)
|
|
2522
|
+
and entry.content == "@leader: investigate the error"
|
|
2523
|
+
for entry in assistant.get_history_snapshot()
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
def test_idle_tool_records_wakeup_message_as_new_input_block(monkeypatch):
|
|
2528
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT, tools=["idle"]))
|
|
2529
|
+
wait_calls = 0
|
|
2530
|
+
llm_messages: list[list[dict]] = []
|
|
2531
|
+
responses = iter(
|
|
2532
|
+
[
|
|
2533
|
+
LLMResponse(
|
|
2534
|
+
tool_calls=[
|
|
2535
|
+
ToolCallResult(
|
|
2536
|
+
id="call-idle",
|
|
2537
|
+
name="idle",
|
|
2538
|
+
arguments={},
|
|
2539
|
+
)
|
|
2540
|
+
]
|
|
2541
|
+
),
|
|
2542
|
+
LLMResponse(),
|
|
2543
|
+
]
|
|
2544
|
+
)
|
|
2545
|
+
|
|
2546
|
+
def fake_wait_for_input() -> None:
|
|
2547
|
+
nonlocal wait_calls
|
|
2548
|
+
wait_calls += 1
|
|
2549
|
+
agent._append_history(
|
|
2550
|
+
ReceivedMessage(content="start waiting", from_id="tester")
|
|
2551
|
+
)
|
|
2552
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
2553
|
+
|
|
2554
|
+
def fake_chat(
|
|
2555
|
+
messages,
|
|
2556
|
+
tools=None,
|
|
2557
|
+
on_chunk=None,
|
|
2558
|
+
register_interrupt=None,
|
|
2559
|
+
role_name=None,
|
|
2560
|
+
):
|
|
2561
|
+
llm_messages.append(messages)
|
|
2562
|
+
if len(llm_messages) == 1:
|
|
2563
|
+
timer = threading.Timer(
|
|
2564
|
+
0.01,
|
|
2565
|
+
lambda: agent.enqueue_message(
|
|
2566
|
+
Message(
|
|
2567
|
+
from_id="human",
|
|
2568
|
+
to_id=agent.uuid,
|
|
2569
|
+
content="wake up now",
|
|
2570
|
+
)
|
|
2571
|
+
),
|
|
2572
|
+
)
|
|
2573
|
+
timer.start()
|
|
2574
|
+
if len(llm_messages) == 2:
|
|
2575
|
+
agent.request_termination("done")
|
|
2576
|
+
return next(responses)
|
|
2577
|
+
|
|
2578
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
2579
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
2580
|
+
|
|
2581
|
+
agent._run()
|
|
2582
|
+
|
|
2583
|
+
assert wait_calls == 1
|
|
2584
|
+
assert agent.state == AgentState.TERMINATED
|
|
2585
|
+
second_round = llm_messages[1]
|
|
2586
|
+
assert any(
|
|
2587
|
+
msg.get("role") == "assistant"
|
|
2588
|
+
and msg.get("tool_calls")
|
|
2589
|
+
== [
|
|
2590
|
+
{
|
|
2591
|
+
"id": "call-idle",
|
|
2592
|
+
"type": "function",
|
|
2593
|
+
"function": {
|
|
2594
|
+
"name": "idle",
|
|
2595
|
+
"arguments": "{}",
|
|
2596
|
+
},
|
|
2597
|
+
}
|
|
2598
|
+
]
|
|
2599
|
+
for msg in second_round
|
|
2600
|
+
)
|
|
2601
|
+
assert any(
|
|
2602
|
+
msg.get("role") == "tool"
|
|
2603
|
+
and msg.get("tool_call_id") == "call-idle"
|
|
2604
|
+
and isinstance(msg.get("content"), str)
|
|
2605
|
+
and msg.get("content", "").startswith("idle ")
|
|
2606
|
+
for msg in second_round
|
|
2607
|
+
)
|
|
2608
|
+
assert any(
|
|
2609
|
+
msg.get("role") == "user"
|
|
2610
|
+
and msg.get("content") == '<message from="human">wake up now</message>'
|
|
2611
|
+
for msg in second_round
|
|
2612
|
+
)
|
|
2613
|
+
assert any(
|
|
2614
|
+
isinstance(entry, ToolCall)
|
|
2615
|
+
and entry.tool_name == "idle"
|
|
2616
|
+
and isinstance(entry.result, str)
|
|
2617
|
+
and entry.result.startswith("idle ")
|
|
2618
|
+
for entry in agent.get_history_snapshot()
|
|
2619
|
+
)
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
def test_agent_contextualizes_plain_loguru_calls(monkeypatch):
|
|
2623
|
+
agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-z")
|
|
2624
|
+
captured: list[tuple[str, str | None]] = []
|
|
2625
|
+
sink_id = logger.add(
|
|
2626
|
+
lambda message: captured.append(
|
|
2627
|
+
(message.record["message"], message.record["extra"].get("agent_id"))
|
|
2628
|
+
)
|
|
2629
|
+
)
|
|
2630
|
+
|
|
2631
|
+
def fake_wait_for_input() -> None:
|
|
2632
|
+
agent._append_history(ReceivedMessage(content="do the task", from_id="tester"))
|
|
2633
|
+
agent.set_state(AgentState.RUNNING, "received message from tester")
|
|
2634
|
+
|
|
2635
|
+
def fake_chat(
|
|
2636
|
+
messages,
|
|
2637
|
+
tools=None,
|
|
2638
|
+
on_chunk=None,
|
|
2639
|
+
register_interrupt=None,
|
|
2640
|
+
role_name=None,
|
|
2641
|
+
):
|
|
2642
|
+
logger.info("plain log inside agent")
|
|
2643
|
+
agent.request_termination("done")
|
|
2644
|
+
return LLMResponse()
|
|
2645
|
+
|
|
2646
|
+
monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
|
|
2647
|
+
monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
|
|
2648
|
+
|
|
2649
|
+
try:
|
|
2650
|
+
agent._run()
|
|
2651
|
+
finally:
|
|
2652
|
+
logger.remove(sink_id)
|
|
2653
|
+
|
|
2654
|
+
assert ("plain log inside agent", "agent-z") in captured
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
def test_agent_denies_tool_call_before_edit_execute(monkeypatch, tmp_path):
|
|
2658
|
+
agent = Agent(
|
|
2659
|
+
NodeConfig(node_type=NodeType.AGENT, tools=["edit"]),
|
|
2660
|
+
uuid="agent-security",
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
def fail_execute(*_args, **_kwargs):
|
|
2664
|
+
raise AssertionError("edit execute should not be called")
|
|
2665
|
+
|
|
2666
|
+
monkeypatch.setattr("flowent.tools.edit.EditTool.execute", fail_execute)
|
|
2667
|
+
|
|
2668
|
+
result = agent._handle_tool_call(
|
|
2669
|
+
"edit",
|
|
2670
|
+
{
|
|
2671
|
+
"path": str(tmp_path / "blocked.txt"),
|
|
2672
|
+
"edits": [
|
|
2673
|
+
{
|
|
2674
|
+
"start_line": 1,
|
|
2675
|
+
"end_line": 1,
|
|
2676
|
+
"new_content": "hello\n",
|
|
2677
|
+
}
|
|
2678
|
+
],
|
|
2679
|
+
},
|
|
2680
|
+
"call-edit",
|
|
2681
|
+
)
|
|
2682
|
+
|
|
2683
|
+
assert result == json.dumps({"error": "Write access is disabled for this agent"})
|
|
2684
|
+
assert isinstance(agent.history[-1], ToolCall)
|
|
2685
|
+
assert agent.history[-1].result == result
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
def test_handle_tool_call_emits_streaming_tool_result_deltas(monkeypatch):
|
|
2689
|
+
agent = Agent(
|
|
2690
|
+
NodeConfig(node_type=NodeType.AGENT, tools=["streaming_tool"]),
|
|
2691
|
+
uuid="agent-stream",
|
|
2692
|
+
)
|
|
2693
|
+
events = []
|
|
2694
|
+
|
|
2695
|
+
class FakeTool:
|
|
2696
|
+
def execute(self, agent, args, **kwargs):
|
|
2697
|
+
on_output = kwargs.get("on_output")
|
|
2698
|
+
assert on_output is not None
|
|
2699
|
+
on_output("chunk 1\n")
|
|
2700
|
+
on_output("chunk 2\n")
|
|
2701
|
+
return json.dumps({"status": "done"})
|
|
2702
|
+
|
|
2703
|
+
class FakeRegistry:
|
|
2704
|
+
def get(self, name):
|
|
2705
|
+
if name == "streaming_tool":
|
|
2706
|
+
return FakeTool()
|
|
2707
|
+
return None
|
|
2708
|
+
|
|
2709
|
+
monkeypatch.setattr("flowent.agent._get_tool_registry", lambda: FakeRegistry())
|
|
2710
|
+
monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
|
|
2711
|
+
|
|
2712
|
+
result = agent._handle_tool_call("streaming_tool", {}, "call-stream")
|
|
2713
|
+
|
|
2714
|
+
assert result == json.dumps({"status": "done"})
|
|
2715
|
+
assert isinstance(agent.history[-1], ToolCall)
|
|
2716
|
+
assert agent.history[-1].tool_call_id == "call-stream"
|
|
2717
|
+
assert agent.history[-1].result == result
|
|
2718
|
+
assert agent.history[-1].streaming is False
|
|
2719
|
+
assert [
|
|
2720
|
+
event.data["text"]
|
|
2721
|
+
for event in events
|
|
2722
|
+
if event.type == EventType.HISTORY_ENTRY_DELTA
|
|
2723
|
+
] == [
|
|
2724
|
+
"chunk 1\n",
|
|
2725
|
+
"chunk 2\n",
|
|
2726
|
+
]
|