"""Tool execution: scope resolution, workers, parallel and sequential batches."""
import asyncio
from typing import TYPE_CHECKING
import structlog
from navi.config import settings
from navi.exceptions import (
LLMBackendError,
LLMConnectionError,
)
from navi.llm.base import LLMBackend, Message
from navi.tools._internal.base import (
current_event_sink,
current_user_role,
)
from ..agent_run_context import AgentTurnContext
from ..events import (
AgentEvent,
AIHelperTokensUsed,
ProfileSwitched,
SubagentComplete,
TodoUpdated,
ToolEvent,
ToolStarted,
)
if TYPE_CHECKING:
from navi.profiles.base import AgentProfile
log = structlog.get_logger()
from .helpers import _TOOL_DONE, _TaggedSink
class AgentToolsMixin:
async def _apply_mcp_scope(self, session):
"""Restrict this run's MCP tool list to the servers its user may use.
A BYOK-gated server with no personal key must not appear in the list at
all: the manager refuses the call anyway, and a tool the model can see
but never call is read as a transient fault and retried. Admins (and the
no-auth local user, whose role is ``admin``) get ``None`` — the shared
credential in the config is theirs, so nothing is filtered.
Also records *which* servers were withheld for want of a key. The reason
is not stored anywhere else and is not derivable by the caller: it is the
complement of what ``visible_servers`` kept, so it is computed from that
same value rather than read a second time (a second read of the key store
could disagree with the first).
Returns the pair of ContextVar tokens; callers reset them where they
already reset the session and cwd vars.
"""
from navi.tools._internal.base import (
current_allowed_mcp_servers,
current_keyless_mcp_servers,
current_user_id,
)
if self._mcp_manager is None:
allowed, keyless = None, frozenset()
else:
try:
allowed = await self._mcp_manager.visible_servers(
current_user_role.get(), current_user_id.get() or session.user_id
)
# Withheld = gated servers the run did not get to keep. None means
# "no restriction", so nothing was withheld.
keyless = (
frozenset()
if allowed is None
else frozenset(self._mcp_manager.byok_servers() - allowed)
)
except Exception:
# Fail closed: an unanswerable visibility question must not fall
# back to "everything is available". Every gated server then counts
# as withheld, which matches the empty tool list the model gets.
log.warning("agent.mcp_scope_failed", exc_info=True)
allowed = frozenset()
keyless = frozenset(self._mcp_manager.byok_servers())
return (
current_allowed_mcp_servers.set(allowed),
current_keyless_mcp_servers.set(keyless),
)
async def _run_workers(
self,
session,
llm: LLMBackend,
model: str,
context_tokens: int | None,
profile: "AgentProfile | None" = None,
) -> list[AgentEvent]:
"""Run all workers sequentially; collect their events."""
from navi.workers.base import WorkerContext
ctx = WorkerContext(
session_id=session.id,
context_tokens=context_tokens,
max_context_tokens=self._window_for(profile),
llm=llm,
model=model,
temperature=settings.context_summary_temperature,
session_store=self._sessions,
profile=profile,
# The backend cut the last prompt down (record_real_baseline saw it).
# Its token count is below the trigger, but the context it came from
# is over the window — compress now instead of waiting for a
# threshold that the truncated count will never reach.
context_truncated=self._compressor.pop_context_truncated(),
memory_store=self._memory,
)
events: list[AgentEvent] = []
for worker in self._workers:
try:
result = await worker.run(session, ctx)
events.extend(result.events)
except Exception:
log.warning("agent.worker_failed", worker=type(worker).__name__, exc_info=True)
return events
async def _execute_tools_with_sink(
self, turn_tool_calls, tools, turn_ctx: AgentTurnContext, session, stop_event, tool_ctx=None
):
"""Execute tool calls, sequentially or as a parallel batch.
The mode is decided per-turn by ``turn_ctx.parallel_tool_calls``
(profile override ? global setting, resolved at tool_ctx build).
"""
# A profile switch replaces the toolset mid-turn, so it must not share a
# batch: every neighbour would still be dispatched against the old
# tool_map and die with "tool 'X' not found" (seen with reload_tools called
# just after a switch). Run it alone first, then re-resolve the tools
# it switched to for the rest of the batch. The turn's own ``profile``
# binding stays old on purpose — the end-of-iteration reload in
# run_stream() is what rebinds profile/llm/schemas.
switching = next((tc for tc in turn_tool_calls if tc.name == "switch_profile"), None)
if switching is not None and len(turn_tool_calls) > 1:
rest = [tc for tc in turn_tool_calls if tc is not switching]
switched_to = None
async for ev in self._execute_tools_sequential(
[switching], tools, turn_ctx, session, stop_event, tool_ctx
):
if isinstance(ev, ProfileSwitched):
switched_to = ev.profile_id
yield ev
if switched_to:
new_tools = self._tool_list_for_profile(switched_to)
if new_tools is not None:
tools = new_tools
turn_tool_calls = rest
if turn_tool_calls and turn_ctx.parallel_tool_calls and len(turn_tool_calls) > 1:
async for ev in self._execute_tools_parallel(
turn_tool_calls, tools, turn_ctx, session, stop_event, tool_ctx
):
yield ev
else:
async for ev in self._execute_tools_sequential(
turn_tool_calls, tools, turn_ctx, session, stop_event, tool_ctx
):
yield ev
async def _execute_tools_parallel(
self, turn_tool_calls, tools, turn_ctx: AgentTurnContext, session, stop_event, tool_ctx=None
):
"""Execute an independent batch of tool calls concurrently.
ToolStarted is emitted for every call up-front (in call order) so the
UI shows the whole batch immediately. Live events from all tools merge
through one tagged queue; results and tool messages are appended in
call order after the batch settles, with a single session save.
Only batch independent calls — two actions over the same terminal in
one batch race each other.
"""
from navi.tools.todo import started_metadata_for_call
tool_map = {t.name: t for t in tools}
for tc in turn_tool_calls:
yield ToolStarted(
tool_name=tc.name,
arguments=tc.arguments,
tool_call_id=tc.id,
metadata=await started_metadata_for_call(tc, tool_ctx),
)
shared: asyncio.Queue = asyncio.Queue()
holders: list[list] = [[] for _ in turn_tool_calls]
tasks: list[asyncio.Task] = []
for idx, tc in enumerate(turn_tool_calls):
tagged = _TaggedSink(shared, idx)
sink_token = current_event_sink.set(tagged)
async def _run_with_sentinel(_tc=tc, _idx=idx, _holder=holders[idx], _sink=tagged):
try:
_holder.append(
await self._tool_executor._run_single_tool(_tc, tool_map, ctx=tool_ctx)
)
except Exception as exc:
_holder.append(exc)
finally:
await _sink.put(_TOOL_DONE)
tasks.append(asyncio.create_task(_run_with_sentinel()))
current_event_sink.reset(sink_token)
try:
pending = set(range(len(tasks)))
stopped = False
while pending:
try:
item = await asyncio.wait_for(shared.get(), timeout=1.0)
except asyncio.TimeoutError:
if stop_event and stop_event.is_set():
stopped = True
break
continue
_idx, value = item
if value is _TOOL_DONE:
pending.discard(_idx)
continue
if isinstance(value, SubagentComplete):
turn_ctx.subagent_tokens += value.token_count
turn_ctx.tool_call_count += value.tool_call_count
elif isinstance(value, AIHelperTokensUsed):
turn_ctx.subagent_tokens += value.completion_tokens
else:
yield value
if stopped:
# Stop must actually stop work: cancel in-flight tools the same
# way the sequential path does (docs/tasks.md: stop mid-batch
# cancels every unfinished tool). Calls that already finished
# keep their real results — only unfinished ones get the
# synthetic stopped note below; erasing a completed
# backgrounded call (task_id) would orphan the task in the
# client UI.
log.info(
"agent.batch_stopped",
tools=[tc.name for tc in turn_tool_calls],
)
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
# Same reason as in the sequential path: whatever the cancelled
# tools announced before unwinding still belongs to cards the
# client is showing, so it is forwarded rather than dropped.
while True:
try:
_idx, value = shared.get_nowait()
except asyncio.QueueEmpty:
break
if value is _TOOL_DONE:
continue
if isinstance(value, SubagentComplete):
turn_ctx.subagent_tokens += value.token_count
turn_ctx.tool_call_count += value.tool_call_count
elif isinstance(value, AIHelperTokensUsed):
turn_ctx.subagent_tokens += value.completion_tokens
else:
yield value
# Results and messages in call order, one save for the batch.
# A stopped batch mixes real results (finished calls) with the
# synthetic stopped note (cancelled calls).
for tc, holder in zip(turn_tool_calls, holders):
if not holder:
if stopped:
yield ToolEvent(
tool_name=tc.name,
arguments=tc.arguments,
result="Tool execution was stopped by the user.",
success=False,
tool_call_id=tc.id,
)
session.messages.append(
Message(
role="tool",
content="Tool execution was stopped by the user.",
tool_call_id=tc.id,
name=tc.name,
metadata={},
is_context=False,
)
)
continue
r = RuntimeError("tool task produced no result")
else:
r = holder[0]
if isinstance(r, Exception):
if isinstance(r, (LLMBackendError, LLMConnectionError)):
raise r
log.warning("agent.tool_exception", tool=tc.name, error=str(r))
tool_event = ToolEvent(
tool_name=tc.name,
arguments=tc.arguments,
result=f"Error: {r}",
success=False,
tool_call_id=tc.id,
)
msg = Message(
role="tool",
content=f"Error: {r}",
tool_call_id=tc.id,
name=tc.name,
metadata={},
)
image_msg = None
else:
tool_event, msg, image_msg = r
turn_ctx.tool_call_count += 1
yield tool_event
session.messages.append(msg)
session.context.append(msg)
if image_msg:
session.messages.append(image_msg)
session.context.append(image_msg)
await self._sessions.save(session)
finally:
# Cancel whatever is still running (generator closed mid-batch or
# an outer cancellation of the run), then gather to contain each
# cancelled task — awaiting the results directly would re-raise
# CancelledError past the results already produced, while an
# outer cancellation of the run itself still propagates.
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def _execute_tools_sequential(
self, turn_tool_calls, tools, turn_ctx: AgentTurnContext, session, stop_event, tool_ctx=None
):
"""Execute tool calls one at a time with cooperative stop support.
Polls *stop_event* every second while draining the event sink so the
Stop button works even during long-running tools (terminal, SSH,
web search, sub-agent spawn).
"""
tool_map = {t.name: t for t in tools}
for tc in turn_tool_calls:
from navi.tools.todo import started_metadata_for_call
yield ToolStarted(
tool_name=tc.name,
arguments=tc.arguments,
tool_call_id=tc.id,
metadata=await started_metadata_for_call(tc, tool_ctx),
)
sink: asyncio.Queue = asyncio.Queue()
sink_token = current_event_sink.set(sink)
result_holder: list = []
async def _run_with_sentinel(_tc=tc, _holder=result_holder, _sink=sink):
try:
_holder.append(
await self._tool_executor._run_single_tool(_tc, tool_map, ctx=tool_ctx)
)
except Exception as exc:
_holder.append(exc)
finally:
await _sink.put(_TOOL_DONE)
tool_task = asyncio.create_task(_run_with_sentinel())
current_event_sink.reset(sink_token)
try:
# Poll sink with 1s timeout so stop_event is checked during long tools
stopped = False
while True:
try:
item = await asyncio.wait_for(sink.get(), timeout=1.0)
except asyncio.TimeoutError:
if stop_event and stop_event.is_set():
stopped = True
if not tool_task.done():
tool_task.cancel()
break
continue
if item is _TOOL_DONE:
break
if isinstance(item, SubagentComplete):
turn_ctx.subagent_tokens += item.token_count
turn_ctx.tool_call_count += item.tool_call_count
elif isinstance(item, AIHelperTokensUsed):
turn_ctx.subagent_tokens += item.completion_tokens
else:
yield item
if stopped:
# A cancelled tool may still have announced work the client
# is already showing: a sub-agent emits a closing ToolEvent
# for its in-flight tool from the CancelledError handler.
# Read those before dropping the queue, or the client keeps
# a spinner it can never resolve until the page reloads.
await asyncio.gather(tool_task, return_exceptions=True)
while True:
try:
item = sink.get_nowait()
except asyncio.QueueEmpty:
break
if item is _TOOL_DONE:
continue
if isinstance(item, SubagentComplete):
turn_ctx.subagent_tokens += item.token_count
turn_ctx.tool_call_count += item.tool_call_count
elif isinstance(item, AIHelperTokensUsed):
turn_ctx.subagent_tokens += item.completion_tokens
else:
yield item
# Tool was interrupted by user — record a synthetic cancellation result
log.info("agent.tool_stopped", tool=tc.name)
yield ToolEvent(
tool_name=tc.name,
arguments=tc.arguments,
result="Tool execution was stopped by the user.",
success=False,
tool_call_id=tc.id,
)
session.messages.append(
Message(
role="tool",
content="Tool execution was stopped by the user.",
tool_call_id=tc.id,
name=tc.name,
metadata={},
is_context=False,
)
)
await self._sessions.save(session)
return
r = (
result_holder[0]
if result_holder
else RuntimeError("tool task produced no result")
)
if isinstance(r, Exception):
if isinstance(r, (LLMBackendError, LLMConnectionError)):
raise r
log.warning("agent.tool_exception", tool=tc.name, error=str(r))
tool_event = ToolEvent(
tool_name=tc.name,
arguments=tc.arguments,
result=f"Error: {r}",
success=False,
tool_call_id=tc.id,
)
msg = Message(
role="tool",
content=f"Error: {r}",
tool_call_id=tc.id,
name=tc.name,
metadata={},
)
image_msg = None
else:
tool_event, msg, image_msg = r
turn_ctx.tool_call_count += 1
yield tool_event
session.messages.append(msg)
session.context.append(msg)
if image_msg:
session.messages.append(image_msg)
session.context.append(image_msg)
# Persist each tool result as it completes, so a crash /
# CancelledError (server restart) between tool calls cannot lose
# already-finished work — only the in-flight single tool is at risk.
await self._sessions.save(session)
finally:
if not tool_task.done():
tool_task.cancel()
# gather(return_exceptions=True) contains the cancelled tool
# task (awaiting it directly would re-raise CancelledError and
# abort the turn before the synthetic stopped results land),
# while an outer cancellation of the run itself propagates.
await asyncio.gather(tool_task, return_exceptions=True)
async def _todo_updated(self, session) -> TodoUpdated:
"""Build a TodoUpdated event from the session's current todo state.
Reads via explicit (user_id, session_id) so it works regardless of the
ContextVar state at the call site. Sub-agent todos are isolated in their
own KV row and never reach here (the parent's session.id is the parent's).
"""
from navi.tools.todo import load_tasks_for
tasks = await load_tasks_for(session.id, session.user_id)
return TodoUpdated(session_id=session.id, tasks=tasks)