flowent 0.0.1 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/backend/.python-version +1 -0
- package/backend/README.md +74 -0
- package/backend/pyproject.toml +58 -0
- package/backend/src/flowent/__init__.py +3 -0
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/observability_service.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
- package/backend/src/flowent/_version.py +7 -0
- package/backend/src/flowent/access.py +247 -0
- package/backend/src/flowent/agent.py +3120 -0
- package/backend/src/flowent/assistant_commands.py +115 -0
- package/backend/src/flowent/channels/__init__.py +3 -0
- package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
- package/backend/src/flowent/channels/telegram.py +615 -0
- package/backend/src/flowent/cli.py +85 -0
- package/backend/src/flowent/config.py +14 -0
- package/backend/src/flowent/dev.py +3 -0
- package/backend/src/flowent/events.py +157 -0
- package/backend/src/flowent/graph_runtime.py +60 -0
- package/backend/src/flowent/graph_service.py +2508 -0
- package/backend/src/flowent/image_assets.py +356 -0
- package/backend/src/flowent/logging.py +155 -0
- package/backend/src/flowent/main.py +124 -0
- package/backend/src/flowent/mcp_service.py +1918 -0
- package/backend/src/flowent/model_metadata.py +102 -0
- package/backend/src/flowent/models/__init__.py +125 -0
- package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
- package/backend/src/flowent/models/agent.py +34 -0
- package/backend/src/flowent/models/base.py +24 -0
- package/backend/src/flowent/models/blueprint.py +176 -0
- package/backend/src/flowent/models/content.py +164 -0
- package/backend/src/flowent/models/delta.py +44 -0
- package/backend/src/flowent/models/event.py +51 -0
- package/backend/src/flowent/models/graph.py +472 -0
- package/backend/src/flowent/models/history.py +272 -0
- package/backend/src/flowent/models/llm.py +62 -0
- package/backend/src/flowent/models/message.py +33 -0
- package/backend/src/flowent/models/tab.py +85 -0
- package/backend/src/flowent/models/todo.py +10 -0
- package/backend/src/flowent/network.py +146 -0
- package/backend/src/flowent/observability_service.py +218 -0
- package/backend/src/flowent/prompts/__init__.py +67 -0
- package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
- package/backend/src/flowent/prompts/common.py +250 -0
- package/backend/src/flowent/prompts/steward.py +64 -0
- package/backend/src/flowent/providers/__init__.py +23 -0
- package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
- package/backend/src/flowent/providers/anthropic.py +468 -0
- package/backend/src/flowent/providers/base_url.py +60 -0
- package/backend/src/flowent/providers/configuration.py +189 -0
- package/backend/src/flowent/providers/content.py +122 -0
- package/backend/src/flowent/providers/errors.py +223 -0
- package/backend/src/flowent/providers/gateway.py +169 -0
- package/backend/src/flowent/providers/gemini.py +447 -0
- package/backend/src/flowent/providers/headers.py +20 -0
- package/backend/src/flowent/providers/management.py +96 -0
- package/backend/src/flowent/providers/ollama.py +293 -0
- package/backend/src/flowent/providers/openai.py +422 -0
- package/backend/src/flowent/providers/openai_responses.py +655 -0
- package/backend/src/flowent/providers/registry.py +144 -0
- package/backend/src/flowent/providers/sse.py +31 -0
- package/backend/src/flowent/providers/thinking.py +79 -0
- package/backend/src/flowent/registry.py +73 -0
- package/backend/src/flowent/role_management.py +267 -0
- package/backend/src/flowent/routes/__init__.py +28 -0
- package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
- package/backend/src/flowent/routes/access.py +48 -0
- package/backend/src/flowent/routes/assistant.py +155 -0
- package/backend/src/flowent/routes/image_assets.py +33 -0
- package/backend/src/flowent/routes/mcp.py +125 -0
- package/backend/src/flowent/routes/meta.py +28 -0
- package/backend/src/flowent/routes/nodes.py +413 -0
- package/backend/src/flowent/routes/prompts.py +46 -0
- package/backend/src/flowent/routes/providers_route.py +365 -0
- package/backend/src/flowent/routes/roles.py +207 -0
- package/backend/src/flowent/routes/settings.py +328 -0
- package/backend/src/flowent/routes/tabs.py +310 -0
- package/backend/src/flowent/routes/ws.py +33 -0
- package/backend/src/flowent/runtime.py +165 -0
- package/backend/src/flowent/sandbox.py +45 -0
- package/backend/src/flowent/security.py +57 -0
- package/backend/src/flowent/settings.py +2518 -0
- package/backend/src/flowent/settings_management.py +298 -0
- package/backend/src/flowent/state_db.py +120 -0
- package/backend/src/flowent/static/assets/AssistantPage-VBohhz4d.js +1 -0
- package/backend/src/flowent/static/assets/ChannelsPage-CIydPZA_.js +1 -0
- package/backend/src/flowent/static/assets/McpPage-CHPm2TPY.js +7 -0
- package/backend/src/flowent/static/assets/PageScaffold-DteOA8V7.js +1 -0
- package/backend/src/flowent/static/assets/PromptsPage-CSmJ3sZg.js +1 -0
- package/backend/src/flowent/static/assets/ProvidersPage-sl2jeG4e.js +3 -0
- package/backend/src/flowent/static/assets/RolesPage-DCe7W6Km.js +1 -0
- package/backend/src/flowent/static/assets/SettingsPage-Bix9e63E.js +3 -0
- package/backend/src/flowent/static/assets/ToolsPage-favNkj5C.js +1 -0
- package/backend/src/flowent/static/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
- package/backend/src/flowent/static/assets/WorkspacePage-KuaDjt_D.js +3 -0
- package/backend/src/flowent/static/assets/WorkspacePanels-BZxBw8M5.js +1 -0
- package/backend/src/flowent/static/assets/alert-dialog-DIBUCmqM.js +1 -0
- package/backend/src/flowent/static/assets/datetime-eJqd0V2S.js +1 -0
- package/backend/src/flowent/static/assets/dialog-BOvHIBrg.js +1 -0
- package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
- package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
- package/backend/src/flowent/static/assets/index-Biio-CoI.js +10 -0
- package/backend/src/flowent/static/assets/index-CmQvO7sl.css +1 -0
- package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
- package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +29 -0
- package/backend/src/flowent/static/assets/modelParams-DcEhGnu0.js +1 -0
- package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
- package/backend/src/flowent/static/assets/roles-BbIEIMeG.js +1 -0
- package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
- package/backend/src/flowent/static/assets/select-D9SwnlXF.js +1 -0
- package/backend/src/flowent/static/assets/surface-Bzr1FRG4.js +1 -0
- package/backend/src/flowent/static/assets/triState-DgLlKdRR.js +1 -0
- package/backend/src/flowent/static/assets/ui-vendor-UazN8rcv.js +51 -0
- package/backend/src/flowent/static/index.html +35 -0
- package/backend/src/flowent/tools/__init__.py +275 -0
- package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
- package/backend/src/flowent/tools/connect.py +100 -0
- package/backend/src/flowent/tools/contacts.py +22 -0
- package/backend/src/flowent/tools/create_agent.py +191 -0
- package/backend/src/flowent/tools/create_tab.py +61 -0
- package/backend/src/flowent/tools/delete_tab.py +39 -0
- package/backend/src/flowent/tools/edit.py +142 -0
- package/backend/src/flowent/tools/exec.py +118 -0
- package/backend/src/flowent/tools/fetch.py +85 -0
- package/backend/src/flowent/tools/idle.py +27 -0
- package/backend/src/flowent/tools/list_roles.py +75 -0
- package/backend/src/flowent/tools/list_tabs.py +100 -0
- package/backend/src/flowent/tools/list_tools.py +28 -0
- package/backend/src/flowent/tools/manage_prompts.py +102 -0
- package/backend/src/flowent/tools/manage_providers.py +220 -0
- package/backend/src/flowent/tools/manage_roles.py +275 -0
- package/backend/src/flowent/tools/manage_settings.py +364 -0
- package/backend/src/flowent/tools/mcp.py +199 -0
- package/backend/src/flowent/tools/read.py +152 -0
- package/backend/src/flowent/tools/send.py +68 -0
- package/backend/src/flowent/tools/set_permissions.py +99 -0
- package/backend/src/flowent/tools/sleep.py +41 -0
- package/backend/src/flowent/tools/todo.py +51 -0
- package/backend/src/flowent/workspace_store.py +479 -0
- package/backend/tests/__init__.py +0 -0
- package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/conftest.py +6 -0
- package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/integration/api/conftest.py +29 -0
- package/backend/tests/integration/api/test_access_api.py +182 -0
- package/backend/tests/integration/api/test_assistant_api.py +354 -0
- package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
- package/backend/tests/integration/api/test_mcp_api.py +116 -0
- package/backend/tests/integration/api/test_meta_api.py +33 -0
- package/backend/tests/integration/api/test_nodes_api.py +722 -0
- package/backend/tests/integration/api/test_prompts_api.py +47 -0
- package/backend/tests/integration/api/test_roles_api.py +228 -0
- package/backend/tests/integration/api/test_tabs_api.py +802 -0
- package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/agent/test_agent_public_api.py +837 -0
- package/backend/tests/unit/agent/test_agent_runtime.py +2942 -0
- package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
- package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/logging/test_logging.py +132 -0
- package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/prompts/test_prompts.py +570 -0
- package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
- package/backend/tests/unit/providers/test_errors.py +68 -0
- package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
- package/backend/tests/unit/providers/test_openai_provider.py +139 -0
- package/backend/tests/unit/providers/test_openai_responses.py +402 -0
- package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
- package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
- package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
- package/backend/tests/unit/routes/test_providers_route.py +370 -0
- package/backend/tests/unit/routes/test_roles_routes.py +535 -0
- package/backend/tests/unit/routes/test_settings_routes.py +1142 -0
- package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1002 -0
- package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
- package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/security/test_security.py +124 -0
- package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/settings/test_settings_roles.py +751 -0
- package/backend/tests/unit/test_access.py +45 -0
- package/backend/tests/unit/test_cli.py +124 -0
- package/backend/tests/unit/test_graph_runtime.py +72 -0
- package/backend/tests/unit/test_network.py +51 -0
- package/backend/tests/unit/test_state_sqlite_storage.py +159 -0
- package/backend/tests/unit/test_workspace_store.py +231 -0
- package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/unit/tools/test_connect_tool.py +228 -0
- package/backend/tests/unit/tools/test_create_agent_tool.py +436 -0
- package/backend/tests/unit/tools/test_delete_tab_tool.py +116 -0
- package/backend/tests/unit/tools/test_edit_tool.py +115 -0
- package/backend/tests/unit/tools/test_exec_tool.py +81 -0
- package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
- package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
- package/backend/tests/unit/tools/test_manage_providers_tool.py +460 -0
- package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
- package/backend/tests/unit/tools/test_manage_settings_tool.py +611 -0
- package/backend/tests/unit/tools/test_read_tool.py +33 -0
- package/backend/tests/unit/tools/test_set_permissions_tool.py +595 -0
- package/backend/tests/unit/tools/test_todo_tool.py +37 -0
- package/backend/tests/unit/tools/test_tool_registry.py +194 -0
- package/backend/uv.lock +1144 -0
- package/bin/flowent.mjs +62 -36
- package/dist/frontend/assets/AssistantPage-VBohhz4d.js +1 -0
- package/dist/frontend/assets/ChannelsPage-CIydPZA_.js +1 -0
- package/dist/frontend/assets/McpPage-CHPm2TPY.js +7 -0
- package/dist/frontend/assets/PageScaffold-DteOA8V7.js +1 -0
- package/dist/frontend/assets/PromptsPage-CSmJ3sZg.js +1 -0
- package/dist/frontend/assets/ProvidersPage-sl2jeG4e.js +3 -0
- package/dist/frontend/assets/RolesPage-DCe7W6Km.js +1 -0
- package/dist/frontend/assets/SettingsPage-Bix9e63E.js +3 -0
- package/dist/frontend/assets/ToolsPage-favNkj5C.js +1 -0
- package/dist/frontend/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
- package/dist/frontend/assets/WorkspacePage-KuaDjt_D.js +3 -0
- package/dist/frontend/assets/WorkspacePanels-BZxBw8M5.js +1 -0
- package/dist/frontend/assets/alert-dialog-DIBUCmqM.js +1 -0
- package/dist/frontend/assets/datetime-eJqd0V2S.js +1 -0
- package/dist/frontend/assets/dialog-BOvHIBrg.js +1 -0
- package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
- package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
- package/dist/frontend/assets/index-Biio-CoI.js +10 -0
- package/dist/frontend/assets/index-CmQvO7sl.css +1 -0
- package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
- package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +29 -0
- package/dist/frontend/assets/modelParams-DcEhGnu0.js +1 -0
- package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
- package/dist/frontend/assets/roles-BbIEIMeG.js +1 -0
- package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
- package/dist/frontend/assets/select-D9SwnlXF.js +1 -0
- package/dist/frontend/assets/surface-Bzr1FRG4.js +1 -0
- package/dist/frontend/assets/triState-DgLlKdRR.js +1 -0
- package/dist/frontend/assets/ui-vendor-UazN8rcv.js +51 -0
- package/dist/frontend/index.html +35 -0
- package/package.json +27 -41
- package/dist/.next/BUILD_ID +0 -1
- package/dist/.next/app-path-routes-manifest.json +0 -6
- package/dist/.next/build-manifest.json +0 -20
- package/dist/.next/package.json +0 -1
- package/dist/.next/prerender-manifest.json +0 -114
- package/dist/.next/required-server-files.json +0 -333
- package/dist/.next/routes-manifest.json +0 -69
- package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
- package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
- package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/_global-error/page.js +0 -9
- package/dist/.next/server/app/_global-error/page.js.map +0 -5
- package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
- package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app/_global-error.html +0 -1
- package/dist/.next/server/app/_global-error.meta +0 -15
- package/dist/.next/server/app/_global-error.rsc +0 -14
- package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
- package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
- package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
- package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
- package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/_not-found/page.js +0 -13
- package/dist/.next/server/app/_not-found/page.js.map +0 -5
- package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
- package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app/_not-found.html +0 -1
- package/dist/.next/server/app/_not-found.meta +0 -16
- package/dist/.next/server/app/_not-found.rsc +0 -16
- package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
- package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
- package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
- package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
- package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
- package/dist/.next/server/app/icon.svg/route.js +0 -6
- package/dist/.next/server/app/icon.svg/route.js.map +0 -5
- package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
- package/dist/.next/server/app/icon.svg.meta +0 -1
- package/dist/.next/server/app/index.html +0 -1
- package/dist/.next/server/app/index.meta +0 -14
- package/dist/.next/server/app/index.rsc +0 -15
- package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
- package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
- package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
- package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
- package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
- package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
- package/dist/.next/server/app/page/build-manifest.json +0 -16
- package/dist/.next/server/app/page/next-font-manifest.json +0 -10
- package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
- package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
- package/dist/.next/server/app/page.js +0 -14
- package/dist/.next/server/app/page.js.map +0 -5
- package/dist/.next/server/app/page.js.nft.json +0 -1
- package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
- package/dist/.next/server/app-paths-manifest.json +0 -6
- package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
- package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
- package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
- package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
- package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
- package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
- package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
- package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
- package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
- package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
- package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
- package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
- package/dist/.next/server/functions-config-manifest.json +0 -4
- package/dist/.next/server/middleware-build-manifest.js +0 -20
- package/dist/.next/server/middleware-manifest.json +0 -6
- package/dist/.next/server/next-font-manifest.js +0 -1
- package/dist/.next/server/next-font-manifest.json +0 -13
- package/dist/.next/server/pages/404.html +0 -1
- package/dist/.next/server/pages/500.html +0 -1
- package/dist/.next/server/pages-manifest.json +0 -4
- package/dist/.next/server/prefetch-hints.json +0 -1
- package/dist/.next/server/server-reference-manifest.js +0 -1
- package/dist/.next/server/server-reference-manifest.json +0 -5
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
- package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
- package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
- package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
- package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
- package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
- package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
- package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
- package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
- package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
- package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
- package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
- package/dist/package.json +0 -88
- package/dist/server.js +0 -38
- /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
- /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
|
@@ -0,0 +1,3120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time as _time
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid as _uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from functools import lru_cache, partial
|
|
11
|
+
from queue import Empty, Queue
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from flowent.assistant_commands import build_conversation_help_text
|
|
18
|
+
from flowent.events import event_bus
|
|
19
|
+
from flowent.image_assets import create_image_asset, require_image_asset
|
|
20
|
+
from flowent.models import (
|
|
21
|
+
AgentState,
|
|
22
|
+
AssistantText,
|
|
23
|
+
AssistantThinking,
|
|
24
|
+
CommandResultEntry,
|
|
25
|
+
ContentDelta,
|
|
26
|
+
ErrorEntry,
|
|
27
|
+
Event,
|
|
28
|
+
EventType,
|
|
29
|
+
HistoryEntry,
|
|
30
|
+
ImagePart,
|
|
31
|
+
LLMOutputImagePart,
|
|
32
|
+
LLMOutputTextPart,
|
|
33
|
+
LLMResponse,
|
|
34
|
+
LLMUsage,
|
|
35
|
+
Message,
|
|
36
|
+
ModelInfo,
|
|
37
|
+
NodeConfig,
|
|
38
|
+
NodeType,
|
|
39
|
+
PortInboundEntry,
|
|
40
|
+
ReceivedMessage,
|
|
41
|
+
SentMessage,
|
|
42
|
+
SystemEntry,
|
|
43
|
+
TextPart,
|
|
44
|
+
ThinkingDelta,
|
|
45
|
+
TodoItem,
|
|
46
|
+
ToolCall,
|
|
47
|
+
ToolResultDelta,
|
|
48
|
+
WorkflowActivationState,
|
|
49
|
+
WorkflowNodeKind,
|
|
50
|
+
content_parts_to_text,
|
|
51
|
+
deserialize_content_parts,
|
|
52
|
+
has_image_parts,
|
|
53
|
+
parse_content_parts_payload,
|
|
54
|
+
)
|
|
55
|
+
from flowent.prompts import get_system_prompt
|
|
56
|
+
from flowent.providers.errors import LLMProviderError
|
|
57
|
+
from flowent.providers.gateway import gateway
|
|
58
|
+
from flowent.providers.thinking import ThinkTagParser, split_thinking_content
|
|
59
|
+
from flowent.security import authorize
|
|
60
|
+
from flowent.settings import get_settings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@lru_cache(maxsize=1)
|
|
64
|
+
def _get_tool_registry() -> Any:
|
|
65
|
+
from flowent.tools import build_tool_registry
|
|
66
|
+
|
|
67
|
+
return build_tool_registry()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class WakeSignal:
|
|
72
|
+
reason: str
|
|
73
|
+
payload: dict[str, Any]
|
|
74
|
+
resume_reason: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class StreamingContentState:
|
|
79
|
+
content_buffer: str = ""
|
|
80
|
+
thinking_buffer: str = ""
|
|
81
|
+
saw_content_chunks: bool = False
|
|
82
|
+
think_parser: ThinkTagParser = field(default_factory=ThinkTagParser)
|
|
83
|
+
emitted_human_content: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ContextPreflight:
|
|
88
|
+
estimated_total_tokens: int
|
|
89
|
+
context_window_tokens: int | None = None
|
|
90
|
+
auto_compact_token_limit: int | None = None
|
|
91
|
+
safe_input_tokens: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InterruptRequestedError(Exception):
|
|
95
|
+
def __init__(self, stream_state: StreamingContentState | None = None) -> None:
|
|
96
|
+
super().__init__("interrupt requested")
|
|
97
|
+
self.stream_state = stream_state
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ContextPreflightError(RuntimeError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS = 1024
|
|
105
|
+
DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS = 1024
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class PreparedLLMContext:
|
|
110
|
+
messages: list[dict[str, Any]]
|
|
111
|
+
system_messages: list[dict[str, Any]]
|
|
112
|
+
execution_context_messages: list[dict[str, Any]]
|
|
113
|
+
runtime_tail_messages: list[dict[str, Any]]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class ContextTokenUsageBaseline:
|
|
118
|
+
usage: LLMUsage
|
|
119
|
+
system_messages: list[dict[str, Any]]
|
|
120
|
+
execution_context_messages: list[dict[str, Any]]
|
|
121
|
+
runtime_tail_messages: list[dict[str, Any]]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ResolvedModelSource:
|
|
126
|
+
provider_id: str | None
|
|
127
|
+
provider_name: str | None
|
|
128
|
+
provider_type: str | None
|
|
129
|
+
provider_base_url: str | None
|
|
130
|
+
model: str | None
|
|
131
|
+
model_info: ModelInfo | None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_error_context(content: str) -> str:
|
|
135
|
+
return f"<system>Previous runtime error:\n{content}</system>"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Agent:
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
config: NodeConfig,
|
|
142
|
+
uuid: str | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self.uuid = uuid or str(_uuid.uuid4())
|
|
145
|
+
self.config = config
|
|
146
|
+
self.node_type = config.node_type
|
|
147
|
+
self.role_name = config.role_name
|
|
148
|
+
self.state = AgentState.INITIALIZING
|
|
149
|
+
self.todos: list[TodoItem] = []
|
|
150
|
+
self.connections: list[str] = []
|
|
151
|
+
self.history: list[HistoryEntry] = []
|
|
152
|
+
self._terminate = threading.Event()
|
|
153
|
+
self._interrupt_requested = threading.Event()
|
|
154
|
+
self._interrupt_callback_lock = threading.Lock()
|
|
155
|
+
self._interrupt_callback: Callable[[], None] | None = None
|
|
156
|
+
self._command_interrupt_lock = threading.Lock()
|
|
157
|
+
self._pause_after_interrupt_requested = threading.Event()
|
|
158
|
+
self._paused_for_command = threading.Event()
|
|
159
|
+
self._resume_after_command = threading.Event()
|
|
160
|
+
self._idle_state_event = threading.Event()
|
|
161
|
+
self._idle_started_at: float | None = None
|
|
162
|
+
self._idle_started_by_tool_call_id: str | None = None
|
|
163
|
+
self._wake_queue: Queue[WakeSignal] = Queue()
|
|
164
|
+
self._thread: threading.Thread | None = None
|
|
165
|
+
self._termination_reason: str = ""
|
|
166
|
+
self._preserve_workspace_state_on_exit = False
|
|
167
|
+
self._connections_lock = threading.Lock()
|
|
168
|
+
self._history_lock = threading.Lock()
|
|
169
|
+
self._todos_lock = threading.Lock()
|
|
170
|
+
self._runtime_notice_lock = threading.Lock()
|
|
171
|
+
self._execution_context_lock = threading.Lock()
|
|
172
|
+
self._execution_context_summary: str = ""
|
|
173
|
+
self._execution_context_history_cutoff: int = 0
|
|
174
|
+
self._context_token_usage_baseline: ContextTokenUsageBaseline | None = None
|
|
175
|
+
self._pending_runtime_notices: list[str] = []
|
|
176
|
+
self._pending_input_turn = False
|
|
177
|
+
self._turn_started_with_pending_input = False
|
|
178
|
+
self._turn_made_progress = False
|
|
179
|
+
self._log = logger.bind(
|
|
180
|
+
agent_id=self.uuid[:8], node_type=self.config.node_type.value
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _persist_workspace_node(self) -> None:
|
|
184
|
+
if self.node_type != NodeType.ASSISTANT and not self.config.tab_id:
|
|
185
|
+
return
|
|
186
|
+
from flowent.models import GraphNodeRecord
|
|
187
|
+
from flowent.workspace_store import workspace_store
|
|
188
|
+
|
|
189
|
+
existing = workspace_store.get_node_record(self.uuid)
|
|
190
|
+
record = GraphNodeRecord(
|
|
191
|
+
id=self.uuid,
|
|
192
|
+
config=self.config,
|
|
193
|
+
state=self.state,
|
|
194
|
+
todos=self.get_todos_snapshot(),
|
|
195
|
+
history=self.get_history_snapshot(),
|
|
196
|
+
execution_context_summary=self.get_execution_context_summary(),
|
|
197
|
+
execution_context_history_cutoff=self.get_execution_context_history_cutoff(),
|
|
198
|
+
position=existing.position if existing is not None else None,
|
|
199
|
+
created_at=existing.created_at if existing is not None else _time.time(),
|
|
200
|
+
updated_at=_time.time(),
|
|
201
|
+
)
|
|
202
|
+
workspace_store.upsert_node_record(record)
|
|
203
|
+
|
|
204
|
+
def add_connection(self, other_uuid: str) -> None:
|
|
205
|
+
with self._connections_lock:
|
|
206
|
+
if other_uuid not in self.connections:
|
|
207
|
+
self.connections.append(other_uuid)
|
|
208
|
+
|
|
209
|
+
def remove_connection(self, other_uuid: str) -> None:
|
|
210
|
+
with self._connections_lock:
|
|
211
|
+
if other_uuid in self.connections:
|
|
212
|
+
self.connections.remove(other_uuid)
|
|
213
|
+
|
|
214
|
+
def is_connected_to(self, uuid: str) -> bool:
|
|
215
|
+
with self._connections_lock:
|
|
216
|
+
return uuid in self.connections
|
|
217
|
+
|
|
218
|
+
def get_connections_snapshot(self) -> list[str]:
|
|
219
|
+
with self._connections_lock:
|
|
220
|
+
return list(self.connections)
|
|
221
|
+
|
|
222
|
+
def get_history_snapshot(self) -> list[HistoryEntry]:
|
|
223
|
+
with self._history_lock:
|
|
224
|
+
return list(self.history)
|
|
225
|
+
|
|
226
|
+
def get_todos_snapshot(self) -> list[TodoItem]:
|
|
227
|
+
with self._todos_lock:
|
|
228
|
+
return [TodoItem(text=t.text) for t in self.todos]
|
|
229
|
+
|
|
230
|
+
def get_execution_context_summary(self) -> str:
|
|
231
|
+
with self._execution_context_lock:
|
|
232
|
+
return self._execution_context_summary
|
|
233
|
+
|
|
234
|
+
def get_execution_context_history_cutoff(self) -> int:
|
|
235
|
+
with self._execution_context_lock:
|
|
236
|
+
return self._execution_context_history_cutoff
|
|
237
|
+
|
|
238
|
+
def _set_execution_context(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
summary: str,
|
|
242
|
+
history_cutoff: int,
|
|
243
|
+
) -> None:
|
|
244
|
+
with self._execution_context_lock:
|
|
245
|
+
self._execution_context_summary = summary
|
|
246
|
+
self._execution_context_history_cutoff = max(history_cutoff, 0)
|
|
247
|
+
self._context_token_usage_baseline = None
|
|
248
|
+
|
|
249
|
+
def _reset_execution_context(self) -> None:
|
|
250
|
+
self._set_execution_context(summary="", history_cutoff=0)
|
|
251
|
+
|
|
252
|
+
def prime_runtime_state(self, state: AgentState) -> None:
|
|
253
|
+
self.state = state
|
|
254
|
+
if state == AgentState.IDLE:
|
|
255
|
+
self._idle_started_at = _time.perf_counter()
|
|
256
|
+
self._idle_state_event.set()
|
|
257
|
+
else:
|
|
258
|
+
self._idle_started_at = None
|
|
259
|
+
self._idle_started_by_tool_call_id = None
|
|
260
|
+
self._idle_state_event.clear()
|
|
261
|
+
|
|
262
|
+
def set_todos(self, todos: list[TodoItem]) -> None:
|
|
263
|
+
with self._todos_lock:
|
|
264
|
+
self.todos = [TodoItem(text=t.text) for t in todos]
|
|
265
|
+
self._persist_workspace_node()
|
|
266
|
+
|
|
267
|
+
def request_idle(self, *, tool_call_id: str | None = None) -> str:
|
|
268
|
+
if self._has_pending_runtime_notices():
|
|
269
|
+
self._log.debug("Skipping idle because runtime notice is pending")
|
|
270
|
+
return ""
|
|
271
|
+
if (
|
|
272
|
+
self.node_type == NodeType.ASSISTANT
|
|
273
|
+
and self._turn_started_with_pending_input
|
|
274
|
+
and not self._turn_made_progress
|
|
275
|
+
):
|
|
276
|
+
self._queue_runtime_notice(self._build_idle_without_progress_notice())
|
|
277
|
+
self._log.debug("Skipping idle because fresh input is still unhandled")
|
|
278
|
+
return ""
|
|
279
|
+
actionable_todo = self._get_first_actionable_todo()
|
|
280
|
+
if actionable_todo is not None:
|
|
281
|
+
self._queue_runtime_notice(
|
|
282
|
+
self._build_actionable_todo_notice(actionable_todo)
|
|
283
|
+
)
|
|
284
|
+
self._log.debug("Skipping idle because TODO is still actionable")
|
|
285
|
+
return ""
|
|
286
|
+
self._idle_started_by_tool_call_id = tool_call_id
|
|
287
|
+
self.set_state(AgentState.IDLE)
|
|
288
|
+
signal = self._wait_for_wakeup()
|
|
289
|
+
elapsed = self._get_idle_elapsed_seconds(tool_call_id=tool_call_id)
|
|
290
|
+
self._resume_from_wakeup(signal)
|
|
291
|
+
return f"idle {elapsed:.2f}s"
|
|
292
|
+
|
|
293
|
+
def request_sleep(self, *, seconds: float) -> str:
|
|
294
|
+
duration = max(0.0, seconds)
|
|
295
|
+
started_at = _time.perf_counter()
|
|
296
|
+
if duration <= 0:
|
|
297
|
+
self._queue_runtime_notice(self._build_sleep_deadline_notice())
|
|
298
|
+
return "slept 0.00s"
|
|
299
|
+
|
|
300
|
+
self.set_state(AgentState.SLEEPING, f"sleeping for {duration:.2f}s")
|
|
301
|
+
while not self._terminate.is_set():
|
|
302
|
+
self._raise_if_interrupt_requested()
|
|
303
|
+
remaining = duration - (_time.perf_counter() - started_at)
|
|
304
|
+
if remaining <= 0:
|
|
305
|
+
self._queue_runtime_notice(self._build_sleep_deadline_notice())
|
|
306
|
+
self.set_state(AgentState.RUNNING, "sleep deadline reached")
|
|
307
|
+
break
|
|
308
|
+
try:
|
|
309
|
+
signal = self._wake_queue.get(timeout=min(remaining, 0.1))
|
|
310
|
+
except Empty:
|
|
311
|
+
continue
|
|
312
|
+
if signal.reason == "termination":
|
|
313
|
+
self._terminate.set()
|
|
314
|
+
break
|
|
315
|
+
if signal.reason == "message":
|
|
316
|
+
self._resume_from_wakeup(signal)
|
|
317
|
+
self._drain_messages()
|
|
318
|
+
elapsed = max(0.0, _time.perf_counter() - started_at)
|
|
319
|
+
return f"woken by message after {elapsed:.2f}s"
|
|
320
|
+
elapsed = min(duration, max(0.0, _time.perf_counter() - started_at))
|
|
321
|
+
return f"slept {elapsed:.2f}s"
|
|
322
|
+
|
|
323
|
+
def _get_idle_elapsed_seconds(self, *, tool_call_id: str | None) -> float:
|
|
324
|
+
started_at = self._idle_started_at
|
|
325
|
+
if started_at is None:
|
|
326
|
+
return 0.0
|
|
327
|
+
if tool_call_id is not None and self._idle_started_by_tool_call_id not in {
|
|
328
|
+
None,
|
|
329
|
+
tool_call_id,
|
|
330
|
+
}:
|
|
331
|
+
return 0.0
|
|
332
|
+
return max(0.0, _time.perf_counter() - started_at)
|
|
333
|
+
|
|
334
|
+
def get_contact_ids_snapshot(self) -> list[str]:
|
|
335
|
+
from flowent.graph_service import get_tab_leader_id, list_agent_contact_paths
|
|
336
|
+
from flowent.registry import registry
|
|
337
|
+
from flowent.workspace_store import workspace_store
|
|
338
|
+
|
|
339
|
+
if self.node_type == NodeType.ASSISTANT:
|
|
340
|
+
return [
|
|
341
|
+
leader_id
|
|
342
|
+
for leader_id in (
|
|
343
|
+
get_tab_leader_id(tab.id) for tab in workspace_store.list_tabs()
|
|
344
|
+
)
|
|
345
|
+
if leader_id and registry.get(leader_id) is not None
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
seen_ids: set[str] = set()
|
|
349
|
+
contact_ids: list[str] = []
|
|
350
|
+
|
|
351
|
+
def append_contact(node_id: str) -> None:
|
|
352
|
+
if node_id == self.uuid or node_id in seen_ids:
|
|
353
|
+
return
|
|
354
|
+
if registry.get(node_id) is None:
|
|
355
|
+
return
|
|
356
|
+
seen_ids.add(node_id)
|
|
357
|
+
contact_ids.append(node_id)
|
|
358
|
+
|
|
359
|
+
if self.config.tab_id is None:
|
|
360
|
+
return contact_ids
|
|
361
|
+
|
|
362
|
+
leader_id = get_tab_leader_id(self.config.tab_id)
|
|
363
|
+
is_leader = leader_id == self.uuid
|
|
364
|
+
assistant = registry.get_assistant()
|
|
365
|
+
if is_leader and assistant is not None:
|
|
366
|
+
append_contact(assistant.uuid)
|
|
367
|
+
|
|
368
|
+
if is_leader:
|
|
369
|
+
for node in registry.get_all():
|
|
370
|
+
if node.uuid == self.uuid or node.node_type != NodeType.AGENT:
|
|
371
|
+
continue
|
|
372
|
+
if node.config.tab_id != self.config.tab_id:
|
|
373
|
+
continue
|
|
374
|
+
if node.uuid == leader_id:
|
|
375
|
+
continue
|
|
376
|
+
append_contact(node.uuid)
|
|
377
|
+
return contact_ids
|
|
378
|
+
|
|
379
|
+
for path in list_agent_contact_paths(
|
|
380
|
+
tab_id=self.config.tab_id,
|
|
381
|
+
node_id=self.uuid,
|
|
382
|
+
):
|
|
383
|
+
append_contact(path.target_id)
|
|
384
|
+
return contact_ids
|
|
385
|
+
|
|
386
|
+
def get_contacts_info(self) -> list[dict[str, Any]]:
|
|
387
|
+
from flowent.graph_service import (
|
|
388
|
+
is_tab_leader,
|
|
389
|
+
list_agent_contact_paths,
|
|
390
|
+
)
|
|
391
|
+
from flowent.registry import registry
|
|
392
|
+
|
|
393
|
+
result: list[dict[str, Any]] = []
|
|
394
|
+
if (
|
|
395
|
+
self.config.tab_id is not None
|
|
396
|
+
and not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id)
|
|
397
|
+
and self.node_type == NodeType.AGENT
|
|
398
|
+
):
|
|
399
|
+
return [
|
|
400
|
+
path.serialize()
|
|
401
|
+
for path in list_agent_contact_paths(
|
|
402
|
+
tab_id=self.config.tab_id,
|
|
403
|
+
node_id=self.uuid,
|
|
404
|
+
)
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
for contact_id in self.get_contact_ids_snapshot():
|
|
408
|
+
node = registry.get(contact_id)
|
|
409
|
+
if node is None:
|
|
410
|
+
continue
|
|
411
|
+
result.append(
|
|
412
|
+
{
|
|
413
|
+
"id": node.uuid,
|
|
414
|
+
"node_type": node.config.node_type.value,
|
|
415
|
+
"role_name": node.config.role_name,
|
|
416
|
+
"name": node.config.name,
|
|
417
|
+
"state": node.state.value,
|
|
418
|
+
"is_leader": (
|
|
419
|
+
node.config.tab_id is not None
|
|
420
|
+
and is_tab_leader(node_id=node.uuid, tab_id=node.config.tab_id)
|
|
421
|
+
),
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def can_contact(self, node_id: str) -> bool:
|
|
427
|
+
return node_id in set(self.get_contact_ids_snapshot())
|
|
428
|
+
|
|
429
|
+
def wait_until_idle(self, timeout: float | None = None) -> bool:
|
|
430
|
+
if self.state == AgentState.IDLE:
|
|
431
|
+
return True
|
|
432
|
+
return self._idle_state_event.wait(timeout=timeout)
|
|
433
|
+
|
|
434
|
+
def start(self) -> None:
|
|
435
|
+
from flowent.graph_service import is_tab_leader
|
|
436
|
+
|
|
437
|
+
self._thread = threading.Thread(
|
|
438
|
+
target=self._run,
|
|
439
|
+
name=f"agent-{self.uuid[:8]}",
|
|
440
|
+
daemon=True,
|
|
441
|
+
)
|
|
442
|
+
self._thread.start()
|
|
443
|
+
event_bus.emit(
|
|
444
|
+
Event(
|
|
445
|
+
type=EventType.NODE_CREATED,
|
|
446
|
+
agent_id=self.uuid,
|
|
447
|
+
data={
|
|
448
|
+
"node_type": self.config.node_type.value,
|
|
449
|
+
"role_name": self.config.role_name,
|
|
450
|
+
"name": self.config.name,
|
|
451
|
+
"tab_id": self.config.tab_id,
|
|
452
|
+
"is_leader": is_tab_leader(
|
|
453
|
+
node_id=self.uuid,
|
|
454
|
+
tab_id=self.config.tab_id,
|
|
455
|
+
),
|
|
456
|
+
},
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
self._persist_workspace_node()
|
|
460
|
+
|
|
461
|
+
def _append_history(self, entry: HistoryEntry) -> None:
|
|
462
|
+
with self._history_lock:
|
|
463
|
+
self.history.append(entry)
|
|
464
|
+
if isinstance(entry, ReceivedMessage):
|
|
465
|
+
self._pending_input_turn = True
|
|
466
|
+
data = entry.serialize()
|
|
467
|
+
self._log.debug(
|
|
468
|
+
"History append: type={}, content_len={}",
|
|
469
|
+
data.get("type"),
|
|
470
|
+
len(getattr(entry, "content", None) or "")
|
|
471
|
+
if hasattr(entry, "content")
|
|
472
|
+
else 0,
|
|
473
|
+
)
|
|
474
|
+
event_bus.emit(
|
|
475
|
+
Event(
|
|
476
|
+
type=EventType.HISTORY_ENTRY_ADDED,
|
|
477
|
+
agent_id=self.uuid,
|
|
478
|
+
data=data,
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
self._persist_workspace_node()
|
|
482
|
+
|
|
483
|
+
def _clear_pending_message_wakeups(self) -> None:
|
|
484
|
+
preserved_signals: list[WakeSignal] = []
|
|
485
|
+
|
|
486
|
+
while True:
|
|
487
|
+
try:
|
|
488
|
+
signal = self._wake_queue.get_nowait()
|
|
489
|
+
except Empty:
|
|
490
|
+
break
|
|
491
|
+
|
|
492
|
+
if signal.reason != "message":
|
|
493
|
+
preserved_signals.append(signal)
|
|
494
|
+
|
|
495
|
+
for signal in preserved_signals:
|
|
496
|
+
self._wake_queue.put(signal)
|
|
497
|
+
|
|
498
|
+
def _extract_pending_message_wakeups(self) -> list[WakeSignal]:
|
|
499
|
+
extracted_signals: list[WakeSignal] = []
|
|
500
|
+
preserved_signals: list[WakeSignal] = []
|
|
501
|
+
|
|
502
|
+
while True:
|
|
503
|
+
try:
|
|
504
|
+
signal = self._wake_queue.get_nowait()
|
|
505
|
+
except Empty:
|
|
506
|
+
break
|
|
507
|
+
|
|
508
|
+
if signal.reason == "message":
|
|
509
|
+
extracted_signals.append(signal)
|
|
510
|
+
continue
|
|
511
|
+
preserved_signals.append(signal)
|
|
512
|
+
|
|
513
|
+
for signal in preserved_signals:
|
|
514
|
+
self._wake_queue.put(signal)
|
|
515
|
+
|
|
516
|
+
return extracted_signals
|
|
517
|
+
|
|
518
|
+
def _restore_pending_message_wakeups(
|
|
519
|
+
self,
|
|
520
|
+
signals: list[WakeSignal],
|
|
521
|
+
) -> None:
|
|
522
|
+
for signal in signals:
|
|
523
|
+
self._wake_queue.put(signal)
|
|
524
|
+
|
|
525
|
+
def _pause_for_command_execution(self, *, timeout: float) -> bool:
|
|
526
|
+
if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
|
|
527
|
+
return False
|
|
528
|
+
if not self._command_interrupt_lock.acquire(timeout=timeout):
|
|
529
|
+
raise TimeoutError("Current chat did not pause for the command in time")
|
|
530
|
+
|
|
531
|
+
self._pause_after_interrupt_requested.set()
|
|
532
|
+
self._paused_for_command.clear()
|
|
533
|
+
self._resume_after_command.clear()
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
if not self.request_interrupt():
|
|
537
|
+
self._pause_after_interrupt_requested.clear()
|
|
538
|
+
self._resume_after_command.set()
|
|
539
|
+
self._command_interrupt_lock.release()
|
|
540
|
+
return False
|
|
541
|
+
if not self._paused_for_command.wait(timeout=timeout):
|
|
542
|
+
raise TimeoutError("Current chat did not pause after interrupt")
|
|
543
|
+
return True
|
|
544
|
+
except Exception:
|
|
545
|
+
self._pause_after_interrupt_requested.clear()
|
|
546
|
+
self._resume_after_command.set()
|
|
547
|
+
self._command_interrupt_lock.release()
|
|
548
|
+
raise
|
|
549
|
+
|
|
550
|
+
def _resume_after_command_execution(self) -> None:
|
|
551
|
+
self._pause_after_interrupt_requested.clear()
|
|
552
|
+
self._resume_after_command.set()
|
|
553
|
+
self._command_interrupt_lock.release()
|
|
554
|
+
|
|
555
|
+
def clear_chat_history(self, *, interrupt_timeout: float = 5.0) -> None:
|
|
556
|
+
if self.node_type != NodeType.ASSISTANT:
|
|
557
|
+
from flowent.graph_service import is_tab_leader
|
|
558
|
+
|
|
559
|
+
if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
|
|
560
|
+
raise RuntimeError("Only Assistant or workflow chats can be cleared")
|
|
561
|
+
|
|
562
|
+
paused_for_command = self._pause_for_command_execution(
|
|
563
|
+
timeout=interrupt_timeout
|
|
564
|
+
)
|
|
565
|
+
try:
|
|
566
|
+
self._clear_pending_message_wakeups()
|
|
567
|
+
with self._runtime_notice_lock:
|
|
568
|
+
self._pending_runtime_notices.clear()
|
|
569
|
+
with self._history_lock:
|
|
570
|
+
self.history = [
|
|
571
|
+
entry for entry in self.history if isinstance(entry, SystemEntry)
|
|
572
|
+
]
|
|
573
|
+
self._pending_input_turn = False
|
|
574
|
+
self._turn_started_with_pending_input = False
|
|
575
|
+
self._turn_made_progress = False
|
|
576
|
+
self._reset_execution_context()
|
|
577
|
+
|
|
578
|
+
event_bus.emit(
|
|
579
|
+
Event(
|
|
580
|
+
type=EventType.HISTORY_CLEARED,
|
|
581
|
+
agent_id=self.uuid,
|
|
582
|
+
data={
|
|
583
|
+
"scope": "assistant_chat"
|
|
584
|
+
if self.node_type == NodeType.ASSISTANT
|
|
585
|
+
else "workflow_chat"
|
|
586
|
+
},
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
self._persist_workspace_node()
|
|
590
|
+
finally:
|
|
591
|
+
if paused_for_command:
|
|
592
|
+
self._resume_after_command_execution()
|
|
593
|
+
|
|
594
|
+
def retry_human_message(
|
|
595
|
+
self,
|
|
596
|
+
*,
|
|
597
|
+
message_id: str,
|
|
598
|
+
interrupt_timeout: float = 5.0,
|
|
599
|
+
) -> str:
|
|
600
|
+
if self.node_type != NodeType.ASSISTANT:
|
|
601
|
+
raise RuntimeError("Only assistant chat history can be retried")
|
|
602
|
+
|
|
603
|
+
normalized_message_id = message_id.strip()
|
|
604
|
+
if not normalized_message_id:
|
|
605
|
+
raise ValueError("Assistant retry message_id cannot be empty")
|
|
606
|
+
|
|
607
|
+
paused_for_command = self._pause_for_command_execution(
|
|
608
|
+
timeout=interrupt_timeout
|
|
609
|
+
)
|
|
610
|
+
extracted_message_signals: list[WakeSignal] = []
|
|
611
|
+
previous_runtime_notices: list[str] = []
|
|
612
|
+
previous_history: list[HistoryEntry] = []
|
|
613
|
+
previous_pending_input_turn = False
|
|
614
|
+
previous_turn_started_with_pending_input = False
|
|
615
|
+
previous_turn_made_progress = False
|
|
616
|
+
previous_execution_summary = ""
|
|
617
|
+
previous_execution_cutoff = 0
|
|
618
|
+
previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
|
|
619
|
+
try:
|
|
620
|
+
with self._history_lock:
|
|
621
|
+
anchor_index = -1
|
|
622
|
+
anchor_parts: list[TextPart | ImagePart] = []
|
|
623
|
+
current_history = list(self.history)
|
|
624
|
+
for index, entry in enumerate(current_history):
|
|
625
|
+
if (
|
|
626
|
+
isinstance(entry, ReceivedMessage)
|
|
627
|
+
and entry.from_id == "human"
|
|
628
|
+
and entry.message_id == normalized_message_id
|
|
629
|
+
):
|
|
630
|
+
anchor_index = index
|
|
631
|
+
anchor_parts = list(entry.parts)
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
if anchor_index < 0:
|
|
635
|
+
raise LookupError(
|
|
636
|
+
f"Assistant human message `{normalized_message_id}` was not found."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
if has_image_parts(anchor_parts) and not self.supports_input_image():
|
|
640
|
+
raise RuntimeError(
|
|
641
|
+
"Assistant current model does not support `input_image`."
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
for part in anchor_parts:
|
|
645
|
+
asset_id = getattr(part, "asset_id", None)
|
|
646
|
+
if isinstance(asset_id, str):
|
|
647
|
+
require_image_asset(asset_id)
|
|
648
|
+
|
|
649
|
+
extracted_message_signals = self._extract_pending_message_wakeups()
|
|
650
|
+
with self._runtime_notice_lock:
|
|
651
|
+
previous_runtime_notices = list(self._pending_runtime_notices)
|
|
652
|
+
self._pending_runtime_notices.clear()
|
|
653
|
+
|
|
654
|
+
with self._history_lock:
|
|
655
|
+
previous_history = list(self.history)
|
|
656
|
+
previous_pending_input_turn = self._pending_input_turn
|
|
657
|
+
previous_turn_started_with_pending_input = (
|
|
658
|
+
self._turn_started_with_pending_input
|
|
659
|
+
)
|
|
660
|
+
previous_turn_made_progress = self._turn_made_progress
|
|
661
|
+
self.history = [
|
|
662
|
+
entry
|
|
663
|
+
for index, entry in enumerate(previous_history)
|
|
664
|
+
if index < anchor_index or isinstance(entry, SystemEntry)
|
|
665
|
+
]
|
|
666
|
+
retried_message_id = str(_uuid.uuid4())
|
|
667
|
+
self.history.append(
|
|
668
|
+
ReceivedMessage(
|
|
669
|
+
from_id="human",
|
|
670
|
+
parts=anchor_parts,
|
|
671
|
+
message_id=retried_message_id,
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
self._pending_input_turn = True
|
|
675
|
+
self._turn_started_with_pending_input = False
|
|
676
|
+
self._turn_made_progress = False
|
|
677
|
+
|
|
678
|
+
with self._execution_context_lock:
|
|
679
|
+
previous_execution_summary = self._execution_context_summary
|
|
680
|
+
previous_execution_cutoff = self._execution_context_history_cutoff
|
|
681
|
+
previous_context_token_usage_baseline = (
|
|
682
|
+
self._context_token_usage_baseline
|
|
683
|
+
)
|
|
684
|
+
self._set_execution_context(summary="", history_cutoff=0)
|
|
685
|
+
self._persist_workspace_node()
|
|
686
|
+
self.enqueue_message(
|
|
687
|
+
Message(
|
|
688
|
+
from_id="human",
|
|
689
|
+
to_id=self.uuid,
|
|
690
|
+
parts=anchor_parts,
|
|
691
|
+
message_id=retried_message_id,
|
|
692
|
+
history_recorded=True,
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
history_snapshot = self.get_history_snapshot()
|
|
696
|
+
event_bus.emit(
|
|
697
|
+
Event(
|
|
698
|
+
type=EventType.HISTORY_REPLACED,
|
|
699
|
+
agent_id=self.uuid,
|
|
700
|
+
data={
|
|
701
|
+
"scope": "assistant_retry",
|
|
702
|
+
"replaced_message_id": normalized_message_id,
|
|
703
|
+
"message_id": retried_message_id,
|
|
704
|
+
"history": [entry.serialize() for entry in history_snapshot],
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
return retried_message_id
|
|
709
|
+
except Exception:
|
|
710
|
+
if previous_history:
|
|
711
|
+
with self._history_lock:
|
|
712
|
+
self.history = previous_history
|
|
713
|
+
self._pending_input_turn = previous_pending_input_turn
|
|
714
|
+
self._turn_started_with_pending_input = (
|
|
715
|
+
previous_turn_started_with_pending_input
|
|
716
|
+
)
|
|
717
|
+
self._turn_made_progress = previous_turn_made_progress
|
|
718
|
+
with self._runtime_notice_lock:
|
|
719
|
+
self._pending_runtime_notices = previous_runtime_notices
|
|
720
|
+
with self._execution_context_lock:
|
|
721
|
+
self._execution_context_summary = previous_execution_summary
|
|
722
|
+
self._execution_context_history_cutoff = previous_execution_cutoff
|
|
723
|
+
self._context_token_usage_baseline = (
|
|
724
|
+
previous_context_token_usage_baseline
|
|
725
|
+
)
|
|
726
|
+
if extracted_message_signals:
|
|
727
|
+
self._restore_pending_message_wakeups(extracted_message_signals)
|
|
728
|
+
self._persist_workspace_node()
|
|
729
|
+
raise
|
|
730
|
+
finally:
|
|
731
|
+
if paused_for_command:
|
|
732
|
+
self._resume_after_command_execution()
|
|
733
|
+
|
|
734
|
+
def retry_received_message(
|
|
735
|
+
self,
|
|
736
|
+
*,
|
|
737
|
+
message_id: str,
|
|
738
|
+
interrupt_timeout: float = 5.0,
|
|
739
|
+
) -> str:
|
|
740
|
+
normalized_message_id = message_id.strip()
|
|
741
|
+
if not normalized_message_id:
|
|
742
|
+
raise ValueError("Retry message_id cannot be empty")
|
|
743
|
+
|
|
744
|
+
leader_human_only = False
|
|
745
|
+
if self.node_type != NodeType.ASSISTANT and self.config.tab_id:
|
|
746
|
+
from flowent.graph_service import is_tab_leader
|
|
747
|
+
|
|
748
|
+
leader_human_only = is_tab_leader(
|
|
749
|
+
node_id=self.uuid,
|
|
750
|
+
tab_id=self.config.tab_id,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
paused_for_command = self._pause_for_command_execution(
|
|
754
|
+
timeout=interrupt_timeout
|
|
755
|
+
)
|
|
756
|
+
extracted_message_signals: list[WakeSignal] = []
|
|
757
|
+
previous_runtime_notices: list[str] = []
|
|
758
|
+
previous_history: list[HistoryEntry] = []
|
|
759
|
+
previous_pending_input_turn = False
|
|
760
|
+
previous_turn_started_with_pending_input = False
|
|
761
|
+
previous_turn_made_progress = False
|
|
762
|
+
previous_execution_summary = ""
|
|
763
|
+
previous_execution_cutoff = 0
|
|
764
|
+
previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
|
|
765
|
+
try:
|
|
766
|
+
with self._history_lock:
|
|
767
|
+
anchor_index = -1
|
|
768
|
+
anchor_parts: list[TextPart | ImagePart] = []
|
|
769
|
+
anchor_from_id = "human"
|
|
770
|
+
current_history = list(self.history)
|
|
771
|
+
for index, entry in enumerate(current_history):
|
|
772
|
+
if (
|
|
773
|
+
isinstance(entry, ReceivedMessage)
|
|
774
|
+
and entry.message_id == normalized_message_id
|
|
775
|
+
and (not leader_human_only or entry.from_id == "human")
|
|
776
|
+
):
|
|
777
|
+
anchor_index = index
|
|
778
|
+
anchor_parts = list(entry.parts)
|
|
779
|
+
anchor_from_id = entry.from_id
|
|
780
|
+
break
|
|
781
|
+
|
|
782
|
+
if anchor_index < 0:
|
|
783
|
+
if leader_human_only:
|
|
784
|
+
raise LookupError(
|
|
785
|
+
f"Leader human message `{normalized_message_id}` was not found."
|
|
786
|
+
)
|
|
787
|
+
raise LookupError(
|
|
788
|
+
f"Received message `{normalized_message_id}` was not found."
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
if has_image_parts(anchor_parts) and not self.supports_input_image():
|
|
792
|
+
raise RuntimeError("Current model does not support `input_image`.")
|
|
793
|
+
|
|
794
|
+
for part in anchor_parts:
|
|
795
|
+
asset_id = getattr(part, "asset_id", None)
|
|
796
|
+
if isinstance(asset_id, str):
|
|
797
|
+
require_image_asset(asset_id)
|
|
798
|
+
|
|
799
|
+
extracted_message_signals = self._extract_pending_message_wakeups()
|
|
800
|
+
with self._runtime_notice_lock:
|
|
801
|
+
previous_runtime_notices = list(self._pending_runtime_notices)
|
|
802
|
+
self._pending_runtime_notices.clear()
|
|
803
|
+
|
|
804
|
+
with self._history_lock:
|
|
805
|
+
previous_history = list(self.history)
|
|
806
|
+
previous_pending_input_turn = self._pending_input_turn
|
|
807
|
+
previous_turn_started_with_pending_input = (
|
|
808
|
+
self._turn_started_with_pending_input
|
|
809
|
+
)
|
|
810
|
+
previous_turn_made_progress = self._turn_made_progress
|
|
811
|
+
self.history = [
|
|
812
|
+
entry
|
|
813
|
+
for index, entry in enumerate(previous_history)
|
|
814
|
+
if index < anchor_index or isinstance(entry, SystemEntry)
|
|
815
|
+
]
|
|
816
|
+
retried_message_id = str(_uuid.uuid4())
|
|
817
|
+
self.history.append(
|
|
818
|
+
ReceivedMessage(
|
|
819
|
+
from_id=anchor_from_id,
|
|
820
|
+
parts=anchor_parts,
|
|
821
|
+
message_id=retried_message_id,
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
self._pending_input_turn = True
|
|
825
|
+
self._turn_started_with_pending_input = False
|
|
826
|
+
self._turn_made_progress = False
|
|
827
|
+
|
|
828
|
+
with self._execution_context_lock:
|
|
829
|
+
previous_execution_summary = self._execution_context_summary
|
|
830
|
+
previous_execution_cutoff = self._execution_context_history_cutoff
|
|
831
|
+
previous_context_token_usage_baseline = (
|
|
832
|
+
self._context_token_usage_baseline
|
|
833
|
+
)
|
|
834
|
+
self._set_execution_context(summary="", history_cutoff=0)
|
|
835
|
+
self._persist_workspace_node()
|
|
836
|
+
self.enqueue_message(
|
|
837
|
+
Message(
|
|
838
|
+
from_id=anchor_from_id,
|
|
839
|
+
to_id=self.uuid,
|
|
840
|
+
parts=anchor_parts,
|
|
841
|
+
message_id=retried_message_id,
|
|
842
|
+
history_recorded=True,
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
history_snapshot = self.get_history_snapshot()
|
|
846
|
+
event_bus.emit(
|
|
847
|
+
Event(
|
|
848
|
+
type=EventType.HISTORY_REPLACED,
|
|
849
|
+
agent_id=self.uuid,
|
|
850
|
+
data={
|
|
851
|
+
"scope": "node_retry",
|
|
852
|
+
"replaced_message_id": normalized_message_id,
|
|
853
|
+
"message_id": retried_message_id,
|
|
854
|
+
"history": [entry.serialize() for entry in history_snapshot],
|
|
855
|
+
},
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
return retried_message_id
|
|
859
|
+
except Exception:
|
|
860
|
+
if previous_history:
|
|
861
|
+
with self._history_lock:
|
|
862
|
+
self.history = previous_history
|
|
863
|
+
self._pending_input_turn = previous_pending_input_turn
|
|
864
|
+
self._turn_started_with_pending_input = (
|
|
865
|
+
previous_turn_started_with_pending_input
|
|
866
|
+
)
|
|
867
|
+
self._turn_made_progress = previous_turn_made_progress
|
|
868
|
+
with self._runtime_notice_lock:
|
|
869
|
+
self._pending_runtime_notices = previous_runtime_notices
|
|
870
|
+
self._restore_pending_message_wakeups(extracted_message_signals)
|
|
871
|
+
with self._execution_context_lock:
|
|
872
|
+
self._execution_context_summary = previous_execution_summary
|
|
873
|
+
self._execution_context_history_cutoff = previous_execution_cutoff
|
|
874
|
+
self._context_token_usage_baseline = (
|
|
875
|
+
previous_context_token_usage_baseline
|
|
876
|
+
)
|
|
877
|
+
self._persist_workspace_node()
|
|
878
|
+
raise
|
|
879
|
+
finally:
|
|
880
|
+
if paused_for_command:
|
|
881
|
+
self._resume_after_command_execution()
|
|
882
|
+
|
|
883
|
+
def compact_chat_history(
|
|
884
|
+
self,
|
|
885
|
+
*,
|
|
886
|
+
focus: str | None = None,
|
|
887
|
+
interrupt_timeout: float = 5.0,
|
|
888
|
+
) -> CommandResultEntry:
|
|
889
|
+
if self.node_type != NodeType.ASSISTANT:
|
|
890
|
+
from flowent.graph_service import is_tab_leader
|
|
891
|
+
|
|
892
|
+
if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
|
|
893
|
+
raise RuntimeError("Only Assistant or workflow chats can be compacted")
|
|
894
|
+
|
|
895
|
+
paused_for_command = self._pause_for_command_execution(
|
|
896
|
+
timeout=interrupt_timeout
|
|
897
|
+
)
|
|
898
|
+
try:
|
|
899
|
+
self._run_compact_with_observability(
|
|
900
|
+
trigger_type="manual",
|
|
901
|
+
focus=focus,
|
|
902
|
+
)
|
|
903
|
+
content = "Compacted this chat for future replies."
|
|
904
|
+
if focus and focus.strip():
|
|
905
|
+
content += f"\n\nFocus: {focus.strip()}"
|
|
906
|
+
|
|
907
|
+
with self._runtime_notice_lock:
|
|
908
|
+
self._pending_runtime_notices.clear()
|
|
909
|
+
|
|
910
|
+
return CommandResultEntry(
|
|
911
|
+
command_name="/compact",
|
|
912
|
+
content=content,
|
|
913
|
+
include_in_context=False,
|
|
914
|
+
)
|
|
915
|
+
finally:
|
|
916
|
+
if paused_for_command:
|
|
917
|
+
self._resume_after_command_execution()
|
|
918
|
+
|
|
919
|
+
def execute_conversation_command(
|
|
920
|
+
self,
|
|
921
|
+
*,
|
|
922
|
+
command_name: str,
|
|
923
|
+
argument: str = "",
|
|
924
|
+
interrupt_timeout: float = 5.0,
|
|
925
|
+
) -> CommandResultEntry:
|
|
926
|
+
append_to_history = True
|
|
927
|
+
if command_name == "/clear":
|
|
928
|
+
self.clear_chat_history(interrupt_timeout=interrupt_timeout)
|
|
929
|
+
entry = CommandResultEntry(
|
|
930
|
+
command_name=command_name,
|
|
931
|
+
content="Cleared the current chat.",
|
|
932
|
+
include_in_context=False,
|
|
933
|
+
)
|
|
934
|
+
append_to_history = False
|
|
935
|
+
elif command_name == "/compact":
|
|
936
|
+
entry = self.compact_chat_history(
|
|
937
|
+
focus=argument or None,
|
|
938
|
+
interrupt_timeout=interrupt_timeout,
|
|
939
|
+
)
|
|
940
|
+
elif command_name == "/help":
|
|
941
|
+
entry = CommandResultEntry(
|
|
942
|
+
command_name=command_name,
|
|
943
|
+
content=build_conversation_help_text(),
|
|
944
|
+
)
|
|
945
|
+
else:
|
|
946
|
+
raise RuntimeError(f"Unsupported conversation command: {command_name}")
|
|
947
|
+
|
|
948
|
+
if append_to_history:
|
|
949
|
+
self._append_history(entry)
|
|
950
|
+
return entry
|
|
951
|
+
|
|
952
|
+
def execute_assistant_command(
|
|
953
|
+
self,
|
|
954
|
+
*,
|
|
955
|
+
command_name: str,
|
|
956
|
+
argument: str = "",
|
|
957
|
+
interrupt_timeout: float = 5.0,
|
|
958
|
+
) -> CommandResultEntry:
|
|
959
|
+
return self.execute_conversation_command(
|
|
960
|
+
command_name=command_name,
|
|
961
|
+
argument=argument,
|
|
962
|
+
interrupt_timeout=interrupt_timeout,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def _run(self) -> None:
|
|
966
|
+
with logger.contextualize(
|
|
967
|
+
agent_id=self.uuid[:8],
|
|
968
|
+
node_type=self.config.node_type.value,
|
|
969
|
+
):
|
|
970
|
+
with self._history_lock:
|
|
971
|
+
has_system_entry = any(
|
|
972
|
+
isinstance(entry, SystemEntry) for entry in self.history
|
|
973
|
+
)
|
|
974
|
+
if has_system_entry:
|
|
975
|
+
self._sync_system_prompt_entry()
|
|
976
|
+
else:
|
|
977
|
+
self._append_history(
|
|
978
|
+
SystemEntry(content=get_system_prompt(self.config))
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
if self.state == AgentState.INITIALIZING:
|
|
982
|
+
self.set_state(AgentState.IDLE, "initialized, awaiting first message")
|
|
983
|
+
self._log.info("Agent started, waiting for first message")
|
|
984
|
+
else:
|
|
985
|
+
self._log.info(
|
|
986
|
+
"Agent restored in state {}, waiting for input",
|
|
987
|
+
self.state.value,
|
|
988
|
+
)
|
|
989
|
+
self._wait_for_input()
|
|
990
|
+
|
|
991
|
+
if self._terminate.is_set():
|
|
992
|
+
if self._should_preserve_workspace_state_on_exit():
|
|
993
|
+
self._log.info(
|
|
994
|
+
"Agent stopped for process exit with state {}",
|
|
995
|
+
self.state.value,
|
|
996
|
+
)
|
|
997
|
+
return
|
|
998
|
+
self._finalize_termination("terminated before first message")
|
|
999
|
+
return
|
|
1000
|
+
|
|
1001
|
+
while not self._terminate.is_set():
|
|
1002
|
+
try:
|
|
1003
|
+
self._sync_system_prompt_entry()
|
|
1004
|
+
self._drain_messages()
|
|
1005
|
+
self._turn_started_with_pending_input = self._pending_input_turn
|
|
1006
|
+
self._turn_made_progress = False
|
|
1007
|
+
|
|
1008
|
+
tools_schema = _get_tool_registry().get_tools_schema(self)
|
|
1009
|
+
prepared_context = self._prepare_messages_for_llm()
|
|
1010
|
+
messages = prepared_context.messages
|
|
1011
|
+
|
|
1012
|
+
self._log.debug(
|
|
1013
|
+
"LLM request: messages={}, tools={}, history_len={}",
|
|
1014
|
+
len(messages),
|
|
1015
|
+
len(tools_schema) if tools_schema else 0,
|
|
1016
|
+
len(self.history),
|
|
1017
|
+
)
|
|
1018
|
+
stream_state: StreamingContentState | None = None
|
|
1019
|
+
try:
|
|
1020
|
+
response, stream_state = self._chat_with_retries(
|
|
1021
|
+
prepared_context=prepared_context,
|
|
1022
|
+
tools_schema=tools_schema,
|
|
1023
|
+
)
|
|
1024
|
+
self._flush_streaming_think_parser(stream_state)
|
|
1025
|
+
|
|
1026
|
+
self._log.debug(
|
|
1027
|
+
"LLM response: content_len={}, parts_len={}, thinking_len={}, tool_calls={}",
|
|
1028
|
+
len(response.content) if response.content else 0,
|
|
1029
|
+
len(response.parts) if response.parts else 0,
|
|
1030
|
+
len(response.thinking) if response.thinking else 0,
|
|
1031
|
+
[tc.name for tc in response.tool_calls]
|
|
1032
|
+
if response.tool_calls
|
|
1033
|
+
else None,
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
final_thinking = (
|
|
1037
|
+
response.thinking or stream_state.thinking_buffer
|
|
1038
|
+
)
|
|
1039
|
+
final_content = (
|
|
1040
|
+
stream_state.content_buffer
|
|
1041
|
+
if stream_state.saw_content_chunks
|
|
1042
|
+
else response.content
|
|
1043
|
+
)
|
|
1044
|
+
final_parts = response.parts
|
|
1045
|
+
if final_content and final_thinking:
|
|
1046
|
+
final_content, _ = split_thinking_content(final_content)
|
|
1047
|
+
|
|
1048
|
+
if final_thinking:
|
|
1049
|
+
self._append_history(
|
|
1050
|
+
AssistantThinking(content=final_thinking),
|
|
1051
|
+
)
|
|
1052
|
+
stream_state.thinking_buffer = ""
|
|
1053
|
+
|
|
1054
|
+
if response.tool_calls:
|
|
1055
|
+
self._log.debug(
|
|
1056
|
+
"Processing {} tool call(s)",
|
|
1057
|
+
len(response.tool_calls),
|
|
1058
|
+
)
|
|
1059
|
+
if final_parts:
|
|
1060
|
+
self._record_content_parts_output(
|
|
1061
|
+
self._normalize_llm_output_parts(final_parts),
|
|
1062
|
+
emitted_human_content=stream_state.emitted_human_content,
|
|
1063
|
+
)
|
|
1064
|
+
stream_state.content_buffer = ""
|
|
1065
|
+
elif final_content:
|
|
1066
|
+
self._record_text_output(
|
|
1067
|
+
final_content,
|
|
1068
|
+
emitted_human_content=stream_state.emitted_human_content,
|
|
1069
|
+
)
|
|
1070
|
+
stream_state.content_buffer = ""
|
|
1071
|
+
self._raise_if_interrupt_requested()
|
|
1072
|
+
stop_future_send_calls = False
|
|
1073
|
+
for tc in response.tool_calls:
|
|
1074
|
+
if stop_future_send_calls and tc.name == "send":
|
|
1075
|
+
continue
|
|
1076
|
+
tool_result = self._handle_tool_call(
|
|
1077
|
+
tc.name,
|
|
1078
|
+
tc.arguments,
|
|
1079
|
+
tc.id,
|
|
1080
|
+
)
|
|
1081
|
+
if tc.name == "send" and self._tool_result_has_error(
|
|
1082
|
+
tool_result
|
|
1083
|
+
):
|
|
1084
|
+
stop_future_send_calls = True
|
|
1085
|
+
self._raise_if_interrupt_requested()
|
|
1086
|
+
if self._terminate.is_set():
|
|
1087
|
+
break
|
|
1088
|
+
elif final_parts:
|
|
1089
|
+
self._record_content_parts_output(
|
|
1090
|
+
self._normalize_llm_output_parts(final_parts),
|
|
1091
|
+
emitted_human_content=stream_state.emitted_human_content,
|
|
1092
|
+
)
|
|
1093
|
+
stream_state.content_buffer = ""
|
|
1094
|
+
self._log.debug(
|
|
1095
|
+
"No tool calls, continuing execution after structured response"
|
|
1096
|
+
)
|
|
1097
|
+
elif final_content:
|
|
1098
|
+
self._record_text_output(
|
|
1099
|
+
final_content,
|
|
1100
|
+
emitted_human_content=stream_state.emitted_human_content,
|
|
1101
|
+
)
|
|
1102
|
+
stream_state.content_buffer = ""
|
|
1103
|
+
self._log.debug(
|
|
1104
|
+
"No tool calls, continuing execution after text response"
|
|
1105
|
+
)
|
|
1106
|
+
else:
|
|
1107
|
+
self._log.warning(
|
|
1108
|
+
"LLM returned empty response (no content, no tool_calls)",
|
|
1109
|
+
)
|
|
1110
|
+
except InterruptRequestedError as exc:
|
|
1111
|
+
self._handle_interrupt(exc.stream_state or stream_state)
|
|
1112
|
+
if self._terminate.is_set():
|
|
1113
|
+
break
|
|
1114
|
+
continue
|
|
1115
|
+
|
|
1116
|
+
except LLMProviderError as exc:
|
|
1117
|
+
self._interrupt_requested.clear()
|
|
1118
|
+
self.set_interrupt_callback(None)
|
|
1119
|
+
self._log.warning("Agent LLM provider error: {}", exc)
|
|
1120
|
+
error_summary = str(exc)
|
|
1121
|
+
self._append_history(ErrorEntry(content=error_summary))
|
|
1122
|
+
self.set_state(AgentState.ERROR, error_summary)
|
|
1123
|
+
self._wait_for_input()
|
|
1124
|
+
if self._terminate.is_set():
|
|
1125
|
+
break
|
|
1126
|
+
|
|
1127
|
+
except ContextPreflightError as exc:
|
|
1128
|
+
self._interrupt_requested.clear()
|
|
1129
|
+
self.set_interrupt_callback(None)
|
|
1130
|
+
self._log.warning("Agent context preflight failed: {}", exc)
|
|
1131
|
+
error_summary = str(exc)
|
|
1132
|
+
self._append_history(ErrorEntry(content=error_summary))
|
|
1133
|
+
self.set_state(AgentState.ERROR, error_summary)
|
|
1134
|
+
self._wait_for_input()
|
|
1135
|
+
if self._terminate.is_set():
|
|
1136
|
+
break
|
|
1137
|
+
|
|
1138
|
+
except Exception as exc:
|
|
1139
|
+
self._interrupt_requested.clear()
|
|
1140
|
+
self.set_interrupt_callback(None)
|
|
1141
|
+
self._log.exception("Agent error")
|
|
1142
|
+
tb_str = traceback.format_exc()
|
|
1143
|
+
self._append_history(
|
|
1144
|
+
ErrorEntry(content=f"{type(exc).__name__}: {exc}\n\n{tb_str}"),
|
|
1145
|
+
)
|
|
1146
|
+
self.set_state(AgentState.ERROR, f"{type(exc).__name__}: {exc}")
|
|
1147
|
+
self._wait_for_input()
|
|
1148
|
+
if self._terminate.is_set():
|
|
1149
|
+
break
|
|
1150
|
+
|
|
1151
|
+
if self._should_preserve_workspace_state_on_exit():
|
|
1152
|
+
self._log.info(
|
|
1153
|
+
"Agent stopped for process exit with state {}",
|
|
1154
|
+
self.state.value,
|
|
1155
|
+
)
|
|
1156
|
+
return
|
|
1157
|
+
|
|
1158
|
+
self._finalize_termination(self._termination_reason or "finished")
|
|
1159
|
+
|
|
1160
|
+
def _sync_system_prompt_entry(self) -> None:
|
|
1161
|
+
system_prompt = get_system_prompt(self.config)
|
|
1162
|
+
with self._history_lock:
|
|
1163
|
+
for entry in self.history:
|
|
1164
|
+
if isinstance(entry, SystemEntry):
|
|
1165
|
+
entry.content = system_prompt
|
|
1166
|
+
break
|
|
1167
|
+
else:
|
|
1168
|
+
self.history.insert(0, SystemEntry(content=system_prompt))
|
|
1169
|
+
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _build_runtime_system_message(content: str) -> dict[str, str]:
|
|
1172
|
+
return {"role": "user", "content": f"<system>{content}</system>"}
|
|
1173
|
+
|
|
1174
|
+
def _build_runtime_tail_messages(self) -> list[dict[str, str]]:
|
|
1175
|
+
with self._todos_lock:
|
|
1176
|
+
todos = [TodoItem(text=t.text) for t in self.todos]
|
|
1177
|
+
with self._history_lock:
|
|
1178
|
+
history_snapshot = list(self.history)
|
|
1179
|
+
runtime_notices = self._consume_runtime_notices()
|
|
1180
|
+
custom_post_prompt = get_settings().custom_post_prompt.strip()
|
|
1181
|
+
messages: list[dict[str, str]] = []
|
|
1182
|
+
todo_message = self._build_runtime_todo_message(todos)
|
|
1183
|
+
if todo_message is not None:
|
|
1184
|
+
messages.append(todo_message)
|
|
1185
|
+
messages.append(
|
|
1186
|
+
self._build_runtime_post_prompt_message(
|
|
1187
|
+
has_todos=bool(todos),
|
|
1188
|
+
pending_agent_dispatches=self._get_pending_agent_dispatches(
|
|
1189
|
+
history_snapshot
|
|
1190
|
+
),
|
|
1191
|
+
)
|
|
1192
|
+
)
|
|
1193
|
+
for notice in runtime_notices:
|
|
1194
|
+
messages.append(self._build_runtime_system_message(notice))
|
|
1195
|
+
if custom_post_prompt:
|
|
1196
|
+
messages.append(self._build_runtime_system_message(custom_post_prompt))
|
|
1197
|
+
return messages
|
|
1198
|
+
|
|
1199
|
+
def _build_runtime_todo_message(
|
|
1200
|
+
self,
|
|
1201
|
+
todos: list[TodoItem],
|
|
1202
|
+
) -> dict[str, str] | None:
|
|
1203
|
+
if not todos:
|
|
1204
|
+
return None
|
|
1205
|
+
lines = []
|
|
1206
|
+
for todo in todos:
|
|
1207
|
+
lines.append(f" - {todo.text}")
|
|
1208
|
+
todo_text = "Current TODO list:\n" + "\n".join(lines)
|
|
1209
|
+
return self._build_runtime_system_message(todo_text)
|
|
1210
|
+
|
|
1211
|
+
@staticmethod
|
|
1212
|
+
def _get_created_agent_label(payload: dict[str, Any]) -> str | None:
|
|
1213
|
+
agent_id = payload.get("agent_id")
|
|
1214
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
1215
|
+
agent_id = payload.get("id")
|
|
1216
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
1217
|
+
return None
|
|
1218
|
+
name = payload.get("name")
|
|
1219
|
+
if (not isinstance(name, str) or not name.strip()) and isinstance(
|
|
1220
|
+
payload.get("config"), dict
|
|
1221
|
+
):
|
|
1222
|
+
config = payload["config"]
|
|
1223
|
+
config_name = config.get("name")
|
|
1224
|
+
config_role_name = config.get("role_name")
|
|
1225
|
+
if isinstance(config_name, str) and config_name.strip():
|
|
1226
|
+
name = config_name
|
|
1227
|
+
elif isinstance(config_role_name, str) and config_role_name.strip():
|
|
1228
|
+
name = config_role_name
|
|
1229
|
+
short_id = agent_id[:8]
|
|
1230
|
+
if isinstance(name, str) and name.strip():
|
|
1231
|
+
return f"{name.strip()} (`{short_id}`)"
|
|
1232
|
+
return f"`{short_id}`"
|
|
1233
|
+
|
|
1234
|
+
@classmethod
|
|
1235
|
+
def _iter_dispatched_agent_payloads(
|
|
1236
|
+
cls,
|
|
1237
|
+
tool_name: str,
|
|
1238
|
+
payload: dict[str, Any],
|
|
1239
|
+
) -> list[tuple[str, str]]:
|
|
1240
|
+
if tool_name == "create_agent":
|
|
1241
|
+
label = cls._get_created_agent_label(payload)
|
|
1242
|
+
agent_id = payload.get("id")
|
|
1243
|
+
if isinstance(agent_id, str) and label is not None:
|
|
1244
|
+
return [(agent_id, label)]
|
|
1245
|
+
return []
|
|
1246
|
+
return []
|
|
1247
|
+
|
|
1248
|
+
def _get_pending_agent_dispatches(
|
|
1249
|
+
self,
|
|
1250
|
+
history_snapshot: list[HistoryEntry],
|
|
1251
|
+
) -> list[str]:
|
|
1252
|
+
pending: dict[str, str] = {}
|
|
1253
|
+
|
|
1254
|
+
for entry in history_snapshot:
|
|
1255
|
+
if isinstance(entry, ToolCall):
|
|
1256
|
+
if entry.tool_name != "create_agent":
|
|
1257
|
+
continue
|
|
1258
|
+
if entry.result is None:
|
|
1259
|
+
continue
|
|
1260
|
+
try:
|
|
1261
|
+
payload = json.loads(entry.result)
|
|
1262
|
+
except json.JSONDecodeError:
|
|
1263
|
+
continue
|
|
1264
|
+
if not isinstance(payload, dict) or payload.get("error") is not None:
|
|
1265
|
+
continue
|
|
1266
|
+
for agent_id, label in self._iter_dispatched_agent_payloads(
|
|
1267
|
+
entry.tool_name,
|
|
1268
|
+
payload,
|
|
1269
|
+
):
|
|
1270
|
+
pending[agent_id] = label
|
|
1271
|
+
continue
|
|
1272
|
+
|
|
1273
|
+
if isinstance(entry, SentMessage):
|
|
1274
|
+
pending.pop(entry.to_id, None)
|
|
1275
|
+
|
|
1276
|
+
return list(pending.values())
|
|
1277
|
+
|
|
1278
|
+
def _build_runtime_post_prompt_message(
|
|
1279
|
+
self,
|
|
1280
|
+
*,
|
|
1281
|
+
has_todos: bool,
|
|
1282
|
+
pending_agent_dispatches: list[str],
|
|
1283
|
+
) -> dict[str, str]:
|
|
1284
|
+
if self._is_entry_level_sender():
|
|
1285
|
+
send_lines = [
|
|
1286
|
+
"- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.",
|
|
1287
|
+
"- Use `contacts` to inspect the node ids and names you can currently message directly.",
|
|
1288
|
+
]
|
|
1289
|
+
else:
|
|
1290
|
+
send_lines = [
|
|
1291
|
+
"- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.",
|
|
1292
|
+
"- Use `contacts` to inspect the current output-port paths you can send through.",
|
|
1293
|
+
]
|
|
1294
|
+
lines = [
|
|
1295
|
+
"Runtime post prompt:",
|
|
1296
|
+
"- Plain content is never delivered to other agents.",
|
|
1297
|
+
*send_lines,
|
|
1298
|
+
"- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.",
|
|
1299
|
+
]
|
|
1300
|
+
if pending_agent_dispatches:
|
|
1301
|
+
targets = ", ".join(pending_agent_dispatches)
|
|
1302
|
+
lines.append(
|
|
1303
|
+
f"- Newly created agents still waiting for their first task: {targets}."
|
|
1304
|
+
)
|
|
1305
|
+
lines.append(
|
|
1306
|
+
"- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself."
|
|
1307
|
+
)
|
|
1308
|
+
lines.append(
|
|
1309
|
+
"- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`."
|
|
1310
|
+
)
|
|
1311
|
+
elif has_todos:
|
|
1312
|
+
lines.append(
|
|
1313
|
+
"- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items."
|
|
1314
|
+
)
|
|
1315
|
+
else:
|
|
1316
|
+
lines.append(
|
|
1317
|
+
"- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`."
|
|
1318
|
+
)
|
|
1319
|
+
return self._build_runtime_system_message("\n".join(lines))
|
|
1320
|
+
|
|
1321
|
+
@staticmethod
|
|
1322
|
+
def _build_idle_without_progress_notice() -> str:
|
|
1323
|
+
return (
|
|
1324
|
+
"Idle reminder: you received a new message this turn, but this "
|
|
1325
|
+
"response did not send a reply, call `send`, or use any "
|
|
1326
|
+
"non-idle tool. Do not call `idle` yet. First reply to the Human, "
|
|
1327
|
+
"dispatch/delegate work, or take another concrete step."
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
@staticmethod
|
|
1331
|
+
def _build_actionable_todo_notice(todo_text: str) -> str:
|
|
1332
|
+
return (
|
|
1333
|
+
"Idle reminder: your first remaining TODO still looks actionable "
|
|
1334
|
+
f"(`{todo_text}`). Do that next, or update the TODO list so the "
|
|
1335
|
+
"first remaining item is the actual waiting step, before calling "
|
|
1336
|
+
"`idle`."
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
@staticmethod
|
|
1340
|
+
def _build_sleep_deadline_notice() -> str:
|
|
1341
|
+
return (
|
|
1342
|
+
"Sleep deadline reached: the timed wait has expired. Continue from "
|
|
1343
|
+
"that deadline wake-up and decide whether to retry, follow up, or "
|
|
1344
|
+
"escalate."
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
def _queue_runtime_notice(self, content: str) -> None:
|
|
1348
|
+
with self._runtime_notice_lock:
|
|
1349
|
+
if content not in self._pending_runtime_notices:
|
|
1350
|
+
self._pending_runtime_notices.append(content)
|
|
1351
|
+
|
|
1352
|
+
def _has_pending_runtime_notices(self) -> bool:
|
|
1353
|
+
with self._runtime_notice_lock:
|
|
1354
|
+
return bool(self._pending_runtime_notices)
|
|
1355
|
+
|
|
1356
|
+
def _consume_runtime_notices(self) -> list[str]:
|
|
1357
|
+
with self._runtime_notice_lock:
|
|
1358
|
+
notices = list(self._pending_runtime_notices)
|
|
1359
|
+
self._pending_runtime_notices.clear()
|
|
1360
|
+
return notices
|
|
1361
|
+
|
|
1362
|
+
def _handle_llm_chunk(
|
|
1363
|
+
self,
|
|
1364
|
+
state: StreamingContentState,
|
|
1365
|
+
chunk_type: str,
|
|
1366
|
+
text: str,
|
|
1367
|
+
) -> None:
|
|
1368
|
+
self._raise_if_interrupt_requested()
|
|
1369
|
+
if chunk_type == "content":
|
|
1370
|
+
state.saw_content_chunks = True
|
|
1371
|
+
for normalized_type, normalized_text in state.think_parser.feed(text):
|
|
1372
|
+
if normalized_type == "thinking":
|
|
1373
|
+
self._handle_streaming_thinking_chunk(state, normalized_text)
|
|
1374
|
+
else:
|
|
1375
|
+
self._handle_streaming_content_chunk(state, normalized_text)
|
|
1376
|
+
return
|
|
1377
|
+
if chunk_type != "thinking":
|
|
1378
|
+
return
|
|
1379
|
+
self._handle_streaming_thinking_chunk(state, text)
|
|
1380
|
+
|
|
1381
|
+
def _handle_streaming_content_chunk(
|
|
1382
|
+
self,
|
|
1383
|
+
state: StreamingContentState,
|
|
1384
|
+
text: str,
|
|
1385
|
+
) -> None:
|
|
1386
|
+
if not text:
|
|
1387
|
+
return
|
|
1388
|
+
state.content_buffer += text
|
|
1389
|
+
delta = ContentDelta(text=text)
|
|
1390
|
+
event_bus.emit(
|
|
1391
|
+
Event(
|
|
1392
|
+
type=EventType.HISTORY_ENTRY_DELTA,
|
|
1393
|
+
agent_id=self.uuid,
|
|
1394
|
+
data=delta.serialize(),
|
|
1395
|
+
),
|
|
1396
|
+
)
|
|
1397
|
+
if self.node_type == NodeType.ASSISTANT:
|
|
1398
|
+
state.emitted_human_content = True
|
|
1399
|
+
event_bus.emit(
|
|
1400
|
+
Event(
|
|
1401
|
+
type=EventType.ASSISTANT_CONTENT,
|
|
1402
|
+
agent_id=self.uuid,
|
|
1403
|
+
data={"content": text},
|
|
1404
|
+
),
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
def _handle_streaming_thinking_chunk(
|
|
1408
|
+
self,
|
|
1409
|
+
state: StreamingContentState,
|
|
1410
|
+
text: str,
|
|
1411
|
+
) -> None:
|
|
1412
|
+
if not text:
|
|
1413
|
+
return
|
|
1414
|
+
state.thinking_buffer += text
|
|
1415
|
+
delta = ThinkingDelta(text=text)
|
|
1416
|
+
event_bus.emit(
|
|
1417
|
+
Event(
|
|
1418
|
+
type=EventType.HISTORY_ENTRY_DELTA,
|
|
1419
|
+
agent_id=self.uuid,
|
|
1420
|
+
data=delta.serialize(),
|
|
1421
|
+
),
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
def _flush_streaming_think_parser(self, state: StreamingContentState) -> None:
|
|
1425
|
+
for normalized_type, normalized_text in state.think_parser.flush():
|
|
1426
|
+
if normalized_type == "thinking":
|
|
1427
|
+
self._handle_streaming_thinking_chunk(state, normalized_text)
|
|
1428
|
+
else:
|
|
1429
|
+
self._handle_streaming_content_chunk(state, normalized_text)
|
|
1430
|
+
|
|
1431
|
+
def _deliver_message(
|
|
1432
|
+
self,
|
|
1433
|
+
target: Agent,
|
|
1434
|
+
parts: list[TextPart | ImagePart],
|
|
1435
|
+
message_id: str,
|
|
1436
|
+
) -> None:
|
|
1437
|
+
content_preview = content_parts_to_text(parts)
|
|
1438
|
+
target._append_history(
|
|
1439
|
+
ReceivedMessage(
|
|
1440
|
+
from_id=self.uuid,
|
|
1441
|
+
parts=parts,
|
|
1442
|
+
message_id=message_id,
|
|
1443
|
+
)
|
|
1444
|
+
)
|
|
1445
|
+
target.enqueue_message(
|
|
1446
|
+
Message(
|
|
1447
|
+
from_id=self.uuid,
|
|
1448
|
+
to_id=target.uuid,
|
|
1449
|
+
parts=parts,
|
|
1450
|
+
message_id=message_id,
|
|
1451
|
+
history_recorded=True,
|
|
1452
|
+
)
|
|
1453
|
+
)
|
|
1454
|
+
self._log.debug(
|
|
1455
|
+
"Message sent: {} -> {} ({} chars)",
|
|
1456
|
+
self.uuid[:8],
|
|
1457
|
+
target.uuid[:8],
|
|
1458
|
+
len(content_preview),
|
|
1459
|
+
)
|
|
1460
|
+
event_bus.emit(
|
|
1461
|
+
Event(
|
|
1462
|
+
type=EventType.NODE_MESSAGE,
|
|
1463
|
+
agent_id=self.uuid,
|
|
1464
|
+
data={
|
|
1465
|
+
"to_id": target.uuid,
|
|
1466
|
+
"content": content_preview,
|
|
1467
|
+
"message_id": message_id,
|
|
1468
|
+
},
|
|
1469
|
+
),
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
@staticmethod
|
|
1473
|
+
def _parts_value(parts: list[TextPart | ImagePart]) -> list[dict[str, Any]]:
|
|
1474
|
+
return [part.serialize() for part in parts]
|
|
1475
|
+
|
|
1476
|
+
def _record_text_output(
|
|
1477
|
+
self,
|
|
1478
|
+
content: str,
|
|
1479
|
+
*,
|
|
1480
|
+
emitted_human_content: bool,
|
|
1481
|
+
) -> None:
|
|
1482
|
+
normalized_content, normalized_thinking = split_thinking_content(content)
|
|
1483
|
+
|
|
1484
|
+
if normalized_thinking.strip():
|
|
1485
|
+
self._mark_turn_progress()
|
|
1486
|
+
self._append_history(AssistantThinking(content=normalized_thinking))
|
|
1487
|
+
|
|
1488
|
+
plain_content = normalized_content
|
|
1489
|
+
if not plain_content.strip():
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
self._record_content_parts_output(
|
|
1493
|
+
[TextPart(text=plain_content)],
|
|
1494
|
+
emitted_human_content=emitted_human_content,
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
def _record_content_output(
|
|
1498
|
+
self,
|
|
1499
|
+
content: str,
|
|
1500
|
+
*,
|
|
1501
|
+
emitted_human_content: bool,
|
|
1502
|
+
) -> None:
|
|
1503
|
+
self._record_text_output(
|
|
1504
|
+
content,
|
|
1505
|
+
emitted_human_content=emitted_human_content,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
def _normalize_llm_output_parts(
|
|
1509
|
+
self,
|
|
1510
|
+
parts: list[LLMOutputTextPart | LLMOutputImagePart],
|
|
1511
|
+
) -> list[TextPart | ImagePart]:
|
|
1512
|
+
normalized: list[TextPart | ImagePart] = []
|
|
1513
|
+
for part in parts:
|
|
1514
|
+
if isinstance(part, LLMOutputTextPart):
|
|
1515
|
+
normalized_content, normalized_thinking = split_thinking_content(
|
|
1516
|
+
part.text
|
|
1517
|
+
)
|
|
1518
|
+
if normalized_thinking.strip():
|
|
1519
|
+
self._append_history(AssistantThinking(content=normalized_thinking))
|
|
1520
|
+
if normalized_content:
|
|
1521
|
+
normalized.append(TextPart(text=normalized_content))
|
|
1522
|
+
continue
|
|
1523
|
+
if not self.supports_output_image():
|
|
1524
|
+
raise ContextPreflightError(
|
|
1525
|
+
"Current model does not support `output_image`."
|
|
1526
|
+
)
|
|
1527
|
+
asset = create_image_asset(part.data, mime_type=part.mime_type)
|
|
1528
|
+
normalized.append(
|
|
1529
|
+
ImagePart(
|
|
1530
|
+
asset_id=asset.id,
|
|
1531
|
+
mime_type=asset.mime_type,
|
|
1532
|
+
width=part.width or asset.width,
|
|
1533
|
+
height=part.height or asset.height,
|
|
1534
|
+
)
|
|
1535
|
+
)
|
|
1536
|
+
return normalized
|
|
1537
|
+
|
|
1538
|
+
def _record_content_parts_output(
|
|
1539
|
+
self,
|
|
1540
|
+
parts: list[TextPart | ImagePart],
|
|
1541
|
+
*,
|
|
1542
|
+
emitted_human_content: bool,
|
|
1543
|
+
) -> None:
|
|
1544
|
+
normalized_parts = [
|
|
1545
|
+
part for part in parts if not isinstance(part, TextPart) or part.text
|
|
1546
|
+
]
|
|
1547
|
+
if not normalized_parts:
|
|
1548
|
+
return
|
|
1549
|
+
|
|
1550
|
+
visible_text = "".join(
|
|
1551
|
+
part.text for part in normalized_parts if isinstance(part, TextPart)
|
|
1552
|
+
)
|
|
1553
|
+
content_preview = content_parts_to_text(normalized_parts)
|
|
1554
|
+
self._mark_turn_progress()
|
|
1555
|
+
|
|
1556
|
+
if (
|
|
1557
|
+
self.node_type == NodeType.ASSISTANT
|
|
1558
|
+
and not emitted_human_content
|
|
1559
|
+
and visible_text.strip()
|
|
1560
|
+
):
|
|
1561
|
+
event_bus.emit(
|
|
1562
|
+
Event(
|
|
1563
|
+
type=EventType.ASSISTANT_CONTENT,
|
|
1564
|
+
agent_id=self.uuid,
|
|
1565
|
+
data={"content": visible_text},
|
|
1566
|
+
),
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
self._append_history(
|
|
1570
|
+
AssistantText(
|
|
1571
|
+
parts=normalized_parts,
|
|
1572
|
+
content=content_preview,
|
|
1573
|
+
)
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
def _resolve_contact_target(self, target_ref: str) -> Agent:
|
|
1577
|
+
from flowent.graph_runtime import resolve_node_ref
|
|
1578
|
+
|
|
1579
|
+
target = resolve_node_ref(target_ref)
|
|
1580
|
+
if target is None:
|
|
1581
|
+
raise ValueError(f"Send failed: target `{target_ref}` was not found.")
|
|
1582
|
+
if not self.can_contact(target.uuid):
|
|
1583
|
+
raise ValueError(f"Send failed: target `{target_ref}` is not in contacts.")
|
|
1584
|
+
return target
|
|
1585
|
+
|
|
1586
|
+
def _is_entry_level_sender(self) -> bool:
|
|
1587
|
+
if self.node_type == NodeType.ASSISTANT:
|
|
1588
|
+
return True
|
|
1589
|
+
if self.config.tab_id is None:
|
|
1590
|
+
return False
|
|
1591
|
+
from flowent.graph_service import is_tab_leader
|
|
1592
|
+
|
|
1593
|
+
return is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id)
|
|
1594
|
+
|
|
1595
|
+
def _resolve_port_send_path(
|
|
1596
|
+
self,
|
|
1597
|
+
*,
|
|
1598
|
+
target_ref: str,
|
|
1599
|
+
from_output_port_key: str,
|
|
1600
|
+
to_input_port_key: str,
|
|
1601
|
+
):
|
|
1602
|
+
if self.config.tab_id is None:
|
|
1603
|
+
raise ValueError("Send failed: node is not part of a workflow.")
|
|
1604
|
+
from flowent.graph_service import (
|
|
1605
|
+
resolve_agent_contact_path,
|
|
1606
|
+
resolve_workflow_node_ref,
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
target_id = resolve_workflow_node_ref(
|
|
1610
|
+
tab_id=self.config.tab_id,
|
|
1611
|
+
node_ref=target_ref,
|
|
1612
|
+
)
|
|
1613
|
+
if target_id is None:
|
|
1614
|
+
raise ValueError(f"Send failed: target `{target_ref}` was not found.")
|
|
1615
|
+
path = resolve_agent_contact_path(
|
|
1616
|
+
tab_id=self.config.tab_id,
|
|
1617
|
+
source_node_id=self.uuid,
|
|
1618
|
+
target_node_id=target_id,
|
|
1619
|
+
from_output_port_key=from_output_port_key,
|
|
1620
|
+
to_input_port_key=to_input_port_key,
|
|
1621
|
+
)
|
|
1622
|
+
if path is None:
|
|
1623
|
+
raise ValueError(
|
|
1624
|
+
f"Send failed: target `{target_ref}` is not connected from `{from_output_port_key}` to `{to_input_port_key}`."
|
|
1625
|
+
)
|
|
1626
|
+
return path
|
|
1627
|
+
|
|
1628
|
+
def _ensure_can_dispatch_to_contact(self, target: Agent) -> None:
|
|
1629
|
+
if self.config.tab_id is None:
|
|
1630
|
+
return
|
|
1631
|
+
|
|
1632
|
+
from flowent.graph_service import is_tab_leader
|
|
1633
|
+
from flowent.workspace_store import workspace_store
|
|
1634
|
+
|
|
1635
|
+
if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
|
|
1636
|
+
return
|
|
1637
|
+
if target.node_type != NodeType.AGENT:
|
|
1638
|
+
return
|
|
1639
|
+
if is_tab_leader(node_id=target.uuid, tab_id=target.config.tab_id):
|
|
1640
|
+
return
|
|
1641
|
+
|
|
1642
|
+
tab = workspace_store.get_tab(self.config.tab_id)
|
|
1643
|
+
if tab is not None and tab.activation_state != WorkflowActivationState.ACTIVE:
|
|
1644
|
+
raise ValueError(
|
|
1645
|
+
"Activate this workflow before sending work to agent nodes."
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
def supports_input_image(self) -> bool:
|
|
1649
|
+
_, model_info = self._get_effective_model_info()
|
|
1650
|
+
if model_info is None:
|
|
1651
|
+
return False
|
|
1652
|
+
return model_info.capabilities.input_image
|
|
1653
|
+
|
|
1654
|
+
def supports_output_image(self) -> bool:
|
|
1655
|
+
_, model_info = self._get_effective_model_info()
|
|
1656
|
+
if model_info is None:
|
|
1657
|
+
return False
|
|
1658
|
+
return model_info.capabilities.output_image
|
|
1659
|
+
|
|
1660
|
+
def send_message(
|
|
1661
|
+
self,
|
|
1662
|
+
*,
|
|
1663
|
+
target_ref: str,
|
|
1664
|
+
raw_parts: Any,
|
|
1665
|
+
from_output_port_key: str | None = None,
|
|
1666
|
+
to_input_port_key: str | None = None,
|
|
1667
|
+
raw_value: Any | None = None,
|
|
1668
|
+
) -> str:
|
|
1669
|
+
if not self._is_entry_level_sender():
|
|
1670
|
+
return self.send_port_value(
|
|
1671
|
+
target_ref=target_ref,
|
|
1672
|
+
from_output_port_key=from_output_port_key,
|
|
1673
|
+
to_input_port_key=to_input_port_key,
|
|
1674
|
+
raw_value=raw_value if raw_value is not None else raw_parts,
|
|
1675
|
+
)
|
|
1676
|
+
parts = parse_content_parts_payload(raw_parts)
|
|
1677
|
+
target = self._resolve_contact_target(target_ref)
|
|
1678
|
+
self._ensure_can_dispatch_to_contact(target)
|
|
1679
|
+
if has_image_parts(parts) and not target.supports_input_image():
|
|
1680
|
+
raise ValueError(
|
|
1681
|
+
f"Send failed: target `{target_ref}` does not support `input_image`."
|
|
1682
|
+
)
|
|
1683
|
+
for part in parts:
|
|
1684
|
+
asset_id = getattr(part, "asset_id", None)
|
|
1685
|
+
if isinstance(asset_id, str):
|
|
1686
|
+
require_image_asset(asset_id)
|
|
1687
|
+
message_id = str(_uuid.uuid4())
|
|
1688
|
+
self._deliver_message(target, parts, message_id)
|
|
1689
|
+
self._mark_turn_progress()
|
|
1690
|
+
self._append_history(
|
|
1691
|
+
SentMessage(
|
|
1692
|
+
to_id=target.uuid,
|
|
1693
|
+
parts=parts,
|
|
1694
|
+
message_id=message_id,
|
|
1695
|
+
)
|
|
1696
|
+
)
|
|
1697
|
+
return json.dumps({"status": "sent", "target_id": target.uuid})
|
|
1698
|
+
|
|
1699
|
+
def send_port_value(
|
|
1700
|
+
self,
|
|
1701
|
+
*,
|
|
1702
|
+
target_ref: str,
|
|
1703
|
+
from_output_port_key: str | None,
|
|
1704
|
+
to_input_port_key: str | None,
|
|
1705
|
+
raw_value: Any,
|
|
1706
|
+
) -> str:
|
|
1707
|
+
if self._is_entry_level_sender():
|
|
1708
|
+
raise ValueError("Send failed: entry contacts use target and parts.")
|
|
1709
|
+
if (
|
|
1710
|
+
not isinstance(from_output_port_key, str)
|
|
1711
|
+
or not from_output_port_key.strip()
|
|
1712
|
+
):
|
|
1713
|
+
raise ValueError("send.from_output_port_key must be a non-empty string")
|
|
1714
|
+
if not isinstance(to_input_port_key, str) or not to_input_port_key.strip():
|
|
1715
|
+
raise ValueError("send.to_input_port_key must be a non-empty string")
|
|
1716
|
+
|
|
1717
|
+
path = self._resolve_port_send_path(
|
|
1718
|
+
target_ref=target_ref,
|
|
1719
|
+
from_output_port_key=from_output_port_key.strip(),
|
|
1720
|
+
to_input_port_key=to_input_port_key.strip(),
|
|
1721
|
+
)
|
|
1722
|
+
value = raw_value
|
|
1723
|
+
if path.port_type == "parts":
|
|
1724
|
+
parts = parse_content_parts_payload(raw_value)
|
|
1725
|
+
value = self._parts_value(parts)
|
|
1726
|
+
if path.target_node_type == WorkflowNodeKind.AGENT.value:
|
|
1727
|
+
target = self._resolve_contact_target(path.target_id)
|
|
1728
|
+
if has_image_parts(parts) and not target.supports_input_image():
|
|
1729
|
+
raise ValueError(
|
|
1730
|
+
f"Send failed: target `{target_ref}` does not support `input_image`."
|
|
1731
|
+
)
|
|
1732
|
+
for part in parts:
|
|
1733
|
+
asset_id = getattr(part, "asset_id", None)
|
|
1734
|
+
if isinstance(asset_id, str):
|
|
1735
|
+
require_image_asset(asset_id)
|
|
1736
|
+
|
|
1737
|
+
message_id = str(_uuid.uuid4())
|
|
1738
|
+
from flowent.graph_service import dispatch_port_value
|
|
1739
|
+
|
|
1740
|
+
payload, error = dispatch_port_value(
|
|
1741
|
+
tab_id=self.config.tab_id or "",
|
|
1742
|
+
source_node_id=self.uuid,
|
|
1743
|
+
source_output_port_key=path.from_output_port_key,
|
|
1744
|
+
target_node_id=path.target_id,
|
|
1745
|
+
target_input_port_key=path.to_input_port_key,
|
|
1746
|
+
value=value,
|
|
1747
|
+
source_is_agent_send=True,
|
|
1748
|
+
message_id=message_id,
|
|
1749
|
+
)
|
|
1750
|
+
if error is not None or payload is None:
|
|
1751
|
+
raise ValueError(f"Send failed: {error or 'path unavailable'}.")
|
|
1752
|
+
|
|
1753
|
+
self._mark_turn_progress()
|
|
1754
|
+
sent_parts = (
|
|
1755
|
+
parse_content_parts_payload(value) if path.port_type == "parts" else []
|
|
1756
|
+
)
|
|
1757
|
+
value_summary = str(payload.get("value_summary", ""))
|
|
1758
|
+
self._append_history(
|
|
1759
|
+
SentMessage(
|
|
1760
|
+
to_id=path.target_id,
|
|
1761
|
+
parts=sent_parts,
|
|
1762
|
+
content=value_summary if path.port_type != "parts" else "",
|
|
1763
|
+
message_id=message_id,
|
|
1764
|
+
from_output_port_key=path.from_output_port_key,
|
|
1765
|
+
to_input_port_key=path.to_input_port_key,
|
|
1766
|
+
value_summary=value_summary,
|
|
1767
|
+
)
|
|
1768
|
+
)
|
|
1769
|
+
return json.dumps(payload)
|
|
1770
|
+
|
|
1771
|
+
def _mark_turn_progress(self) -> None:
|
|
1772
|
+
self._turn_made_progress = True
|
|
1773
|
+
self._pending_input_turn = False
|
|
1774
|
+
|
|
1775
|
+
def _get_first_actionable_todo(self) -> str | None:
|
|
1776
|
+
with self._todos_lock:
|
|
1777
|
+
if not self.todos:
|
|
1778
|
+
return None
|
|
1779
|
+
first_todo = self.todos[0].text.strip()
|
|
1780
|
+
if not first_todo:
|
|
1781
|
+
return None
|
|
1782
|
+
normalized = first_todo.lower()
|
|
1783
|
+
waiting_prefixes = (
|
|
1784
|
+
"wait",
|
|
1785
|
+
"await",
|
|
1786
|
+
"waiting",
|
|
1787
|
+
"awaiting",
|
|
1788
|
+
"monitor",
|
|
1789
|
+
"listen",
|
|
1790
|
+
"idle",
|
|
1791
|
+
"sleep",
|
|
1792
|
+
)
|
|
1793
|
+
if normalized.startswith(waiting_prefixes) or first_todo.startswith(
|
|
1794
|
+
("等待", "等候", "监听", "休眠", "空闲")
|
|
1795
|
+
):
|
|
1796
|
+
return None
|
|
1797
|
+
return first_todo
|
|
1798
|
+
|
|
1799
|
+
def _raise_if_interrupt_requested(self) -> None:
|
|
1800
|
+
if self._interrupt_requested.is_set():
|
|
1801
|
+
raise InterruptRequestedError()
|
|
1802
|
+
|
|
1803
|
+
def _get_llm_retry_policy(self) -> str:
|
|
1804
|
+
return get_settings().model.retry_policy
|
|
1805
|
+
|
|
1806
|
+
def _get_llm_max_retries(self) -> int:
|
|
1807
|
+
settings = get_settings()
|
|
1808
|
+
if settings.model.retry_policy != "limited":
|
|
1809
|
+
return 0
|
|
1810
|
+
return settings.model.max_retries
|
|
1811
|
+
|
|
1812
|
+
def _get_llm_retry_delay(self, retry_number: int) -> float:
|
|
1813
|
+
settings = get_settings()
|
|
1814
|
+
capped_retry_number = min(
|
|
1815
|
+
max(retry_number - 1, 0),
|
|
1816
|
+
settings.model.retry_backoff_cap_retries - 1,
|
|
1817
|
+
)
|
|
1818
|
+
return min(
|
|
1819
|
+
settings.model.retry_max_delay_seconds,
|
|
1820
|
+
settings.model.retry_initial_delay_seconds * (2**capped_retry_number),
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
def _get_llm_retry_429_delay(self, status_code: int | None) -> float:
|
|
1824
|
+
if status_code != 429:
|
|
1825
|
+
return 0.0
|
|
1826
|
+
from flowent.settings import find_provider, find_role
|
|
1827
|
+
|
|
1828
|
+
settings = get_settings()
|
|
1829
|
+
provider_id = settings.model.active_provider_id
|
|
1830
|
+
role_cfg = (
|
|
1831
|
+
find_role(settings, self.config.role_name)
|
|
1832
|
+
if self.config.role_name
|
|
1833
|
+
else None
|
|
1834
|
+
)
|
|
1835
|
+
if (
|
|
1836
|
+
role_cfg is not None
|
|
1837
|
+
and role_cfg.model is not None
|
|
1838
|
+
and role_cfg.model.provider_id
|
|
1839
|
+
and role_cfg.model.model
|
|
1840
|
+
):
|
|
1841
|
+
provider_id = role_cfg.model.provider_id
|
|
1842
|
+
if not provider_id:
|
|
1843
|
+
return 0.0
|
|
1844
|
+
provider = find_provider(settings, provider_id)
|
|
1845
|
+
if provider is None:
|
|
1846
|
+
return 0.0
|
|
1847
|
+
return float(provider.retry_429_delay_seconds)
|
|
1848
|
+
|
|
1849
|
+
def _wait_for_llm_retry_delay(self, delay_seconds: float) -> None:
|
|
1850
|
+
self.set_interrupt_callback(self._interrupt_requested.set)
|
|
1851
|
+
try:
|
|
1852
|
+
if self._interrupt_requested.wait(max(delay_seconds, 0.0)):
|
|
1853
|
+
raise InterruptRequestedError()
|
|
1854
|
+
finally:
|
|
1855
|
+
self.set_interrupt_callback(None)
|
|
1856
|
+
|
|
1857
|
+
def _chat_with_retries(
|
|
1858
|
+
self,
|
|
1859
|
+
*,
|
|
1860
|
+
prepared_context: PreparedLLMContext,
|
|
1861
|
+
tools_schema: list[dict[str, Any]] | None,
|
|
1862
|
+
) -> tuple[LLMResponse, StreamingContentState]:
|
|
1863
|
+
retry_policy = self._get_llm_retry_policy()
|
|
1864
|
+
retry_limit = self._get_llm_max_retries()
|
|
1865
|
+
retry_count = 0
|
|
1866
|
+
started_at = _time.time()
|
|
1867
|
+
self._raise_if_llm_network_disallowed()
|
|
1868
|
+
|
|
1869
|
+
while True:
|
|
1870
|
+
stream_state = StreamingContentState()
|
|
1871
|
+
try:
|
|
1872
|
+
try:
|
|
1873
|
+
messages = prepared_context.messages
|
|
1874
|
+
response = gateway.chat(
|
|
1875
|
+
messages=messages,
|
|
1876
|
+
tools=tools_schema or None,
|
|
1877
|
+
on_chunk=partial(self._handle_llm_chunk, stream_state),
|
|
1878
|
+
register_interrupt=self.set_interrupt_callback,
|
|
1879
|
+
role_name=self.config.role_name,
|
|
1880
|
+
)
|
|
1881
|
+
except Exception:
|
|
1882
|
+
if self._interrupt_requested.is_set():
|
|
1883
|
+
raise InterruptRequestedError(stream_state) from None
|
|
1884
|
+
raise
|
|
1885
|
+
finally:
|
|
1886
|
+
self.set_interrupt_callback(None)
|
|
1887
|
+
self._raise_if_interrupt_requested()
|
|
1888
|
+
self._record_request_observability(
|
|
1889
|
+
started_at=started_at,
|
|
1890
|
+
ended_at=_time.time(),
|
|
1891
|
+
retry_count=retry_count,
|
|
1892
|
+
result="success",
|
|
1893
|
+
usage=response.usage,
|
|
1894
|
+
raw_usage=response.raw_usage,
|
|
1895
|
+
)
|
|
1896
|
+
self._record_context_token_usage_baseline(
|
|
1897
|
+
prepared_context=prepared_context,
|
|
1898
|
+
usage=response.usage,
|
|
1899
|
+
)
|
|
1900
|
+
return response, stream_state
|
|
1901
|
+
except InterruptRequestedError:
|
|
1902
|
+
raise
|
|
1903
|
+
except LLMProviderError as exc:
|
|
1904
|
+
should_retry = False
|
|
1905
|
+
if exc.transient:
|
|
1906
|
+
if retry_policy == "limited":
|
|
1907
|
+
should_retry = retry_count < retry_limit
|
|
1908
|
+
elif retry_policy == "unlimited":
|
|
1909
|
+
should_retry = True
|
|
1910
|
+
if not should_retry:
|
|
1911
|
+
self._record_request_observability(
|
|
1912
|
+
started_at=started_at,
|
|
1913
|
+
ended_at=_time.time(),
|
|
1914
|
+
retry_count=retry_count,
|
|
1915
|
+
result="error",
|
|
1916
|
+
error_summary=str(exc),
|
|
1917
|
+
)
|
|
1918
|
+
raise
|
|
1919
|
+
retry_count += 1
|
|
1920
|
+
delay_seconds = self._get_llm_retry_delay(
|
|
1921
|
+
retry_count
|
|
1922
|
+
) + self._get_llm_retry_429_delay(exc.status_code)
|
|
1923
|
+
self._log.warning(
|
|
1924
|
+
"Transient LLM error, retry {} ({}) in {:.2f}s: {}",
|
|
1925
|
+
retry_count,
|
|
1926
|
+
retry_policy,
|
|
1927
|
+
delay_seconds,
|
|
1928
|
+
exc,
|
|
1929
|
+
)
|
|
1930
|
+
self._wait_for_llm_retry_delay(delay_seconds)
|
|
1931
|
+
except Exception as exc:
|
|
1932
|
+
self._record_request_observability(
|
|
1933
|
+
started_at=started_at,
|
|
1934
|
+
ended_at=_time.time(),
|
|
1935
|
+
retry_count=retry_count,
|
|
1936
|
+
result="error",
|
|
1937
|
+
error_summary=str(exc),
|
|
1938
|
+
)
|
|
1939
|
+
raise
|
|
1940
|
+
|
|
1941
|
+
def _handle_interrupt(self, stream_state: StreamingContentState | None) -> None:
|
|
1942
|
+
if stream_state is not None:
|
|
1943
|
+
self._flush_streaming_think_parser(stream_state)
|
|
1944
|
+
if stream_state.thinking_buffer:
|
|
1945
|
+
self._append_history(
|
|
1946
|
+
AssistantThinking(content=stream_state.thinking_buffer),
|
|
1947
|
+
)
|
|
1948
|
+
if stream_state.content_buffer:
|
|
1949
|
+
self._record_text_output(
|
|
1950
|
+
stream_state.content_buffer,
|
|
1951
|
+
emitted_human_content=stream_state.emitted_human_content,
|
|
1952
|
+
)
|
|
1953
|
+
self._interrupt_requested.clear()
|
|
1954
|
+
self.set_interrupt_callback(None)
|
|
1955
|
+
self._pending_input_turn = False
|
|
1956
|
+
self.set_state(AgentState.IDLE, "interrupted by human")
|
|
1957
|
+
if self._pause_after_interrupt_requested.is_set():
|
|
1958
|
+
self._paused_for_command.set()
|
|
1959
|
+
self._resume_after_command.wait()
|
|
1960
|
+
self._paused_for_command.clear()
|
|
1961
|
+
self._resume_after_command.clear()
|
|
1962
|
+
self._wait_for_input()
|
|
1963
|
+
|
|
1964
|
+
def _generate_compacted_history_summary(
|
|
1965
|
+
self,
|
|
1966
|
+
*,
|
|
1967
|
+
focus: str | None = None,
|
|
1968
|
+
context_messages: list[dict[str, Any]],
|
|
1969
|
+
) -> str:
|
|
1970
|
+
if not context_messages:
|
|
1971
|
+
return "- No prior execution context was available to compact."
|
|
1972
|
+
|
|
1973
|
+
self._raise_if_llm_network_disallowed()
|
|
1974
|
+
|
|
1975
|
+
focus_text = focus.strip() if focus else ""
|
|
1976
|
+
request_lines = [
|
|
1977
|
+
"Compact this agent execution context into a durable markdown summary.",
|
|
1978
|
+
"Preserve only confirmed information.",
|
|
1979
|
+
"Keep the summary concise and directly reusable as future context.",
|
|
1980
|
+
"Use these sections in order:",
|
|
1981
|
+
"## Current Goal",
|
|
1982
|
+
"## Active Task Boundary",
|
|
1983
|
+
"## Key Constraints",
|
|
1984
|
+
"## Confirmed Decisions",
|
|
1985
|
+
"## Open Questions",
|
|
1986
|
+
"## Next Actions",
|
|
1987
|
+
]
|
|
1988
|
+
if focus_text:
|
|
1989
|
+
request_lines.append(f"Prioritize this focus: {focus_text}")
|
|
1990
|
+
request_lines.append("Return only the markdown summary.")
|
|
1991
|
+
|
|
1992
|
+
response = gateway.chat(
|
|
1993
|
+
messages=[
|
|
1994
|
+
{
|
|
1995
|
+
"role": "system",
|
|
1996
|
+
"content": (
|
|
1997
|
+
"You compress Assistant conversations into durable "
|
|
1998
|
+
"task summaries. Do not address the human. Do not "
|
|
1999
|
+
"invent facts. Keep the result tightly scoped to what "
|
|
2000
|
+
"future turns need."
|
|
2001
|
+
),
|
|
2002
|
+
},
|
|
2003
|
+
*context_messages,
|
|
2004
|
+
{"role": "user", "content": "\n".join(request_lines)},
|
|
2005
|
+
],
|
|
2006
|
+
tools=None,
|
|
2007
|
+
role_name=self.config.role_name,
|
|
2008
|
+
)
|
|
2009
|
+
summary = (response.content or response.thinking or "").strip()
|
|
2010
|
+
if not summary:
|
|
2011
|
+
raise RuntimeError("Assistant compact did not produce a summary")
|
|
2012
|
+
return summary
|
|
2013
|
+
|
|
2014
|
+
def _build_prepared_llm_context(self) -> PreparedLLMContext:
|
|
2015
|
+
system_messages = [
|
|
2016
|
+
{"role": "system", "content": get_system_prompt(self.config)}
|
|
2017
|
+
]
|
|
2018
|
+
with self._history_lock:
|
|
2019
|
+
history_snapshot = list(self.history)
|
|
2020
|
+
execution_context_messages = self._build_execution_context_messages(
|
|
2021
|
+
history_snapshot
|
|
2022
|
+
)
|
|
2023
|
+
runtime_tail_messages = self._build_runtime_tail_messages()
|
|
2024
|
+
return PreparedLLMContext(
|
|
2025
|
+
messages=[
|
|
2026
|
+
*system_messages,
|
|
2027
|
+
*execution_context_messages,
|
|
2028
|
+
*runtime_tail_messages,
|
|
2029
|
+
],
|
|
2030
|
+
system_messages=system_messages,
|
|
2031
|
+
execution_context_messages=execution_context_messages,
|
|
2032
|
+
runtime_tail_messages=runtime_tail_messages,
|
|
2033
|
+
)
|
|
2034
|
+
|
|
2035
|
+
def _build_messages(self) -> list[dict[str, Any]]:
|
|
2036
|
+
return self._build_prepared_llm_context().messages
|
|
2037
|
+
|
|
2038
|
+
def _build_execution_context_messages(
|
|
2039
|
+
self,
|
|
2040
|
+
history_snapshot: list[HistoryEntry],
|
|
2041
|
+
) -> list[dict[str, Any]]:
|
|
2042
|
+
with self._execution_context_lock:
|
|
2043
|
+
summary = self._execution_context_summary.strip()
|
|
2044
|
+
history_cutoff = min(
|
|
2045
|
+
self._execution_context_history_cutoff,
|
|
2046
|
+
len(history_snapshot),
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
messages: list[dict[str, Any]] = []
|
|
2050
|
+
if summary:
|
|
2051
|
+
messages.append(
|
|
2052
|
+
self._build_runtime_system_message(
|
|
2053
|
+
f"Compacted execution context:\n{summary}"
|
|
2054
|
+
)
|
|
2055
|
+
)
|
|
2056
|
+
messages.extend(self._build_history_messages(history_snapshot[history_cutoff:]))
|
|
2057
|
+
return messages
|
|
2058
|
+
|
|
2059
|
+
@staticmethod
|
|
2060
|
+
def _serialize_context_parts(
|
|
2061
|
+
parts: list[TextPart | ImagePart],
|
|
2062
|
+
) -> str | list[dict[str, Any]]:
|
|
2063
|
+
if all(isinstance(part, TextPart) for part in parts):
|
|
2064
|
+
text_parts = [part for part in parts if isinstance(part, TextPart)]
|
|
2065
|
+
return "".join(part.text for part in text_parts)
|
|
2066
|
+
return [part.serialize() for part in parts]
|
|
2067
|
+
|
|
2068
|
+
@classmethod
|
|
2069
|
+
def _wrap_context_parts(
|
|
2070
|
+
cls,
|
|
2071
|
+
parts: list[TextPart | ImagePart],
|
|
2072
|
+
*,
|
|
2073
|
+
prefix: str,
|
|
2074
|
+
suffix: str = "",
|
|
2075
|
+
) -> str | list[dict[str, Any]]:
|
|
2076
|
+
if all(isinstance(part, TextPart) for part in parts):
|
|
2077
|
+
text_parts = [part for part in parts if isinstance(part, TextPart)]
|
|
2078
|
+
return prefix + "".join(part.text for part in text_parts) + suffix
|
|
2079
|
+
wrapped: list[TextPart | ImagePart] = [TextPart(text=prefix), *parts]
|
|
2080
|
+
if suffix:
|
|
2081
|
+
wrapped.append(TextPart(text=suffix))
|
|
2082
|
+
return cls._serialize_context_parts(wrapped)
|
|
2083
|
+
|
|
2084
|
+
def _build_history_messages(
|
|
2085
|
+
self,
|
|
2086
|
+
history_snapshot: list[HistoryEntry],
|
|
2087
|
+
) -> list[dict[str, Any]]:
|
|
2088
|
+
messages: list[dict[str, Any]] = []
|
|
2089
|
+
pending_tool_calls: list[dict[str, Any]] = []
|
|
2090
|
+
|
|
2091
|
+
for entry in history_snapshot:
|
|
2092
|
+
if isinstance(entry, SystemEntry):
|
|
2093
|
+
continue
|
|
2094
|
+
|
|
2095
|
+
elif isinstance(entry, ReceivedMessage):
|
|
2096
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2097
|
+
if entry.from_output_port_key and entry.to_input_port_key:
|
|
2098
|
+
prefix = (
|
|
2099
|
+
f'<message from="{entry.from_id}" '
|
|
2100
|
+
f'from_output="{entry.from_output_port_key}" '
|
|
2101
|
+
f'to_input="{entry.to_input_port_key}">'
|
|
2102
|
+
)
|
|
2103
|
+
else:
|
|
2104
|
+
prefix = f'<message from="{entry.from_id}">'
|
|
2105
|
+
messages.append(
|
|
2106
|
+
{
|
|
2107
|
+
"role": "user",
|
|
2108
|
+
"content": self._wrap_context_parts(
|
|
2109
|
+
entry.parts,
|
|
2110
|
+
prefix=prefix,
|
|
2111
|
+
suffix="</message>",
|
|
2112
|
+
),
|
|
2113
|
+
}
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
elif isinstance(entry, AssistantText):
|
|
2117
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2118
|
+
messages.append(
|
|
2119
|
+
{
|
|
2120
|
+
"role": "assistant",
|
|
2121
|
+
"content": self._serialize_context_parts(entry.parts),
|
|
2122
|
+
}
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
elif isinstance(entry, SentMessage):
|
|
2126
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2127
|
+
if entry.from_output_port_key and entry.to_input_port_key:
|
|
2128
|
+
prefix = (
|
|
2129
|
+
f'<message to="{entry.to_id}" '
|
|
2130
|
+
f'from_output="{entry.from_output_port_key}" '
|
|
2131
|
+
f'to_input="{entry.to_input_port_key}">'
|
|
2132
|
+
)
|
|
2133
|
+
else:
|
|
2134
|
+
prefix = f'<message to="{entry.to_id}">'
|
|
2135
|
+
messages.append(
|
|
2136
|
+
{
|
|
2137
|
+
"role": "assistant",
|
|
2138
|
+
"content": self._wrap_context_parts(
|
|
2139
|
+
entry.parts,
|
|
2140
|
+
prefix=prefix,
|
|
2141
|
+
suffix="</message>",
|
|
2142
|
+
),
|
|
2143
|
+
}
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
elif isinstance(entry, AssistantThinking):
|
|
2147
|
+
pass
|
|
2148
|
+
|
|
2149
|
+
elif isinstance(entry, ToolCall):
|
|
2150
|
+
if entry.streaming:
|
|
2151
|
+
continue
|
|
2152
|
+
|
|
2153
|
+
pending_tool_calls.append(
|
|
2154
|
+
{
|
|
2155
|
+
"id": entry.tool_call_id,
|
|
2156
|
+
"type": "function",
|
|
2157
|
+
"function": {
|
|
2158
|
+
"name": entry.tool_name,
|
|
2159
|
+
"arguments": json.dumps(entry.arguments)
|
|
2160
|
+
if entry.arguments
|
|
2161
|
+
else "{}",
|
|
2162
|
+
},
|
|
2163
|
+
}
|
|
2164
|
+
)
|
|
2165
|
+
|
|
2166
|
+
if entry.result is not None:
|
|
2167
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2168
|
+
messages.append(
|
|
2169
|
+
{
|
|
2170
|
+
"role": "tool",
|
|
2171
|
+
"tool_call_id": entry.tool_call_id,
|
|
2172
|
+
"content": entry.result,
|
|
2173
|
+
}
|
|
2174
|
+
)
|
|
2175
|
+
|
|
2176
|
+
elif isinstance(entry, ErrorEntry):
|
|
2177
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2178
|
+
messages.append(
|
|
2179
|
+
{
|
|
2180
|
+
"role": "user",
|
|
2181
|
+
"content": build_error_context(entry.content),
|
|
2182
|
+
}
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
elif isinstance(entry, CommandResultEntry):
|
|
2186
|
+
if not entry.include_in_context or entry.command_name == "/compact":
|
|
2187
|
+
continue
|
|
2188
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2189
|
+
messages.append(self._build_runtime_system_message(entry.content))
|
|
2190
|
+
|
|
2191
|
+
elif isinstance(entry, PortInboundEntry):
|
|
2192
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2193
|
+
messages.append(
|
|
2194
|
+
self._build_runtime_system_message(
|
|
2195
|
+
"Port input received: "
|
|
2196
|
+
f"from {entry.from_id}.{entry.from_output_port_key} "
|
|
2197
|
+
f"to {entry.to_input_port_key} "
|
|
2198
|
+
f"({entry.port_type})\n"
|
|
2199
|
+
f"{json.dumps(entry.value, ensure_ascii=False, sort_keys=True)}"
|
|
2200
|
+
)
|
|
2201
|
+
)
|
|
2202
|
+
|
|
2203
|
+
self._flush_tool_calls(messages, pending_tool_calls)
|
|
2204
|
+
return messages
|
|
2205
|
+
|
|
2206
|
+
@staticmethod
|
|
2207
|
+
def _estimate_text_tokens(text: str) -> int:
|
|
2208
|
+
stripped = text.strip()
|
|
2209
|
+
if not stripped:
|
|
2210
|
+
return 0
|
|
2211
|
+
return max(1, (len(stripped) + 3) // 4)
|
|
2212
|
+
|
|
2213
|
+
@classmethod
|
|
2214
|
+
def _estimate_message_tokens(cls, message: dict[str, Any]) -> int:
|
|
2215
|
+
total = cls._estimate_text_tokens(str(message.get("role", "")))
|
|
2216
|
+
content = message.get("content")
|
|
2217
|
+
if isinstance(content, str):
|
|
2218
|
+
total += cls._estimate_text_tokens(content)
|
|
2219
|
+
elif content is not None:
|
|
2220
|
+
total += cls._estimate_text_tokens(json.dumps(content, ensure_ascii=False))
|
|
2221
|
+
tool_calls = message.get("tool_calls")
|
|
2222
|
+
if isinstance(tool_calls, list):
|
|
2223
|
+
for tool_call in tool_calls:
|
|
2224
|
+
if not isinstance(tool_call, dict):
|
|
2225
|
+
continue
|
|
2226
|
+
function = tool_call.get("function")
|
|
2227
|
+
if not isinstance(function, dict):
|
|
2228
|
+
continue
|
|
2229
|
+
total += cls._estimate_text_tokens(str(function.get("name", "")))
|
|
2230
|
+
total += cls._estimate_text_tokens(str(function.get("arguments", "")))
|
|
2231
|
+
return total
|
|
2232
|
+
|
|
2233
|
+
@classmethod
|
|
2234
|
+
def _estimate_input_tokens(cls, messages: list[dict[str, Any]]) -> int:
|
|
2235
|
+
return sum(cls._estimate_message_tokens(message) for message in messages)
|
|
2236
|
+
|
|
2237
|
+
def _estimate_tokens_from_usage_baseline(
|
|
2238
|
+
self,
|
|
2239
|
+
prepared_context: PreparedLLMContext,
|
|
2240
|
+
) -> int | None:
|
|
2241
|
+
with self._execution_context_lock:
|
|
2242
|
+
baseline = self._context_token_usage_baseline
|
|
2243
|
+
|
|
2244
|
+
if baseline is None:
|
|
2245
|
+
return None
|
|
2246
|
+
if baseline.system_messages != prepared_context.system_messages:
|
|
2247
|
+
return None
|
|
2248
|
+
if baseline.runtime_tail_messages != prepared_context.runtime_tail_messages:
|
|
2249
|
+
return None
|
|
2250
|
+
|
|
2251
|
+
baseline_execution_messages = baseline.execution_context_messages
|
|
2252
|
+
current_execution_messages = prepared_context.execution_context_messages
|
|
2253
|
+
if len(current_execution_messages) < len(baseline_execution_messages):
|
|
2254
|
+
return None
|
|
2255
|
+
if (
|
|
2256
|
+
current_execution_messages[: len(baseline_execution_messages)]
|
|
2257
|
+
!= baseline_execution_messages
|
|
2258
|
+
):
|
|
2259
|
+
return None
|
|
2260
|
+
|
|
2261
|
+
tail_messages = current_execution_messages[len(baseline_execution_messages) :]
|
|
2262
|
+
return baseline.usage.total_tokens + self._estimate_input_tokens(tail_messages)
|
|
2263
|
+
|
|
2264
|
+
def _record_context_token_usage_baseline(
|
|
2265
|
+
self,
|
|
2266
|
+
*,
|
|
2267
|
+
prepared_context: PreparedLLMContext,
|
|
2268
|
+
usage: LLMUsage | None,
|
|
2269
|
+
) -> None:
|
|
2270
|
+
if usage is None:
|
|
2271
|
+
return
|
|
2272
|
+
|
|
2273
|
+
with self._execution_context_lock:
|
|
2274
|
+
self._context_token_usage_baseline = ContextTokenUsageBaseline(
|
|
2275
|
+
usage=usage,
|
|
2276
|
+
system_messages=list(prepared_context.system_messages),
|
|
2277
|
+
execution_context_messages=list(
|
|
2278
|
+
prepared_context.execution_context_messages
|
|
2279
|
+
),
|
|
2280
|
+
runtime_tail_messages=list(prepared_context.runtime_tail_messages),
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
def _get_observability_node_label(self) -> str:
|
|
2284
|
+
if self.config.name:
|
|
2285
|
+
return self.config.name
|
|
2286
|
+
if self.config.role_name:
|
|
2287
|
+
return self.config.role_name
|
|
2288
|
+
if self.node_type == NodeType.ASSISTANT:
|
|
2289
|
+
return "Assistant"
|
|
2290
|
+
from flowent.graph_service import is_tab_leader
|
|
2291
|
+
|
|
2292
|
+
if is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
|
|
2293
|
+
return "Leader"
|
|
2294
|
+
return "Agent"
|
|
2295
|
+
|
|
2296
|
+
def _get_observability_tab_title(self) -> str | None:
|
|
2297
|
+
if not self.config.tab_id:
|
|
2298
|
+
return None
|
|
2299
|
+
from flowent.workspace_store import workspace_store
|
|
2300
|
+
|
|
2301
|
+
tab = workspace_store.get_tab(self.config.tab_id)
|
|
2302
|
+
if tab is None:
|
|
2303
|
+
return None
|
|
2304
|
+
return tab.title
|
|
2305
|
+
|
|
2306
|
+
def _get_effective_model_source(self) -> ResolvedModelSource:
|
|
2307
|
+
from flowent.settings import find_provider, find_role, resolve_model_info
|
|
2308
|
+
|
|
2309
|
+
settings = get_settings()
|
|
2310
|
+
provider_id = settings.model.active_provider_id
|
|
2311
|
+
model_id = settings.model.active_model
|
|
2312
|
+
use_system_model_overrides = True
|
|
2313
|
+
role_cfg = (
|
|
2314
|
+
find_role(settings, self.config.role_name)
|
|
2315
|
+
if self.config.role_name
|
|
2316
|
+
else None
|
|
2317
|
+
)
|
|
2318
|
+
if (
|
|
2319
|
+
role_cfg is not None
|
|
2320
|
+
and role_cfg.model is not None
|
|
2321
|
+
and role_cfg.model.provider_id
|
|
2322
|
+
and role_cfg.model.model
|
|
2323
|
+
):
|
|
2324
|
+
provider_id = role_cfg.model.provider_id
|
|
2325
|
+
model_id = role_cfg.model.model
|
|
2326
|
+
use_system_model_overrides = False
|
|
2327
|
+
if not provider_id or not model_id:
|
|
2328
|
+
return ResolvedModelSource(
|
|
2329
|
+
provider_id=None,
|
|
2330
|
+
provider_name=None,
|
|
2331
|
+
provider_type=None,
|
|
2332
|
+
provider_base_url=None,
|
|
2333
|
+
model=None,
|
|
2334
|
+
model_info=None,
|
|
2335
|
+
)
|
|
2336
|
+
provider = find_provider(settings, provider_id)
|
|
2337
|
+
if provider is None:
|
|
2338
|
+
return ResolvedModelSource(
|
|
2339
|
+
provider_id=None,
|
|
2340
|
+
provider_name=None,
|
|
2341
|
+
provider_type=None,
|
|
2342
|
+
provider_base_url=None,
|
|
2343
|
+
model=None,
|
|
2344
|
+
model_info=None,
|
|
2345
|
+
)
|
|
2346
|
+
return ResolvedModelSource(
|
|
2347
|
+
provider_id=provider.id,
|
|
2348
|
+
provider_name=provider.name,
|
|
2349
|
+
provider_type=provider.type,
|
|
2350
|
+
provider_base_url=provider.base_url,
|
|
2351
|
+
model=model_id,
|
|
2352
|
+
model_info=resolve_model_info(
|
|
2353
|
+
provider=provider,
|
|
2354
|
+
model_id=model_id,
|
|
2355
|
+
input_image=(
|
|
2356
|
+
settings.model.input_image if use_system_model_overrides else None
|
|
2357
|
+
),
|
|
2358
|
+
output_image=(
|
|
2359
|
+
settings.model.output_image if use_system_model_overrides else None
|
|
2360
|
+
),
|
|
2361
|
+
context_window_tokens=(
|
|
2362
|
+
settings.model.context_window_tokens
|
|
2363
|
+
if use_system_model_overrides
|
|
2364
|
+
else None
|
|
2365
|
+
),
|
|
2366
|
+
),
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
def _raise_if_llm_network_disallowed(self) -> None:
|
|
2370
|
+
from flowent.graph_service import resolve_effective_permissions_for_agent
|
|
2371
|
+
|
|
2372
|
+
allow_network, _ = resolve_effective_permissions_for_agent(self)
|
|
2373
|
+
if allow_network:
|
|
2374
|
+
return
|
|
2375
|
+
source = self._get_effective_model_source()
|
|
2376
|
+
if not self._model_source_requires_network(source):
|
|
2377
|
+
return
|
|
2378
|
+
raise LLMProviderError(
|
|
2379
|
+
"Network access is disabled for this workflow",
|
|
2380
|
+
transient=False,
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
@staticmethod
|
|
2384
|
+
def _model_source_requires_network(source: ResolvedModelSource) -> bool:
|
|
2385
|
+
base_url = source.provider_base_url
|
|
2386
|
+
if not base_url:
|
|
2387
|
+
return False
|
|
2388
|
+
parsed = urlparse(base_url)
|
|
2389
|
+
host = (parsed.hostname or "").lower()
|
|
2390
|
+
return host not in {"localhost", "127.0.0.1", "::1"}
|
|
2391
|
+
|
|
2392
|
+
def _get_effective_model_info(self) -> tuple[str | None, ModelInfo | None]:
|
|
2393
|
+
resolved_source = self._get_effective_model_source()
|
|
2394
|
+
return resolved_source.provider_type, resolved_source.model_info
|
|
2395
|
+
|
|
2396
|
+
def _record_request_observability(
|
|
2397
|
+
self,
|
|
2398
|
+
*,
|
|
2399
|
+
started_at: float,
|
|
2400
|
+
ended_at: float,
|
|
2401
|
+
retry_count: int,
|
|
2402
|
+
result: str,
|
|
2403
|
+
usage: LLMUsage | None = None,
|
|
2404
|
+
raw_usage: dict[str, Any] | None = None,
|
|
2405
|
+
error_summary: str | None = None,
|
|
2406
|
+
) -> None:
|
|
2407
|
+
from flowent.observability_service import (
|
|
2408
|
+
RequestRecordInput,
|
|
2409
|
+
observability_store,
|
|
2410
|
+
)
|
|
2411
|
+
|
|
2412
|
+
resolved_source = self._get_effective_model_source()
|
|
2413
|
+
observability_store.record_request(
|
|
2414
|
+
RequestRecordInput(
|
|
2415
|
+
node_id=self.uuid,
|
|
2416
|
+
node_label=self._get_observability_node_label(),
|
|
2417
|
+
role_name=self.config.role_name,
|
|
2418
|
+
tab_id=self.config.tab_id,
|
|
2419
|
+
tab_title=self._get_observability_tab_title(),
|
|
2420
|
+
provider_id=resolved_source.provider_id,
|
|
2421
|
+
provider_name=resolved_source.provider_name,
|
|
2422
|
+
provider_type=resolved_source.provider_type,
|
|
2423
|
+
model=resolved_source.model,
|
|
2424
|
+
started_at=started_at,
|
|
2425
|
+
ended_at=ended_at,
|
|
2426
|
+
retry_count=retry_count,
|
|
2427
|
+
result="success" if result == "success" else "error",
|
|
2428
|
+
normalized_usage=usage,
|
|
2429
|
+
raw_usage=raw_usage,
|
|
2430
|
+
error_summary=error_summary,
|
|
2431
|
+
)
|
|
2432
|
+
)
|
|
2433
|
+
|
|
2434
|
+
def _run_compact_with_observability(
|
|
2435
|
+
self,
|
|
2436
|
+
*,
|
|
2437
|
+
trigger_type: str,
|
|
2438
|
+
focus: str | None = None,
|
|
2439
|
+
) -> str:
|
|
2440
|
+
from flowent.observability_service import (
|
|
2441
|
+
CompactRecordInput,
|
|
2442
|
+
observability_store,
|
|
2443
|
+
)
|
|
2444
|
+
|
|
2445
|
+
started_at = _time.time()
|
|
2446
|
+
resolved_source = self._get_effective_model_source()
|
|
2447
|
+
try:
|
|
2448
|
+
result = self._compact_execution_context(focus=focus)
|
|
2449
|
+
except Exception as exc:
|
|
2450
|
+
observability_store.record_compact(
|
|
2451
|
+
CompactRecordInput(
|
|
2452
|
+
node_id=self.uuid,
|
|
2453
|
+
node_label=self._get_observability_node_label(),
|
|
2454
|
+
role_name=self.config.role_name,
|
|
2455
|
+
tab_id=self.config.tab_id,
|
|
2456
|
+
tab_title=self._get_observability_tab_title(),
|
|
2457
|
+
provider_id=resolved_source.provider_id,
|
|
2458
|
+
provider_name=resolved_source.provider_name,
|
|
2459
|
+
provider_type=resolved_source.provider_type,
|
|
2460
|
+
model=resolved_source.model,
|
|
2461
|
+
trigger_type="manual" if trigger_type == "manual" else "auto",
|
|
2462
|
+
started_at=started_at,
|
|
2463
|
+
ended_at=_time.time(),
|
|
2464
|
+
result="error",
|
|
2465
|
+
error_summary=str(exc),
|
|
2466
|
+
)
|
|
2467
|
+
)
|
|
2468
|
+
raise
|
|
2469
|
+
observability_store.record_compact(
|
|
2470
|
+
CompactRecordInput(
|
|
2471
|
+
node_id=self.uuid,
|
|
2472
|
+
node_label=self._get_observability_node_label(),
|
|
2473
|
+
role_name=self.config.role_name,
|
|
2474
|
+
tab_id=self.config.tab_id,
|
|
2475
|
+
tab_title=self._get_observability_tab_title(),
|
|
2476
|
+
provider_id=resolved_source.provider_id,
|
|
2477
|
+
provider_name=resolved_source.provider_name,
|
|
2478
|
+
provider_type=resolved_source.provider_type,
|
|
2479
|
+
model=resolved_source.model,
|
|
2480
|
+
trigger_type="manual" if trigger_type == "manual" else "auto",
|
|
2481
|
+
started_at=started_at,
|
|
2482
|
+
ended_at=_time.time(),
|
|
2483
|
+
result="success",
|
|
2484
|
+
)
|
|
2485
|
+
)
|
|
2486
|
+
return result
|
|
2487
|
+
|
|
2488
|
+
def _get_effective_output_budget_tokens(self) -> int:
|
|
2489
|
+
from flowent.settings import find_role, merge_model_params
|
|
2490
|
+
|
|
2491
|
+
settings = get_settings()
|
|
2492
|
+
role_cfg = (
|
|
2493
|
+
find_role(settings, self.config.role_name)
|
|
2494
|
+
if self.config.role_name
|
|
2495
|
+
else None
|
|
2496
|
+
)
|
|
2497
|
+
model_params = merge_model_params(
|
|
2498
|
+
settings.model.params,
|
|
2499
|
+
role_cfg.model_params if role_cfg is not None else None,
|
|
2500
|
+
)
|
|
2501
|
+
if model_params is not None and model_params.max_output_tokens is not None:
|
|
2502
|
+
return max(1, model_params.max_output_tokens)
|
|
2503
|
+
return DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS
|
|
2504
|
+
|
|
2505
|
+
def _compute_context_preflight(
|
|
2506
|
+
self,
|
|
2507
|
+
prepared_context: PreparedLLMContext,
|
|
2508
|
+
) -> ContextPreflight:
|
|
2509
|
+
settings = get_settings()
|
|
2510
|
+
_, model_info = self._get_effective_model_info()
|
|
2511
|
+
estimated_total_tokens = self._estimate_tokens_from_usage_baseline(
|
|
2512
|
+
prepared_context
|
|
2513
|
+
)
|
|
2514
|
+
if estimated_total_tokens is None:
|
|
2515
|
+
estimated_total_tokens = self._estimate_input_tokens(
|
|
2516
|
+
prepared_context.messages
|
|
2517
|
+
)
|
|
2518
|
+
|
|
2519
|
+
auto_compact_token_limit = settings.model.auto_compact_token_limit
|
|
2520
|
+
if model_info is None or model_info.context_window_tokens is None:
|
|
2521
|
+
return ContextPreflight(
|
|
2522
|
+
estimated_total_tokens=estimated_total_tokens,
|
|
2523
|
+
auto_compact_token_limit=auto_compact_token_limit,
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
output_budget_tokens = self._get_effective_output_budget_tokens()
|
|
2527
|
+
safe_input_tokens = max(
|
|
2528
|
+
1,
|
|
2529
|
+
model_info.context_window_tokens
|
|
2530
|
+
- output_budget_tokens
|
|
2531
|
+
- DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS,
|
|
2532
|
+
)
|
|
2533
|
+
return ContextPreflight(
|
|
2534
|
+
estimated_total_tokens=estimated_total_tokens,
|
|
2535
|
+
context_window_tokens=model_info.context_window_tokens,
|
|
2536
|
+
auto_compact_token_limit=auto_compact_token_limit,
|
|
2537
|
+
safe_input_tokens=safe_input_tokens,
|
|
2538
|
+
)
|
|
2539
|
+
|
|
2540
|
+
def _compact_execution_context(self, *, focus: str | None = None) -> str:
|
|
2541
|
+
with self._history_lock:
|
|
2542
|
+
history_snapshot = list(self.history)
|
|
2543
|
+
context_messages = self._build_execution_context_messages(history_snapshot)
|
|
2544
|
+
if not context_messages:
|
|
2545
|
+
self._set_execution_context(
|
|
2546
|
+
summary="",
|
|
2547
|
+
history_cutoff=len(history_snapshot),
|
|
2548
|
+
)
|
|
2549
|
+
self._persist_workspace_node()
|
|
2550
|
+
return ""
|
|
2551
|
+
summary = self._generate_compacted_history_summary(
|
|
2552
|
+
focus=focus,
|
|
2553
|
+
context_messages=context_messages,
|
|
2554
|
+
)
|
|
2555
|
+
self._set_execution_context(
|
|
2556
|
+
summary=summary,
|
|
2557
|
+
history_cutoff=len(history_snapshot),
|
|
2558
|
+
)
|
|
2559
|
+
self._persist_workspace_node()
|
|
2560
|
+
return summary
|
|
2561
|
+
|
|
2562
|
+
def _prepare_messages_for_llm(self) -> PreparedLLMContext:
|
|
2563
|
+
prepared_context = self._build_prepared_llm_context()
|
|
2564
|
+
preflight = self._compute_context_preflight(prepared_context)
|
|
2565
|
+
if (
|
|
2566
|
+
preflight.auto_compact_token_limit is None
|
|
2567
|
+
or preflight.estimated_total_tokens < preflight.auto_compact_token_limit
|
|
2568
|
+
):
|
|
2569
|
+
return prepared_context
|
|
2570
|
+
|
|
2571
|
+
self._log.debug(
|
|
2572
|
+
"Automatic compact preflight: estimated_total={}, token_limit={}, safe={}, context_window={}",
|
|
2573
|
+
preflight.estimated_total_tokens,
|
|
2574
|
+
preflight.auto_compact_token_limit,
|
|
2575
|
+
preflight.safe_input_tokens,
|
|
2576
|
+
preflight.context_window_tokens,
|
|
2577
|
+
)
|
|
2578
|
+
try:
|
|
2579
|
+
self._run_compact_with_observability(trigger_type="auto")
|
|
2580
|
+
except Exception as exc:
|
|
2581
|
+
if (
|
|
2582
|
+
preflight.safe_input_tokens is not None
|
|
2583
|
+
and preflight.estimated_total_tokens > preflight.safe_input_tokens
|
|
2584
|
+
):
|
|
2585
|
+
raise ContextPreflightError(
|
|
2586
|
+
"Automatic compact failed and the current execution context exceeds the safe model window."
|
|
2587
|
+
) from exc
|
|
2588
|
+
self._log.warning("Automatic compact failed below safe window: {}", exc)
|
|
2589
|
+
return prepared_context
|
|
2590
|
+
|
|
2591
|
+
prepared_context = self._build_prepared_llm_context()
|
|
2592
|
+
post_compact = self._compute_context_preflight(prepared_context)
|
|
2593
|
+
if (
|
|
2594
|
+
post_compact.safe_input_tokens is not None
|
|
2595
|
+
and post_compact.estimated_total_tokens > post_compact.safe_input_tokens
|
|
2596
|
+
):
|
|
2597
|
+
raise ContextPreflightError(
|
|
2598
|
+
"Automatic compact completed but the current execution context still exceeds the safe model window."
|
|
2599
|
+
)
|
|
2600
|
+
return prepared_context
|
|
2601
|
+
|
|
2602
|
+
@staticmethod
|
|
2603
|
+
def _flush_tool_calls(
|
|
2604
|
+
messages: list[dict[str, Any]],
|
|
2605
|
+
pending: list[dict[str, Any]],
|
|
2606
|
+
) -> None:
|
|
2607
|
+
if not pending:
|
|
2608
|
+
return
|
|
2609
|
+
last = messages[-1] if messages else None
|
|
2610
|
+
if last and last["role"] == "assistant":
|
|
2611
|
+
last.setdefault("tool_calls", []).extend(pending)
|
|
2612
|
+
else:
|
|
2613
|
+
messages.append({"role": "assistant", "tool_calls": list(pending)})
|
|
2614
|
+
pending.clear()
|
|
2615
|
+
|
|
2616
|
+
@staticmethod
|
|
2617
|
+
def _tool_result_has_error(result: str | None) -> bool:
|
|
2618
|
+
if not isinstance(result, str):
|
|
2619
|
+
return False
|
|
2620
|
+
try:
|
|
2621
|
+
payload = json.loads(result)
|
|
2622
|
+
except json.JSONDecodeError:
|
|
2623
|
+
return False
|
|
2624
|
+
return isinstance(payload, dict) and isinstance(payload.get("error"), str)
|
|
2625
|
+
|
|
2626
|
+
def _drain_messages(self) -> None:
|
|
2627
|
+
drained: list[WakeSignal] = []
|
|
2628
|
+
while True:
|
|
2629
|
+
try:
|
|
2630
|
+
signal = self._wake_queue.get_nowait()
|
|
2631
|
+
except Empty:
|
|
2632
|
+
break
|
|
2633
|
+
if signal.reason == "message":
|
|
2634
|
+
drained.append(signal)
|
|
2635
|
+
continue
|
|
2636
|
+
if signal.reason == "termination":
|
|
2637
|
+
self._terminate.set()
|
|
2638
|
+
break
|
|
2639
|
+
|
|
2640
|
+
if drained:
|
|
2641
|
+
self._log.debug("Drained {} message(s) from queue", len(drained))
|
|
2642
|
+
|
|
2643
|
+
for signal in drained:
|
|
2644
|
+
message = signal.payload.get("message", {})
|
|
2645
|
+
content = message.get("content", "")
|
|
2646
|
+
parts = deserialize_content_parts(
|
|
2647
|
+
message.get("parts"),
|
|
2648
|
+
fallback_text=content if isinstance(content, str) else None,
|
|
2649
|
+
)
|
|
2650
|
+
from_id = message.get("from", "")
|
|
2651
|
+
message_id = message.get("message_id")
|
|
2652
|
+
history_recorded = bool(message.get("history_recorded", False))
|
|
2653
|
+
port_inbound_recorded = bool(message.get("port_inbound_recorded", False))
|
|
2654
|
+
if (
|
|
2655
|
+
not isinstance(content, str)
|
|
2656
|
+
or not isinstance(from_id, str)
|
|
2657
|
+
or (message_id is not None and not isinstance(message_id, str))
|
|
2658
|
+
):
|
|
2659
|
+
continue
|
|
2660
|
+
self._log.debug(
|
|
2661
|
+
"Message from {}: {}",
|
|
2662
|
+
from_id,
|
|
2663
|
+
(content[:100] + "...") if len(content) > 100 else content,
|
|
2664
|
+
)
|
|
2665
|
+
if not history_recorded:
|
|
2666
|
+
self._append_history(
|
|
2667
|
+
ReceivedMessage(
|
|
2668
|
+
from_id=from_id,
|
|
2669
|
+
parts=parts,
|
|
2670
|
+
content=content,
|
|
2671
|
+
message_id=message_id,
|
|
2672
|
+
),
|
|
2673
|
+
)
|
|
2674
|
+
elif not port_inbound_recorded and message.get("port_type") in {
|
|
2675
|
+
"string",
|
|
2676
|
+
"json",
|
|
2677
|
+
}:
|
|
2678
|
+
value = message.get("value")
|
|
2679
|
+
self._append_history(
|
|
2680
|
+
PortInboundEntry(
|
|
2681
|
+
from_id=from_id,
|
|
2682
|
+
from_output_port_key=str(
|
|
2683
|
+
message.get("from_output_port_key", "")
|
|
2684
|
+
),
|
|
2685
|
+
to_input_port_key=str(message.get("to_input_port_key", "")),
|
|
2686
|
+
port_type=str(message.get("port_type", "")),
|
|
2687
|
+
value=value,
|
|
2688
|
+
value_summary=str(message.get("value_summary", "")),
|
|
2689
|
+
)
|
|
2690
|
+
)
|
|
2691
|
+
|
|
2692
|
+
def _wait_for_input(self) -> None:
|
|
2693
|
+
signal = self._wait_for_wakeup()
|
|
2694
|
+
self._resume_from_wakeup(signal)
|
|
2695
|
+
|
|
2696
|
+
def _resume_from_wakeup(self, signal: WakeSignal) -> None:
|
|
2697
|
+
if signal.reason == "message":
|
|
2698
|
+
message = signal.payload.get("message")
|
|
2699
|
+
if isinstance(message, dict):
|
|
2700
|
+
content = message.get("content")
|
|
2701
|
+
parts = deserialize_content_parts(
|
|
2702
|
+
message.get("parts"),
|
|
2703
|
+
fallback_text=content if isinstance(content, str) else None,
|
|
2704
|
+
)
|
|
2705
|
+
from_id = message.get("from")
|
|
2706
|
+
message_id = message.get("message_id")
|
|
2707
|
+
history_recorded = bool(message.get("history_recorded", False))
|
|
2708
|
+
port_inbound_recorded = bool(
|
|
2709
|
+
message.get("port_inbound_recorded", False)
|
|
2710
|
+
)
|
|
2711
|
+
if (
|
|
2712
|
+
isinstance(content, str)
|
|
2713
|
+
and isinstance(from_id, str)
|
|
2714
|
+
and (message_id is None or isinstance(message_id, str))
|
|
2715
|
+
and not history_recorded
|
|
2716
|
+
):
|
|
2717
|
+
self._append_history(
|
|
2718
|
+
ReceivedMessage(
|
|
2719
|
+
from_id=from_id,
|
|
2720
|
+
parts=parts,
|
|
2721
|
+
content=content,
|
|
2722
|
+
message_id=message_id,
|
|
2723
|
+
)
|
|
2724
|
+
)
|
|
2725
|
+
elif (
|
|
2726
|
+
isinstance(from_id, str)
|
|
2727
|
+
and history_recorded
|
|
2728
|
+
and not port_inbound_recorded
|
|
2729
|
+
and message.get("port_type") in {"string", "json"}
|
|
2730
|
+
):
|
|
2731
|
+
self._append_history(
|
|
2732
|
+
PortInboundEntry(
|
|
2733
|
+
from_id=from_id,
|
|
2734
|
+
from_output_port_key=str(
|
|
2735
|
+
message.get("from_output_port_key", "")
|
|
2736
|
+
),
|
|
2737
|
+
to_input_port_key=str(message.get("to_input_port_key", "")),
|
|
2738
|
+
port_type=str(message.get("port_type", "")),
|
|
2739
|
+
value=message.get("value"),
|
|
2740
|
+
value_summary=str(message.get("value_summary", "")),
|
|
2741
|
+
)
|
|
2742
|
+
)
|
|
2743
|
+
|
|
2744
|
+
if signal.reason != "termination":
|
|
2745
|
+
self.set_state(
|
|
2746
|
+
AgentState.RUNNING,
|
|
2747
|
+
signal.resume_reason or f"woke due to {signal.reason}",
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
def _wait_for_wakeup(self) -> WakeSignal:
|
|
2751
|
+
while not self._terminate.is_set():
|
|
2752
|
+
try:
|
|
2753
|
+
return self._wake_queue.get(timeout=2.0)
|
|
2754
|
+
except Empty:
|
|
2755
|
+
continue
|
|
2756
|
+
|
|
2757
|
+
return WakeSignal(
|
|
2758
|
+
reason="termination",
|
|
2759
|
+
payload={},
|
|
2760
|
+
resume_reason="termination requested",
|
|
2761
|
+
)
|
|
2762
|
+
|
|
2763
|
+
def _handle_tool_call(
|
|
2764
|
+
self,
|
|
2765
|
+
name: str,
|
|
2766
|
+
arguments: dict[str, Any],
|
|
2767
|
+
call_id: str,
|
|
2768
|
+
) -> str | None:
|
|
2769
|
+
self._log.debug(
|
|
2770
|
+
"Tool call: name={}, call_id={}, args={}",
|
|
2771
|
+
name,
|
|
2772
|
+
call_id[:8],
|
|
2773
|
+
json.dumps(arguments, ensure_ascii=False)[:200],
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
tool = _get_tool_registry().get(name)
|
|
2777
|
+
if tool is None:
|
|
2778
|
+
self._log.warning("Unknown tool: {}", name)
|
|
2779
|
+
error_msg = json.dumps({"error": f"Unknown tool: {name}"})
|
|
2780
|
+
self._append_history(
|
|
2781
|
+
ToolCall(
|
|
2782
|
+
tool_name=name,
|
|
2783
|
+
tool_call_id=call_id,
|
|
2784
|
+
arguments=arguments,
|
|
2785
|
+
result=error_msg,
|
|
2786
|
+
streaming=False,
|
|
2787
|
+
),
|
|
2788
|
+
)
|
|
2789
|
+
return error_msg
|
|
2790
|
+
|
|
2791
|
+
registry = _get_tool_registry()
|
|
2792
|
+
if hasattr(registry, "get_tools_for_agent"):
|
|
2793
|
+
allowed_tool_names = {
|
|
2794
|
+
allowed_tool.name for allowed_tool in registry.get_tools_for_agent(self)
|
|
2795
|
+
}
|
|
2796
|
+
else:
|
|
2797
|
+
allowed_tool_names = set(self.config.tools)
|
|
2798
|
+
if name not in allowed_tool_names:
|
|
2799
|
+
self._log.warning("Tool not granted in current boundary: {}", name)
|
|
2800
|
+
error_msg = json.dumps({"error": f"Tool not granted: {name}"})
|
|
2801
|
+
self._append_history(
|
|
2802
|
+
ToolCall(
|
|
2803
|
+
tool_name=name,
|
|
2804
|
+
tool_call_id=call_id,
|
|
2805
|
+
arguments=arguments,
|
|
2806
|
+
result=error_msg,
|
|
2807
|
+
streaming=False,
|
|
2808
|
+
),
|
|
2809
|
+
)
|
|
2810
|
+
return error_msg
|
|
2811
|
+
|
|
2812
|
+
authorization_error = authorize(name, self, arguments)
|
|
2813
|
+
if authorization_error is not None:
|
|
2814
|
+
self._log.warning(
|
|
2815
|
+
"Tool denied by security policy: {} ({})",
|
|
2816
|
+
name,
|
|
2817
|
+
authorization_error,
|
|
2818
|
+
)
|
|
2819
|
+
error_msg = json.dumps({"error": authorization_error})
|
|
2820
|
+
self._append_history(
|
|
2821
|
+
ToolCall(
|
|
2822
|
+
tool_name=name,
|
|
2823
|
+
tool_call_id=call_id,
|
|
2824
|
+
arguments=arguments,
|
|
2825
|
+
result=error_msg,
|
|
2826
|
+
streaming=False,
|
|
2827
|
+
),
|
|
2828
|
+
)
|
|
2829
|
+
return error_msg
|
|
2830
|
+
|
|
2831
|
+
event_bus.emit(
|
|
2832
|
+
Event(
|
|
2833
|
+
type=EventType.TOOL_CALLED,
|
|
2834
|
+
agent_id=self.uuid,
|
|
2835
|
+
data={"tool": name, "arguments": arguments},
|
|
2836
|
+
),
|
|
2837
|
+
)
|
|
2838
|
+
|
|
2839
|
+
if name == "send":
|
|
2840
|
+
t0 = _time.perf_counter()
|
|
2841
|
+
try:
|
|
2842
|
+
self._raise_if_interrupt_requested()
|
|
2843
|
+
result = tool.execute(
|
|
2844
|
+
self,
|
|
2845
|
+
arguments,
|
|
2846
|
+
on_output=None,
|
|
2847
|
+
tool_call_id=call_id,
|
|
2848
|
+
)
|
|
2849
|
+
self._raise_if_interrupt_requested()
|
|
2850
|
+
elapsed = _time.perf_counter() - t0
|
|
2851
|
+
self._log.debug(
|
|
2852
|
+
"Tool {} completed in {:.2f}s, result_len={}",
|
|
2853
|
+
name,
|
|
2854
|
+
elapsed,
|
|
2855
|
+
len(result) if result else 0,
|
|
2856
|
+
)
|
|
2857
|
+
return result
|
|
2858
|
+
except InterruptRequestedError:
|
|
2859
|
+
raise
|
|
2860
|
+
except Exception as exc:
|
|
2861
|
+
elapsed = _time.perf_counter() - t0
|
|
2862
|
+
self._log.warning(
|
|
2863
|
+
"Tool {} failed after {:.2f}s: {}", name, elapsed, exc
|
|
2864
|
+
)
|
|
2865
|
+
error_text = str(exc)
|
|
2866
|
+
self._append_history(ErrorEntry(content=error_text))
|
|
2867
|
+
return json.dumps({"error": error_text})
|
|
2868
|
+
|
|
2869
|
+
streaming_entry = ToolCall(
|
|
2870
|
+
tool_name=name,
|
|
2871
|
+
tool_call_id=call_id,
|
|
2872
|
+
arguments=arguments,
|
|
2873
|
+
streaming=True,
|
|
2874
|
+
)
|
|
2875
|
+
self._append_history(streaming_entry)
|
|
2876
|
+
streamed_result_parts: list[str] = []
|
|
2877
|
+
|
|
2878
|
+
def _on_tool_output(text: str) -> None:
|
|
2879
|
+
self._raise_if_interrupt_requested()
|
|
2880
|
+
streamed_result_parts.append(text)
|
|
2881
|
+
delta = ToolResultDelta(tool_call_id=call_id, text=text)
|
|
2882
|
+
event_bus.emit(
|
|
2883
|
+
Event(
|
|
2884
|
+
type=EventType.HISTORY_ENTRY_DELTA,
|
|
2885
|
+
agent_id=self.uuid,
|
|
2886
|
+
data=delta.serialize(),
|
|
2887
|
+
),
|
|
2888
|
+
)
|
|
2889
|
+
self._raise_if_interrupt_requested()
|
|
2890
|
+
|
|
2891
|
+
t0 = _time.perf_counter()
|
|
2892
|
+
try:
|
|
2893
|
+
self._raise_if_interrupt_requested()
|
|
2894
|
+
result = tool.execute(
|
|
2895
|
+
self,
|
|
2896
|
+
arguments,
|
|
2897
|
+
on_output=_on_tool_output,
|
|
2898
|
+
tool_call_id=call_id,
|
|
2899
|
+
)
|
|
2900
|
+
self._raise_if_interrupt_requested()
|
|
2901
|
+
elapsed = _time.perf_counter() - t0
|
|
2902
|
+
self._log.debug(
|
|
2903
|
+
"Tool {} completed in {:.2f}s, result_len={}",
|
|
2904
|
+
name,
|
|
2905
|
+
elapsed,
|
|
2906
|
+
len(result) if result else 0,
|
|
2907
|
+
)
|
|
2908
|
+
|
|
2909
|
+
self._finalize_tool_call(call_id, name, arguments, result)
|
|
2910
|
+
return result
|
|
2911
|
+
except InterruptRequestedError:
|
|
2912
|
+
partial_result = "".join(streamed_result_parts) or None
|
|
2913
|
+
self._finalize_tool_call(call_id, name, arguments, partial_result)
|
|
2914
|
+
raise
|
|
2915
|
+
except Exception as e:
|
|
2916
|
+
elapsed = _time.perf_counter() - t0
|
|
2917
|
+
self._log.exception("Tool {} failed after {:.2f}s", name, elapsed)
|
|
2918
|
+
error_msg = json.dumps({"error": str(e)})
|
|
2919
|
+
self._finalize_tool_call(call_id, name, arguments, error_msg)
|
|
2920
|
+
return error_msg
|
|
2921
|
+
|
|
2922
|
+
def _finalize_tool_call(
|
|
2923
|
+
self,
|
|
2924
|
+
call_id: str,
|
|
2925
|
+
name: str,
|
|
2926
|
+
arguments: dict[str, Any],
|
|
2927
|
+
result: str | None,
|
|
2928
|
+
) -> None:
|
|
2929
|
+
final: ToolCall | None = None
|
|
2930
|
+
with self._history_lock:
|
|
2931
|
+
for i in range(len(self.history) - 1, -1, -1):
|
|
2932
|
+
entry = self.history[i]
|
|
2933
|
+
if (
|
|
2934
|
+
isinstance(entry, ToolCall)
|
|
2935
|
+
and entry.tool_call_id == call_id
|
|
2936
|
+
and entry.streaming
|
|
2937
|
+
):
|
|
2938
|
+
final = ToolCall(
|
|
2939
|
+
tool_name=name,
|
|
2940
|
+
tool_call_id=call_id,
|
|
2941
|
+
arguments=arguments,
|
|
2942
|
+
result=result,
|
|
2943
|
+
streaming=False,
|
|
2944
|
+
)
|
|
2945
|
+
self.history[i] = final
|
|
2946
|
+
break
|
|
2947
|
+
|
|
2948
|
+
if final is not None:
|
|
2949
|
+
event_bus.emit(
|
|
2950
|
+
Event(
|
|
2951
|
+
type=EventType.HISTORY_ENTRY_ADDED,
|
|
2952
|
+
agent_id=self.uuid,
|
|
2953
|
+
data=final.serialize(),
|
|
2954
|
+
),
|
|
2955
|
+
)
|
|
2956
|
+
|
|
2957
|
+
def enqueue_message(self, msg: Message) -> None:
|
|
2958
|
+
payload = {
|
|
2959
|
+
"from": msg.from_id,
|
|
2960
|
+
"content": msg.content,
|
|
2961
|
+
"parts": [part.serialize() for part in msg.parts],
|
|
2962
|
+
"history_recorded": msg.history_recorded,
|
|
2963
|
+
}
|
|
2964
|
+
if msg.from_output_port_key is not None:
|
|
2965
|
+
payload["from_output_port_key"] = msg.from_output_port_key
|
|
2966
|
+
if msg.to_input_port_key is not None:
|
|
2967
|
+
payload["to_input_port_key"] = msg.to_input_port_key
|
|
2968
|
+
if msg.port_type is not None:
|
|
2969
|
+
payload["port_type"] = msg.port_type
|
|
2970
|
+
if msg.value is not None:
|
|
2971
|
+
payload["value"] = msg.value
|
|
2972
|
+
if msg.value_summary is not None:
|
|
2973
|
+
payload["value_summary"] = msg.value_summary
|
|
2974
|
+
if msg.port_inbound_recorded:
|
|
2975
|
+
payload["port_inbound_recorded"] = msg.port_inbound_recorded
|
|
2976
|
+
if msg.message_id is not None:
|
|
2977
|
+
payload["message_id"] = msg.message_id
|
|
2978
|
+
self._wake_queue.put(
|
|
2979
|
+
WakeSignal(
|
|
2980
|
+
reason="message",
|
|
2981
|
+
payload={"message": payload},
|
|
2982
|
+
resume_reason=f"received message from {msg.from_id}",
|
|
2983
|
+
)
|
|
2984
|
+
)
|
|
2985
|
+
|
|
2986
|
+
def set_state(
|
|
2987
|
+
self,
|
|
2988
|
+
state: AgentState,
|
|
2989
|
+
reason: str = "",
|
|
2990
|
+
*,
|
|
2991
|
+
force_emit: bool = False,
|
|
2992
|
+
) -> None:
|
|
2993
|
+
from flowent.graph_service import is_tab_leader
|
|
2994
|
+
|
|
2995
|
+
old = self.state
|
|
2996
|
+
self.state = state
|
|
2997
|
+
if state == AgentState.IDLE:
|
|
2998
|
+
if old != AgentState.IDLE:
|
|
2999
|
+
self._idle_started_at = _time.perf_counter()
|
|
3000
|
+
self._idle_state_event.set()
|
|
3001
|
+
else:
|
|
3002
|
+
if old == AgentState.IDLE:
|
|
3003
|
+
self._idle_started_at = None
|
|
3004
|
+
self._idle_started_by_tool_call_id = None
|
|
3005
|
+
self._idle_state_event.clear()
|
|
3006
|
+
if old != state or force_emit:
|
|
3007
|
+
self._log.debug(
|
|
3008
|
+
"State: {} -> {}{}",
|
|
3009
|
+
old.value,
|
|
3010
|
+
state.value,
|
|
3011
|
+
f" ({reason})" if reason else "",
|
|
3012
|
+
)
|
|
3013
|
+
event_bus.emit(
|
|
3014
|
+
Event(
|
|
3015
|
+
type=EventType.NODE_STATE_CHANGED,
|
|
3016
|
+
agent_id=self.uuid,
|
|
3017
|
+
data={
|
|
3018
|
+
"old_state": old.value,
|
|
3019
|
+
"new_state": state.value,
|
|
3020
|
+
"tab_id": self.config.tab_id,
|
|
3021
|
+
"role_name": self.config.role_name,
|
|
3022
|
+
"name": self.config.name,
|
|
3023
|
+
"is_leader": is_tab_leader(
|
|
3024
|
+
node_id=self.uuid,
|
|
3025
|
+
tab_id=self.config.tab_id,
|
|
3026
|
+
),
|
|
3027
|
+
"todos": [t.serialize() for t in self.get_todos_snapshot()],
|
|
3028
|
+
},
|
|
3029
|
+
),
|
|
3030
|
+
)
|
|
3031
|
+
self._persist_workspace_node()
|
|
3032
|
+
|
|
3033
|
+
def request_termination(self, reason: str = "") -> None:
|
|
3034
|
+
self._preserve_workspace_state_on_exit = False
|
|
3035
|
+
self._termination_reason = reason
|
|
3036
|
+
self._terminate.set()
|
|
3037
|
+
self._wake_queue.put(
|
|
3038
|
+
WakeSignal(
|
|
3039
|
+
reason="termination",
|
|
3040
|
+
payload={},
|
|
3041
|
+
resume_reason="termination requested",
|
|
3042
|
+
)
|
|
3043
|
+
)
|
|
3044
|
+
|
|
3045
|
+
def request_process_exit(self) -> None:
|
|
3046
|
+
self._preserve_workspace_state_on_exit = (
|
|
3047
|
+
self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
|
|
3048
|
+
)
|
|
3049
|
+
self._termination_reason = "process_exit"
|
|
3050
|
+
self._terminate.set()
|
|
3051
|
+
self._wake_queue.put(
|
|
3052
|
+
WakeSignal(
|
|
3053
|
+
reason="termination",
|
|
3054
|
+
payload={},
|
|
3055
|
+
resume_reason="process exit requested",
|
|
3056
|
+
)
|
|
3057
|
+
)
|
|
3058
|
+
|
|
3059
|
+
def request_interrupt(self) -> bool:
|
|
3060
|
+
if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
|
|
3061
|
+
return False
|
|
3062
|
+
self._interrupt_requested.set()
|
|
3063
|
+
self._invoke_interrupt_callback()
|
|
3064
|
+
return True
|
|
3065
|
+
|
|
3066
|
+
def is_interrupt_requested(self) -> bool:
|
|
3067
|
+
return self._interrupt_requested.is_set()
|
|
3068
|
+
|
|
3069
|
+
def set_interrupt_callback(self, callback: Callable[[], None] | None) -> None:
|
|
3070
|
+
with self._interrupt_callback_lock:
|
|
3071
|
+
self._interrupt_callback = callback
|
|
3072
|
+
if callback is not None and self._interrupt_requested.is_set():
|
|
3073
|
+
self._invoke_interrupt_callback()
|
|
3074
|
+
|
|
3075
|
+
def _invoke_interrupt_callback(self) -> None:
|
|
3076
|
+
with self._interrupt_callback_lock:
|
|
3077
|
+
callback = self._interrupt_callback
|
|
3078
|
+
if callback is None:
|
|
3079
|
+
return
|
|
3080
|
+
try:
|
|
3081
|
+
callback()
|
|
3082
|
+
except Exception:
|
|
3083
|
+
self._log.debug("Interrupt callback raised")
|
|
3084
|
+
|
|
3085
|
+
def terminate_and_wait(self, timeout: float = 10.0) -> None:
|
|
3086
|
+
self.request_termination("shutdown")
|
|
3087
|
+
self.wait_for_termination(timeout=timeout)
|
|
3088
|
+
|
|
3089
|
+
def wait_for_termination(self, timeout: float = 10.0) -> bool:
|
|
3090
|
+
if self._thread and self._thread.is_alive():
|
|
3091
|
+
self._thread.join(timeout=timeout)
|
|
3092
|
+
return not (self._thread and self._thread.is_alive())
|
|
3093
|
+
|
|
3094
|
+
def _should_preserve_workspace_state_on_exit(self) -> bool:
|
|
3095
|
+
return self._preserve_workspace_state_on_exit and (
|
|
3096
|
+
self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
|
|
3097
|
+
)
|
|
3098
|
+
|
|
3099
|
+
def _finalize_termination(self, reason: str) -> None:
|
|
3100
|
+
from flowent.registry import registry
|
|
3101
|
+
|
|
3102
|
+
self.set_state(AgentState.TERMINATED, reason)
|
|
3103
|
+
self._log.info("Agent terminated (reason: {})", reason)
|
|
3104
|
+
event_bus.emit(
|
|
3105
|
+
Event(
|
|
3106
|
+
type=EventType.NODE_TERMINATED,
|
|
3107
|
+
agent_id=self.uuid,
|
|
3108
|
+
data={"reason": reason},
|
|
3109
|
+
),
|
|
3110
|
+
)
|
|
3111
|
+
registry.unregister(self.uuid)
|
|
3112
|
+
|
|
3113
|
+
peer_ids = self.get_connections_snapshot()
|
|
3114
|
+
for peer_id in peer_ids:
|
|
3115
|
+
peer = registry.get(peer_id)
|
|
3116
|
+
if peer is not None:
|
|
3117
|
+
peer.remove_connection(self.uuid)
|
|
3118
|
+
self.remove_connection(peer_id)
|
|
3119
|
+
|
|
3120
|
+
self._persist_workspace_node()
|