diff --git a/docs/agent.md b/docs/agent.md index 6743e96..e6cac8f 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -137,7 +137,7 @@ When `spawn_agent` runs a subagent, its events arrive through `current_event_sink`. The parent drains the queue in real time, yielding subagent events marked with `is_subagent=True`. ### Background tools -`ToolExecutor._execute_one` intercepts `background: true` in the arguments of a tool from `settings.backgroundable_tools` (`terminal`, `ssh_exec`, `peer`, `spawn_agent`, `code_exec` — `terminal` action `open` is never hijacked). The tool's `execute()` is submitted to the `TaskManager` ([`tasks.md`](tasks.md)) under a per-task ring event sink and per-task stop event — a session Stop does NOT affect background tasks. The foreground loop immediately receives a synthetic `ToolResult` (`task_id`, `args_summary`, hint to use the `tasks` tool) and the turn continues normally. Caps: per-session, global, spawn-specific and rate-limited — exceeding a cap runs the tool inline as a failed result instead of detaching. +`ToolExecutor._execute_one` intercepts `background: true` in the arguments of a tool from `settings.backgroundable_tools` (`terminal`, `ssh_exec`, `peer`, `spawn_agent`, `code_exec` — `terminal` action `open` is never hijacked). The tool's `execute()` is submitted to the `TaskManager` ([`tasks.md`](tasks.md)) under a per-task ring event sink and per-task stop event — a session Stop does NOT affect background tasks. The foreground loop immediately receives a synthetic `ToolResult` (`task_id`, `args_summary`, hint to use the `tasks` tool) and the turn continues normally. Caps: per-session, global, spawn-specific and rate-limited — exceeding a cap runs the tool inline as a failed result instead of detaching. For detached `terminal`/`code_exec`/`ssh_exec` calls the executor also lifts `timeout` to 300 s when the agent did not pass one (their foreground defaults — 20/30/60 s — would otherwise complete the task with partial output while the command still runs). ### Parallel tool batches When `profile.parallel_tool_calls ?? settings.parallel_tool_calls` (default off) is enabled and the model returns several tool calls in one response, `_execute_tools_parallel` starts them all together: `ToolStarted` for every call up-front, each call's events multiplexed through a tagged shared queue, and results/messages appended in call order after the whole batch settles — one `save()` per batch. A mid-batch Stop cancels the remaining calls and synthesizes `is_context=False` stopped results. A session loaded with a batch interrupted before its results is repaired on load (`pg_session_store.repair_dangling_tool_calls`). diff --git a/docs/config.md b/docs/config.md index 2ac39ab..1a12006 100644 --- a/docs/config.md +++ b/docs/config.md @@ -233,6 +233,8 @@ See [`docs/tasks.md`](tasks.md) for the full mechanism. +When a `terminal` (run), `code_exec` or `ssh_exec` call is detached and the agent did not pass `timeout`, the executor lifts it to 300 s for the detached run only — the tools' short foreground defaults (20/30/60 s) would otherwise mark long commands "completed" with partial output while the process is still running. + ## Example `.env` ```dotenv diff --git a/docs/websocket.md b/docs/websocket.md index a88cd6f..e1eb827 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -91,6 +91,8 @@ ### Concurrent run guard Only one agent run may be active per session at a time. If a second message arrives while a run is already in progress (either a WebSocket run or a headless recall), the server **queues** it instead of erroring and replies with a `message_queued` frame (see below). Queued messages execute back-to-back on the same socket once the active run finishes (drained in submit order, each as a normal streaming turn); a max of `message_queue_max` (default 5) are retained — when full, the oldest queued message is dropped silently and `dropped_total` in the frame grows. If the socket disconnects while messages are still queued, the server runs them headlessly (turn-completion push still fires). +**When the guard is reachable.** The WebSocket read loop is sequential: the handler awaits the whole agent run before reading the next frame, so a message sent on the *same* socket during a run is read only after the run ends (it then executes immediately, without a queue hop or a `message_queued` frame — nothing is lost). The queue path is therefore reachable only when a run is started outside the socket's own read loop: a second socket/tab on the same session, or a headless recall running concurrently. Messages sent on the same socket during a run are executed back-to-back after it finishes. + --- ## Messages: server → client diff --git a/manuals/tasks.md b/manuals/tasks.md index a1f9669..36e9567 100644 --- a/manuals/tasks.md +++ b/manuals/tasks.md @@ -5,6 +5,10 @@ **Background tasks are NOT stopped by run stop.** Only `cancel` kills them. +## Timeout of detached tool calls + +When `terminal` (run), `code_exec` or `ssh_exec` is detached **and you did not pass `timeout`**, the executor lifts it to 300 s for the detached run only. A shorter default (20-60 s) would mark long commands "completed" before they actually end. If your command can legitimately run longer than 300 s, pass `timeout` explicitly (max 300) — or prefer a persistent terminal (`action: "open"` + streaming output) for truly long jobs. + ## Parameters | Parameter | Required | Description | diff --git a/navi/core/tool_executor.py b/navi/core/tool_executor.py index ad47aee..32b680b 100644 --- a/navi/core/tool_executor.py +++ b/navi/core/tool_executor.py @@ -14,6 +14,10 @@ log = structlog.get_logger() +# Tools whose foreground default timeout is too short for a detached run. +_TIMEOUT_TOOLS = {"terminal", "code_exec", "ssh_exec"} +_BG_TIMEOUT = 300 + def _backgroundable_tools() -> set[str]: from navi.config import settings @@ -109,6 +113,15 @@ if resolved_name == "terminal" and args.get("action") == "open": return None + # A background run should track the command, not the tool's short + # foreground default (terminal 20s, code_exec 30s, ssh 60s) — otherwise + # a "sleep 60" task is marked completed after 20s with a half-read + # output while the process is still alive. When the agent did not pick + # a timeout, lift it to the tools' shared maximum (300s) for the + # detached run only. + if resolved_name in _TIMEOUT_TOOLS and "timeout" not in args: + args["timeout"] = _BG_TIMEOUT + from navi.core.tasks import args_summary, get_task_manager session_id = getattr(ctx, "session_id", None) or current_session_id.get() diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index 3391163..d6bb2c1 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -48,6 +48,7 @@ "image_view", "memory", "list_tools", + "tasks", "tool_manual", "ssh_exec", "spawn_agent", diff --git a/navi/profiles/modeler_3d/config.json b/navi/profiles/modeler_3d/config.json index 88b1f50..85a07f2 100644 --- a/navi/profiles/modeler_3d/config.json +++ b/navi/profiles/modeler_3d/config.json @@ -49,6 +49,7 @@ "image_view", "memory", "list_tools", + "tasks", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/profiles/navi_code/config.json b/navi/profiles/navi_code/config.json index cda9cde..739eb2c 100644 --- a/navi/profiles/navi_code/config.json +++ b/navi/profiles/navi_code/config.json @@ -52,6 +52,7 @@ "image_view", "memory", "list_tools", + "tasks", "tool_manual", "ssh_exec", "spawn_agent", diff --git a/navi/profiles/secretary/config.json b/navi/profiles/secretary/config.json index 9f2b930..464bca2 100644 --- a/navi/profiles/secretary/config.json +++ b/navi/profiles/secretary/config.json @@ -46,6 +46,7 @@ "image_view", "memory", "list_tools", + "tasks", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json index 3e56301..908aab4 100644 --- a/navi/profiles/server_admin/config.json +++ b/navi/profiles/server_admin/config.json @@ -48,6 +48,7 @@ "image_view", "memory", "list_tools", + "tasks", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json index 691fbc6..d8793a7 100644 --- a/navi/profiles/tool_developer/config.json +++ b/navi/profiles/tool_developer/config.json @@ -48,6 +48,7 @@ "memory", "reload_tools", "list_tools", + "tasks", "tool_manual", "spawn_agent", "share_file", diff --git a/persona.txt b/persona.txt index ca1ae43..a13ddc7 100644 --- a/persona.txt +++ b/persona.txt @@ -81,6 +81,7 @@ - Keep at most 2-3 background tasks at once. Background sub-agents are capped even lower. - Never start new background tasks merely because a task-results note arrived — that note is a report, not a request. - Use `tasks` action `cancel` to kill a task that is no longer needed. Stopping a run does NOT stop background tasks. +- For detached `terminal`/`code_exec`/`ssh_exec` calls pass `timeout` explicitly when the command may run longer than the tool's default — otherwise a backgrounded timeout is lifted to 300s and the task completes when the timeout (or the command) ends. - When the profile allows parallel tool calls, you may put several independent tool calls in one batch: they start together and results are reported in call order. Batch only independent calls; NEVER put two actions on the same terminal (or the same ssh host state) into one batch. REFLECTION: diff --git a/persona_navi_code.txt b/persona_navi_code.txt index 3b342a9..54a8e8b 100644 --- a/persona_navi_code.txt +++ b/persona_navi_code.txt @@ -19,6 +19,7 @@ - Держи не более 2-3 фоновых задач одновременно. Не спавни новые задачи в ответ на ноту результатов — это отчёт, а не запрос. - При параллельных вызовах тулов батчи только независимые вызовы; никогда не ставь два действия над одним терминалом в один батч. - `tasks cancel` убивает фоновую задачу; остановка рана её не останавливает. +- Для фон-вызовов `terminal`/`code_exec`/`ssh_exec` указывай `timeout` явно, если команда может идти дольше дефолта тула, — иначе при детаче таймаут автоматически поднимается до 300 с. Язык общения: Используй тот язык, на котором к тебе обратился пользователь (по умолчанию русский). diff --git a/tests/unit/core/test_tool_executor.py b/tests/unit/core/test_tool_executor.py index 26eec7d..1148ec8 100644 --- a/tests/unit/core/test_tool_executor.py +++ b/tests/unit/core/test_tool_executor.py @@ -225,7 +225,39 @@ ) assert msg.metadata.get("background") is True job = list(self.manager.list("s1"))[0] - assert job.args == {"action": "run", "command": "sleep 5"} + # timeout lifted to 300s for the detached run (foreground default is 20s) + assert job.args == {"action": "run", "command": "sleep 5", "timeout": 300} + await job.done.wait() + + async def test_bg_timeout_lift_respects_explicit_timeout(self, monkeypatch): + patch_settings(monkeypatch, backgroundable_tools="terminal") + tool = RecordingTool("terminal") + executor = ToolExecutor({"terminal": tool}) + + _, msg, _ = await executor._execute_one( + ToolCallRequest(id="tc1", name="terminal", + arguments={"action": "run", "command": "sleep 5", + "timeout": 45, "background": True}), + {"terminal": tool}, + ctx=_Ctx(), + ) + job = list(self.manager.list("s1"))[0] + assert job.args["timeout"] == 45 + await job.done.wait() + + async def test_bg_timeout_not_injected_for_spawn_agent(self, monkeypatch): + patch_settings(monkeypatch, backgroundable_tools="spawn_agent") + tool = RecordingTool("spawn_agent") + executor = ToolExecutor({"spawn_agent": tool}) + + _, msg, _ = await executor._execute_one( + ToolCallRequest(id="tc1", name="spawn_agent", + arguments={"task": "x", "background": True}), + {"spawn_agent": tool}, + ctx=_Ctx(), + ) + job = list(self.manager.list("s1"))[0] + assert "timeout" not in job.args await job.done.wait() async def test_spawn_agent_gets_deep_ring(self, monkeypatch): diff --git a/webclient/src/components/messages/ToolCard.vue b/webclient/src/components/messages/ToolCard.vue index a476e75..79f6e7c 100644 --- a/webclient/src/components/messages/ToolCard.vue +++ b/webclient/src/components/messages/ToolCard.vue @@ -94,6 +94,14 @@ const props = defineProps({ tool: { type: Object, required: true } }) +// Icon for a background task_update step (bt-* status line in spawn/terminal cards) +function stepIcon(status) { + if (status === 'running') return 'ph ph-circle-notch task-spin' + if (status === 'completed') return 'ph ph-check-circle' + if (status === 'cancelled') return 'ph ph-prohibit' + return 'ph ph-x-circle' +} + const detailsEl = ref(null) const now = ref(Date.now()) let timer = null diff --git a/webclient/src/styles/app.scss b/webclient/src/styles/app.scss index 43afa4f..d403d30 100644 --- a/webclient/src/styles/app.scss +++ b/webclient/src/styles/app.scss @@ -1285,6 +1285,13 @@ &.completed .task-step-status { color: $color-success; font-style: normal; } &.failed i, &.cancelled i { color: $color-error; } &.failed .task-step-status, &.cancelled .task-step-status { color: $color-error; font-style: normal; } + + .task-spin { animation: task-spin 1s linear infinite; } +} + +@keyframes task-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .subagent-step {