fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
1056 lines
45 KiB
Python
1056 lines
45 KiB
Python
import logging
|
|
import re
|
|
import uuid
|
|
from collections import Counter
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from application.agents.default_tools import (
|
|
is_headless_excluded_tool,
|
|
resolve_tool_by_id,
|
|
synthesized_default_tools,
|
|
)
|
|
from application.agents.tools.tool_action_parser import ToolActionParser
|
|
from application.agents.tools.tool_manager import ToolManager
|
|
from application.security.encryption import decrypt_credentials
|
|
from application.storage.db.base_repository import looks_like_uuid
|
|
from application.storage.db.repositories.agents import AgentsRepository
|
|
from application.storage.db.repositories.tool_call_attempts import (
|
|
ToolCallAttemptsRepository,
|
|
)
|
|
from application.storage.db.repositories.user_tools import UserToolsRepository
|
|
from application.storage.db.repositories.users import UsersRepository
|
|
from application.storage.db.session import db_readonly, db_session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Tightest provider limit on function-call names (OpenAI: ^[a-zA-Z0-9_-]{1,64}$).
|
|
_MAX_LLM_NAME_LEN = 64
|
|
|
|
|
|
def _sanitize_tool_prefix(tool_name: Optional[str]) -> str:
|
|
"""Reduce a tool name to characters allowed in function-call names."""
|
|
return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(tool_name or "")).strip("_")
|
|
|
|
|
|
# Longest string value rendered into a debug log line; longer values (e.g. an
|
|
# LLM-authored ``code`` body or an api_tool ``body``) are truncated so the full
|
|
# program/secret is never written to logs even at DEBUG level.
|
|
_LOG_VALUE_PREVIEW_LEN = 80
|
|
|
|
# Longest tool result persisted on the message / streamed to the UI. The LLM
|
|
# and the ``tool_call_attempts`` journal always receive the full result; this
|
|
# only bounds the message JSONB copy. 50 chars hid every real error behind
|
|
# "...", making retry storms undiagnosable from the stored conversation.
|
|
PERSISTED_RESULT_MAX_LEN = 2000
|
|
|
|
|
|
def truncate_tool_result(value: Any) -> Any:
|
|
"""Bound a tool result for persistence/streaming; short values pass through unchanged."""
|
|
text = value if isinstance(value, str) else str(value)
|
|
if len(text) <= PERSISTED_RESULT_MAX_LEN:
|
|
return value
|
|
return f"{text[:PERSISTED_RESULT_MAX_LEN]}..."
|
|
|
|
|
|
def result_status(result: Any) -> str:
|
|
"""Derive the persisted status from a tool's result payload.
|
|
|
|
Tools report failure in-band (``{"status": "error", ...}`` or an ``error``
|
|
key) while the executor used to stamp every returned result ``completed``,
|
|
so the stored conversation showed failed calls as successes.
|
|
"""
|
|
if isinstance(result, dict) and (result.get("status") == "error" or result.get("error")):
|
|
return "error"
|
|
return "completed"
|
|
|
|
|
|
def _redact_args_for_log(args: Any) -> Any:
|
|
"""Truncate long string values so a code/body argument never lands in logs in full."""
|
|
if not isinstance(args, dict):
|
|
text = str(args)
|
|
return text if len(text) <= _LOG_VALUE_PREVIEW_LEN else f"{text[:_LOG_VALUE_PREVIEW_LEN]}...(truncated)"
|
|
redacted: Dict[str, Any] = {}
|
|
for key, value in args.items():
|
|
if isinstance(value, str) and len(value) > _LOG_VALUE_PREVIEW_LEN:
|
|
redacted[key] = f"{value[:_LOG_VALUE_PREVIEW_LEN]}...(truncated, {len(value)} chars)"
|
|
elif isinstance(value, (dict, list)):
|
|
redacted[key] = f"<{type(value).__name__} omitted>"
|
|
else:
|
|
redacted[key] = value
|
|
return redacted
|
|
|
|
|
|
def _journal_key(call_id: str, message_id: Optional[str]) -> str:
|
|
"""Namespace the durability-journal key by the per-turn ``message_id``.
|
|
|
|
``tool_call_attempts.call_id`` is a table-wide primary key, but providers
|
|
reuse deterministic ids (e.g. ``functions.create_artifact:0``) across turns
|
|
and users, so distinct calls collide on that PK and the later journal rows
|
|
are silently dropped (``ON CONFLICT DO NOTHING``). Scoping the key by
|
|
``message_id`` (unique per turn) gives each logical call its own row while a
|
|
genuine retry of the same call within the same turn still dedupes. The raw
|
|
``call_id`` is left untouched for LLM tool-call/tool-result pairing and the
|
|
UI. Headless attempts with no ``message_id`` keep the raw key (unchanged
|
|
pre-existing behaviour).
|
|
"""
|
|
return f"{message_id}:{call_id}" if message_id else call_id
|
|
|
|
|
|
def _record_proposed(
|
|
call_id: str,
|
|
tool_name: str,
|
|
action_name: str,
|
|
arguments: Any,
|
|
*,
|
|
tool_id: Optional[str] = None,
|
|
message_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
agent_id: Optional[str] = None,
|
|
) -> bool:
|
|
"""Insert a ``proposed`` row; swallow infra failures so tool calls
|
|
still run when the journal is unreachable. Returns True iff THIS call
|
|
created the row.
|
|
|
|
A duplicate ``call_id`` (LLMs reuse "call_0"-style ids) hits
|
|
``ON CONFLICT DO NOTHING`` and returns False: the existing row may
|
|
belong to another in-flight request, so callers must not then flip it
|
|
via ``_mark_failed`` / ``_mark_executed``.
|
|
"""
|
|
try:
|
|
with db_session() as conn:
|
|
inserted = ToolCallAttemptsRepository(conn).record_proposed(
|
|
_journal_key(call_id, message_id),
|
|
tool_name,
|
|
action_name,
|
|
arguments,
|
|
tool_id=tool_id if tool_id and looks_like_uuid(tool_id) else None,
|
|
message_id=message_id,
|
|
user_id=user_id,
|
|
agent_id=(str(agent_id) if agent_id and looks_like_uuid(str(agent_id)) else None),
|
|
)
|
|
if not inserted:
|
|
logger.warning(
|
|
"tool_call_attempts duplicate call_id=%s; existing row left in place",
|
|
call_id,
|
|
extra={"alert": "tool_call_id_collision", "call_id": call_id},
|
|
)
|
|
return inserted
|
|
except Exception:
|
|
logger.exception("tool_call_attempts proposed write failed for %s", call_id)
|
|
return False
|
|
|
|
|
|
def _mark_executed(
|
|
call_id: str,
|
|
result: Any,
|
|
*,
|
|
message_id: Optional[str] = None,
|
|
artifact_id: Optional[str] = None,
|
|
proposed_ok: bool = True,
|
|
tool_name: Optional[str] = None,
|
|
action_name: Optional[str] = None,
|
|
arguments: Any = None,
|
|
tool_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
agent_id: Optional[str] = None,
|
|
) -> None:
|
|
"""Flip the row to ``executed``. If ``proposed_ok`` is False (the
|
|
proposed write failed earlier), upsert a fresh row in ``executed`` so
|
|
the reconciler can still see the attempt — without this, the side
|
|
effect would be invisible to the journal. Both paths are scoped to
|
|
the owning ``user_id`` so a reused ``call_id`` can't cross tenants.
|
|
"""
|
|
key = _journal_key(call_id, message_id)
|
|
try:
|
|
with db_session() as conn:
|
|
repo = ToolCallAttemptsRepository(conn)
|
|
if proposed_ok:
|
|
updated = repo.mark_executed(
|
|
key,
|
|
result,
|
|
message_id=message_id,
|
|
artifact_id=artifact_id,
|
|
user_id=user_id,
|
|
)
|
|
if updated:
|
|
return
|
|
# Fallback synthesizes the row so the journal isn't lost.
|
|
repo.upsert_executed(
|
|
key,
|
|
tool_name=tool_name or "unknown",
|
|
action_name=action_name or "",
|
|
arguments=arguments if arguments is not None else {},
|
|
result=result,
|
|
tool_id=tool_id if tool_id and looks_like_uuid(tool_id) else None,
|
|
message_id=message_id,
|
|
artifact_id=artifact_id,
|
|
user_id=user_id,
|
|
agent_id=(str(agent_id) if agent_id and looks_like_uuid(str(agent_id)) else None),
|
|
)
|
|
except Exception:
|
|
logger.exception("tool_call_attempts executed write failed for %s", call_id)
|
|
|
|
|
|
def _mark_failed(
|
|
call_id: str,
|
|
error: str,
|
|
*,
|
|
message_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
) -> None:
|
|
try:
|
|
with db_session() as conn:
|
|
ToolCallAttemptsRepository(conn).mark_failed(
|
|
_journal_key(call_id, message_id), error, user_id=user_id
|
|
)
|
|
except Exception:
|
|
logger.exception("tool_call_attempts failed-write failed for %s", call_id)
|
|
|
|
|
|
class ToolExecutor:
|
|
"""Handles tool discovery, preparation, and execution.
|
|
|
|
Extracted from BaseAgent to separate concerns and enable tool caching.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
user_api_key: Optional[str] = None,
|
|
user: Optional[str] = None,
|
|
decoded_token: Optional[Dict] = None,
|
|
agent_id: Optional[str] = None,
|
|
*,
|
|
headless: bool = False,
|
|
tool_allowlist: Optional[List[str]] = None,
|
|
):
|
|
self.user_api_key = user_api_key
|
|
self.user = user
|
|
self.decoded_token = decoded_token
|
|
self.agent_id = agent_id
|
|
# Headless mode (scheduled / webhook): no human to resolve a pause,
|
|
# so check_pause returns headless_denied sentinels instead.
|
|
self.headless = bool(headless)
|
|
# Tool-instance ids pre-authorized for headless approval-gated execution.
|
|
self.tool_allowlist: set = {str(x) for x in tool_allowlist} if tool_allowlist else set()
|
|
self.tool_calls: List[Dict] = []
|
|
self._loaded_tools: Dict[str, object] = {}
|
|
# Explicit tool-id scope (workflow agent nodes): when set (even empty),
|
|
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
|
|
# user_tools rows alike — with no defaults mixed in. None = unscoped.
|
|
self.allowed_tool_ids: Optional[List[str]] = None
|
|
self.conversation_id: Optional[str] = None
|
|
# Set by the workflow engine for agent nodes so run-scoped tools
|
|
# (artifact_generator / code_executor) address artifacts by the
|
|
# workflow run rather than a conversation.
|
|
self.workflow_run_id: Optional[str] = None
|
|
self.message_id: Optional[str] = None
|
|
# The request's own (already user-scoped) chat attachments, stamped onto
|
|
# sandbox tools so a referenced attachment can be lazily bridged to a
|
|
# conversation-scoped artifact at tool-use time.
|
|
self.attachments: List[Dict] = []
|
|
self.client_tools: Optional[List[Dict]] = None
|
|
self._name_to_tool: Dict[str, Tuple[str, str]] = {}
|
|
self._tool_to_name: Dict[Tuple[str, str], str] = {}
|
|
# Filled by the LLMHandler.handle_tool_calls headless loop.
|
|
self.headless_denials: List[Dict] = []
|
|
|
|
def get_tools(self) -> Dict[str, Dict]:
|
|
"""Load tool configs from DB based on user context.
|
|
|
|
If *client_tools* have been set on this executor, they are
|
|
automatically merged into the returned dict.
|
|
"""
|
|
if self.allowed_tool_ids is not None:
|
|
tools = self._get_tools_by_ids(self.allowed_tool_ids)
|
|
elif self.user_api_key:
|
|
tools = self._get_tools_by_api_key(self.user_api_key)
|
|
else:
|
|
tools = self._get_user_tools(self.user or "local")
|
|
if self.client_tools:
|
|
self.merge_client_tools(tools, self.client_tools)
|
|
return tools
|
|
|
|
def get_enabled_tool_names(self) -> set:
|
|
"""Return the set of tool names enabled for this context.
|
|
|
|
Authoritative (resolves through :meth:`get_tools`): an agent yields its
|
|
configured ``agents.tools``; an agentless chat yields the user's active
|
|
tools plus the synthesized defaults. Used to gate tool-specific prompt
|
|
sections via the ``tools.enabled`` template namespace.
|
|
"""
|
|
return {str(tool["name"]) for tool in self.get_tools().values() if isinstance(tool, dict) and tool.get("name")}
|
|
|
|
def _get_tools_by_ids(self, tool_ids: List[str]) -> Dict[str, Dict]:
|
|
"""Resolve an explicit tool-id scope — exactly these ids, no defaults.
|
|
|
|
Used by workflow agent nodes: the node's configured tools (builtin
|
|
synthetic ids like Artifact/Code Executor/Read Document, or the user's
|
|
``user_tools`` rows) are the node's WHOLE toolset. An unresolvable id
|
|
is dropped with a warning rather than failing the node.
|
|
"""
|
|
if not tool_ids:
|
|
return {}
|
|
with db_readonly() as conn:
|
|
tools_repo = UserToolsRepository(conn)
|
|
tools: List[Dict] = []
|
|
for tid in tool_ids:
|
|
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
|
|
if row is None:
|
|
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
|
|
continue
|
|
if self.headless and is_headless_excluded_tool(row.get("name")):
|
|
continue
|
|
tools.append(row)
|
|
return {str(tool["id"]): tool for tool in tools}
|
|
|
|
def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]:
|
|
"""Resolve an agent's toolset — exactly ``agents.tools``, no defaults."""
|
|
# Per-operation session: the answer pipeline spans a long-lived
|
|
# generator; wrapping it in a single connection would pin a PG
|
|
# conn for the whole stream. Open, fetch, close.
|
|
with db_readonly() as conn:
|
|
agent_data = AgentsRepository(conn).find_by_key(api_key)
|
|
tool_ids = agent_data.get("tools", []) if agent_data else []
|
|
tools_repo = UserToolsRepository(conn)
|
|
owner = (agent_data.get("user_id") or agent_data.get("user")) if agent_data else None
|
|
tools: List[Dict] = []
|
|
for tid in tool_ids:
|
|
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
|
|
if row is None:
|
|
continue
|
|
# Workflow-only builtins (read_document) never resolve for a
|
|
# chat/scheduled agent — nodes get them via the scoped-id path.
|
|
if row.get("workflow_only"):
|
|
continue
|
|
# Headless runs (scheduled / webhook) drop chat-only tools
|
|
# like ``scheduler`` so a fire-time LLM can't chain schedules.
|
|
if self.headless and is_headless_excluded_tool(row.get("name")):
|
|
continue
|
|
tools.append(row)
|
|
return {str(tool["id"]): tool for tool in tools}
|
|
|
|
def _get_user_tools(self, user: str = "local") -> Dict[str, Dict]:
|
|
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
|
|
with db_readonly() as conn:
|
|
user_tools = UserToolsRepository(conn).list_active_for_user(user)
|
|
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
|
|
# Headless agentless runs (e.g. scheduled fire) drop chat-only
|
|
# tools (``scheduler``) from explicit user_tools too.
|
|
filtered_user_tools = [
|
|
t for t in user_tools if not (self.headless and is_headless_excluded_tool(t.get("name")))
|
|
]
|
|
# Index keys (ints) and synthetic uuid5 keys can't collide.
|
|
tools: Dict[str, Dict] = {str(i): tool for i, tool in enumerate(filtered_user_tools)}
|
|
if self.agent_id is None:
|
|
for default_row in synthesized_default_tools(
|
|
user_doc,
|
|
headless=self.headless,
|
|
):
|
|
tools[str(default_row["id"])] = default_row
|
|
return tools
|
|
|
|
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
|
|
"""Merge client-provided tool definitions into tools_dict.
|
|
|
|
Client tools use the standard function-calling format::
|
|
|
|
[{"type": "function", "function": {"name": "get_weather",
|
|
"description": "...", "parameters": {...}}}]
|
|
|
|
They are stored in *tools_dict* with ``client_side: True`` so that
|
|
:meth:`check_pause` returns a pause signal instead of trying to
|
|
execute them server-side.
|
|
|
|
Args:
|
|
tools_dict: The mutable server tools dict (will be modified in place).
|
|
client_tools: List of tool definitions in function-calling format.
|
|
|
|
Returns:
|
|
The updated *tools_dict* (same reference, for convenience).
|
|
"""
|
|
for i, ct in enumerate(client_tools):
|
|
func = ct.get("function", ct) # tolerate bare {"name":..} too
|
|
name = func.get("name", f"clienttool{i}")
|
|
tool_id = f"ct{i}"
|
|
|
|
tools_dict[tool_id] = {
|
|
"name": name,
|
|
"client_side": True,
|
|
"actions": [
|
|
{
|
|
"name": name,
|
|
"description": func.get("description", ""),
|
|
"active": True,
|
|
"parameters": func.get("parameters", {}),
|
|
}
|
|
],
|
|
}
|
|
return tools_dict
|
|
|
|
def prepare_tools_for_llm(self, tools_dict: Dict) -> List[Dict]:
|
|
"""Convert tool configs to LLM function schemas.
|
|
|
|
Action names are kept clean for the LLM:
|
|
- Unique action names appear as-is (e.g. ``get_weather``).
|
|
- Duplicate action names are disambiguated with the owning tool's
|
|
name (e.g. ``brave_search``, ``duckduckgo_search``); a numeric
|
|
suffix only breaks ties between same-named tools.
|
|
- Every name is clamped to the 64-character provider limit.
|
|
|
|
A reverse mapping is stored in ``_name_to_tool`` so that tool calls
|
|
can be routed back to the correct ``(tool_id, action_name)`` without
|
|
brittle string splitting.
|
|
"""
|
|
# Pass 1: collect entries and count action name occurrences
|
|
# (tool_id, tool_name, action_name, action, is_client)
|
|
entries: List[Tuple[str, str, str, Dict, bool]] = []
|
|
name_counts: Counter = Counter()
|
|
|
|
for tool_id, tool in tools_dict.items():
|
|
is_api = tool["name"] == "api_tool"
|
|
is_client = tool.get("client_side", False)
|
|
|
|
if is_api and "actions" not in tool.get("config", {}):
|
|
continue
|
|
if not is_api and "actions" not in tool:
|
|
continue
|
|
|
|
actions = tool["config"]["actions"].values() if is_api else tool["actions"]
|
|
|
|
for action in actions:
|
|
if not action.get("active", True):
|
|
continue
|
|
entries.append((tool_id, tool.get("name", ""), action["name"], action, is_client))
|
|
name_counts[action["name"]] += 1
|
|
|
|
# Pass 2: assign LLM-visible names and build mappings
|
|
self._name_to_tool = {}
|
|
self._tool_to_name = {}
|
|
all_llm_names: set = set()
|
|
|
|
result = []
|
|
for tool_id, tool_name, action_name, action, is_client in entries:
|
|
if name_counts[action_name] == 1 and len(action_name) <= _MAX_LLM_NAME_LEN:
|
|
llm_name = action_name
|
|
else:
|
|
# An over-long unique name skips the prefix — it needs
|
|
# truncation, not disambiguation.
|
|
prefix = _sanitize_tool_prefix(tool_name) if name_counts[action_name] > 1 else ""
|
|
base = f"{prefix}_{action_name}" if prefix and not action_name.startswith(f"{prefix}_") else action_name
|
|
base = base[:_MAX_LLM_NAME_LEN]
|
|
# A duplicated bare name stays ambiguous, and a candidate
|
|
# must not steal a unique action's name or one already taken.
|
|
candidate = base
|
|
counter = 1
|
|
while candidate == action_name or candidate in all_llm_names or name_counts.get(candidate, 0) == 1:
|
|
suffix = f"_{counter}"
|
|
candidate = base[: _MAX_LLM_NAME_LEN - len(suffix)] + suffix
|
|
counter += 1
|
|
llm_name = candidate
|
|
|
|
all_llm_names.add(llm_name)
|
|
self._name_to_tool[llm_name] = (tool_id, action_name)
|
|
self._tool_to_name[(tool_id, action_name)] = llm_name
|
|
|
|
if is_client:
|
|
params = action.get("parameters", {})
|
|
else:
|
|
params = self._build_tool_parameters(action)
|
|
|
|
result.append(
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": llm_name,
|
|
"description": action.get("description", ""),
|
|
"parameters": params,
|
|
},
|
|
}
|
|
)
|
|
return result
|
|
|
|
def _build_tool_parameters(self, action: Dict) -> Dict:
|
|
params = {"type": "object", "properties": {}, "required": []}
|
|
for param_type in ["query_params", "headers", "body", "parameters"]:
|
|
if param_type in action and action[param_type].get("properties"):
|
|
for k, v in action[param_type]["properties"].items():
|
|
if v.get("filled_by_llm", True):
|
|
params["properties"][k] = {
|
|
key: value for key, value in v.items() if key not in ("filled_by_llm", "value", "required")
|
|
}
|
|
if v.get("required", False):
|
|
params["required"].append(k)
|
|
return params
|
|
|
|
def check_pause(self, tools_dict: Dict, call, llm_class_name: str) -> Optional[Dict]:
|
|
"""Return a pending-action dict (approval / client / headless_denied) or None.
|
|
|
|
In headless mode the dict's pause_type is ``headless_denied`` so the
|
|
upstream loop synthesizes a tool result instead of pausing (nothing can
|
|
resume a scheduled / webhook run).
|
|
"""
|
|
parser = ToolActionParser(llm_class_name, name_mapping=self._name_to_tool)
|
|
tool_id, action_name, call_args = parser.parse_args(call)
|
|
call_id = getattr(call, "id", None) or str(uuid.uuid4())
|
|
llm_name = getattr(call, "name", "")
|
|
|
|
if tool_id is None or action_name is None or tool_id not in tools_dict:
|
|
return None # Will be handled as error by execute()
|
|
|
|
tool_data = tools_dict[tool_id]
|
|
arguments = call_args if isinstance(call_args, dict) else {}
|
|
|
|
# Client-side tools
|
|
if tool_data.get("client_side"):
|
|
if self.headless:
|
|
return {
|
|
"call_id": call_id,
|
|
"name": llm_name,
|
|
"tool_name": tool_data.get("name", "unknown"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
"llm_name": llm_name,
|
|
"arguments": arguments,
|
|
"pause_type": "headless_denied",
|
|
"deny_reason": ("Client-side tools cannot run in headless / scheduled runs."),
|
|
"error_type": "tool_not_allowed",
|
|
"thought_signature": getattr(call, "thought_signature", None),
|
|
}
|
|
return {
|
|
"call_id": call_id,
|
|
"name": llm_name,
|
|
"tool_name": tool_data.get("name", "unknown"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
"llm_name": llm_name,
|
|
"arguments": arguments,
|
|
"pause_type": "requires_client_execution",
|
|
"thought_signature": getattr(call, "thought_signature", None),
|
|
}
|
|
|
|
# Approval required
|
|
if tool_data["name"] == "api_tool":
|
|
action_data = tool_data.get("config", {}).get("actions", {}).get(action_name, {})
|
|
else:
|
|
action_data = next(
|
|
(a for a in tool_data.get("actions", []) if a["name"] == action_name),
|
|
{},
|
|
)
|
|
|
|
require_approval = bool(action_data.get("require_approval"))
|
|
# ``denylist_forced`` marks a prompt the hard denylist mandates; a
|
|
# headless allowlist must never bypass it (see below).
|
|
denylist_forced = False
|
|
# ``remote_device`` decides per-invocation based on the live device
|
|
# state (``approval_mode``, sticky patterns, allow/denylist). The
|
|
# cached ``user_tools.actions[].require_approval`` snapshot does
|
|
# not reflect later approval-mode changes nor command-level
|
|
# heuristics, so consult the tool directly.
|
|
if tool_data.get("name") == "remote_device":
|
|
require_approval, denylist_forced = self._remote_device_requires_approval(
|
|
tool_data,
|
|
action_name,
|
|
arguments,
|
|
)
|
|
elif tool_data.get("name") == "code_executor":
|
|
# The deployment-level ``config.require_approval`` is authoritative
|
|
# over the cached action snapshot, so consult the tool directly.
|
|
require_approval = (
|
|
self._code_executor_requires_approval(
|
|
tool_data,
|
|
action_name,
|
|
arguments,
|
|
)
|
|
or require_approval
|
|
)
|
|
|
|
if require_approval:
|
|
if self.headless:
|
|
tool_row_id = str(tool_data.get("id") or tool_id)
|
|
# A denylist-forced prompt is never pre-authorizable: a
|
|
# scheduled/headless run with the device allowlisted must
|
|
# still be denied a denylisted command. Only non-forced
|
|
# approvals honor the allowlist bypass.
|
|
if tool_row_id in self.tool_allowlist and not denylist_forced:
|
|
# Pre-authorized for headless execution — fall through.
|
|
return None
|
|
return {
|
|
"call_id": call_id,
|
|
"name": llm_name,
|
|
"tool_name": tool_data.get("name", "unknown"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
"llm_name": llm_name,
|
|
"arguments": arguments,
|
|
"pause_type": "headless_denied",
|
|
"deny_reason": ("This tool requires approval and is not in the run's tool_allowlist."),
|
|
"error_type": "tool_not_allowed",
|
|
"thought_signature": getattr(call, "thought_signature", None),
|
|
}
|
|
payload = {
|
|
"call_id": call_id,
|
|
"name": llm_name,
|
|
"tool_name": tool_data.get("name", "unknown"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
"llm_name": llm_name,
|
|
"arguments": arguments,
|
|
"pause_type": "awaiting_approval",
|
|
"thought_signature": getattr(call, "thought_signature", None),
|
|
}
|
|
# Surface the device id so the approval UI can offer a
|
|
# "don't ask again" sticky-pattern action for remote devices.
|
|
if tool_data.get("name") == "remote_device":
|
|
config = tool_data.get("config") or {}
|
|
if config.get("device_id"):
|
|
payload["device_id"] = config["device_id"]
|
|
return payload
|
|
|
|
return None
|
|
|
|
def _remote_device_requires_approval(
|
|
self,
|
|
tool_data: Dict,
|
|
action_name: str,
|
|
arguments: Dict,
|
|
) -> tuple[bool, bool]:
|
|
"""Live approval decision for a ``remote_device`` invocation.
|
|
|
|
Instantiates ``RemoteDeviceTool`` with the cached config and the
|
|
executor's user context, then asks it to evaluate the command.
|
|
Returns ``(requires_approval, denylist_forced)``. Falls back to a
|
|
denylist-forced prompt on any error so a misconfigured device never
|
|
silently bypasses the prompt — not even via the headless allowlist.
|
|
"""
|
|
try:
|
|
from application.agents.tools.remote_device import RemoteDeviceTool
|
|
|
|
tool = RemoteDeviceTool(
|
|
config=tool_data.get("config") or {},
|
|
user_id=self.user,
|
|
)
|
|
return tool.preview_decision(action_name, arguments)
|
|
except Exception:
|
|
logger.exception(
|
|
"remote_device preview_decision failed; defaulting to a forced prompt",
|
|
)
|
|
return True, True
|
|
|
|
def _code_executor_requires_approval(
|
|
self,
|
|
tool_data: Dict,
|
|
action_name: str,
|
|
arguments: Dict,
|
|
) -> bool:
|
|
"""Live approval decision for a ``code_executor`` invocation.
|
|
|
|
Honors the deployment-level ``config.require_approval`` even when the
|
|
cached action snapshot is stale. Fails closed (require approval) on any
|
|
error so a misconfigured tool never silently runs untrusted code.
|
|
"""
|
|
try:
|
|
from application.agents.tools.code_executor import CodeExecutorTool
|
|
|
|
tool = CodeExecutorTool(
|
|
tool_config=tool_data.get("config") or {},
|
|
user_id=self.user,
|
|
)
|
|
requires_approval, _forced = tool.preview_decision(action_name, arguments)
|
|
return requires_approval
|
|
except Exception:
|
|
logger.exception(
|
|
"code_executor preview_decision failed; defaulting to a prompt",
|
|
)
|
|
return True
|
|
|
|
def execute(self, tools_dict: Dict, call, llm_class_name: str):
|
|
"""Execute a tool call. Yields status events, returns (result, call_id)."""
|
|
parser = ToolActionParser(llm_class_name, name_mapping=self._name_to_tool)
|
|
tool_id, action_name, call_args = parser.parse_args(call)
|
|
llm_name = getattr(call, "name", "unknown")
|
|
|
|
call_id = getattr(call, "id", None) or str(uuid.uuid4())
|
|
|
|
if tool_id is None or action_name is None:
|
|
error_message = f"Error: Failed to parse LLM tool call. Tool name: {llm_name}"
|
|
logger.error(
|
|
"tool_call_parse_failed",
|
|
extra={
|
|
"llm_class_name": llm_class_name,
|
|
"llm_tool_name": llm_name,
|
|
"call_id": call_id,
|
|
},
|
|
)
|
|
|
|
tool_call_data = {
|
|
"tool_name": "unknown",
|
|
"call_id": call_id,
|
|
"action_name": llm_name,
|
|
"arguments": call_args or {},
|
|
"result": f"Failed to parse tool call. Invalid tool name format: {llm_name}",
|
|
"status": "error",
|
|
}
|
|
# Journal the malformed call so it still shows up in tool analytics.
|
|
if _record_proposed(
|
|
call_id,
|
|
"unknown",
|
|
llm_name or "unknown",
|
|
call_args if isinstance(call_args, dict) else {},
|
|
message_id=self.message_id,
|
|
user_id=self.user,
|
|
agent_id=self.agent_id,
|
|
):
|
|
_mark_failed(
|
|
call_id,
|
|
tool_call_data["result"],
|
|
message_id=self.message_id,
|
|
user_id=self.user,
|
|
)
|
|
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
|
|
self.tool_calls.append(tool_call_data)
|
|
return "Failed to parse tool call.", call_id
|
|
|
|
if tool_id not in tools_dict:
|
|
error_message = f"Error: Tool ID '{tool_id}' extracted from LLM call not found in available tools_dict. Available IDs: {list(tools_dict.keys())}"
|
|
logger.error(
|
|
"tool_id_not_found",
|
|
extra={
|
|
"tool_id": tool_id,
|
|
"llm_tool_name": llm_name,
|
|
"call_id": call_id,
|
|
"available_tool_count": len(tools_dict),
|
|
},
|
|
)
|
|
|
|
tool_call_data = {
|
|
"tool_name": "unknown",
|
|
"call_id": call_id,
|
|
"action_name": llm_name,
|
|
"arguments": call_args,
|
|
"result": f"Tool with ID {tool_id} not found. Available tools: {list(tools_dict.keys())}",
|
|
"status": "error",
|
|
}
|
|
# Journal the unresolvable call so it still shows up in tool analytics.
|
|
if _record_proposed(
|
|
call_id,
|
|
"unknown",
|
|
llm_name or "unknown",
|
|
call_args if isinstance(call_args, dict) else {},
|
|
message_id=self.message_id,
|
|
user_id=self.user,
|
|
agent_id=self.agent_id,
|
|
):
|
|
_mark_failed(
|
|
call_id,
|
|
f"Tool with ID {tool_id} not found.",
|
|
message_id=self.message_id,
|
|
user_id=self.user,
|
|
)
|
|
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
|
|
self.tool_calls.append(tool_call_data)
|
|
return f"Tool with ID {tool_id} not found.", call_id
|
|
|
|
tool_call_data = {
|
|
"tool_name": tools_dict[tool_id]["name"],
|
|
"call_id": call_id,
|
|
"action_name": llm_name,
|
|
"arguments": call_args,
|
|
}
|
|
tool_data = tools_dict[tool_id]
|
|
# Surface the device id on remote_device tool-call events so the
|
|
# approval UI can wire up the sticky "don't ask again" button.
|
|
if tool_data.get("name") == "remote_device":
|
|
config = tool_data.get("config") or {}
|
|
if config.get("device_id"):
|
|
tool_call_data["device_id"] = config["device_id"]
|
|
# Journal first so the reconciler sees malformed calls and any
|
|
# subsequent ``_mark_failed`` actually updates a real row.
|
|
proposed_ok = _record_proposed(
|
|
call_id,
|
|
tool_data["name"],
|
|
action_name,
|
|
call_args if isinstance(call_args, dict) else {},
|
|
tool_id=tool_data.get("id"),
|
|
message_id=self.message_id,
|
|
user_id=self.user,
|
|
agent_id=self.agent_id,
|
|
)
|
|
# Defensive guard: a non-dict ``call_args`` (e.g. malformed
|
|
# JSON on the resume path) would crash the param walk below
|
|
# with AttributeError on ``.items()``. Surface a clean error
|
|
# event and flip the journal row to ``failed`` instead of
|
|
# killing the stream.
|
|
if not isinstance(call_args, dict):
|
|
error_message = f"Tool call arguments must be a JSON object, got {type(call_args).__name__}."
|
|
tool_call_data["result"] = error_message
|
|
tool_call_data["arguments"] = {}
|
|
tool_call_data["status"] = "error"
|
|
if proposed_ok:
|
|
_mark_failed(
|
|
call_id, error_message, message_id=self.message_id, user_id=self.user
|
|
)
|
|
yield {
|
|
"type": "tool_call",
|
|
"data": {**tool_call_data, "status": "error"},
|
|
}
|
|
self.tool_calls.append(tool_call_data)
|
|
return error_message, call_id
|
|
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
|
|
action_data = (
|
|
tool_data["config"]["actions"][action_name]
|
|
if tool_data["name"] == "api_tool"
|
|
else next(action for action in tool_data["actions"] if action["name"] == action_name)
|
|
)
|
|
|
|
query_params, headers, body, parameters = {}, {}, {}, {}
|
|
param_types = {
|
|
"query_params": query_params,
|
|
"headers": headers,
|
|
"body": body,
|
|
"parameters": parameters,
|
|
}
|
|
|
|
for param_type, target_dict in param_types.items():
|
|
if param_type in action_data and action_data[param_type].get("properties"):
|
|
for param, details in action_data[param_type]["properties"].items():
|
|
if param not in call_args and "value" in details and details["value"]:
|
|
target_dict[param] = details["value"]
|
|
for param, value in call_args.items():
|
|
for param_type, target_dict in param_types.items():
|
|
if param_type in action_data and param in action_data[param_type].get("properties", {}):
|
|
target_dict[param] = value
|
|
|
|
# Load tool (with caching)
|
|
tool = self._get_or_load_tool(
|
|
tool_data,
|
|
tool_id,
|
|
action_name,
|
|
headers=headers,
|
|
query_params=query_params,
|
|
)
|
|
|
|
if tool is None:
|
|
error_message = (
|
|
f"Failed to load tool '{tool_data.get('name')}' (tool_id key={tool_id}): missing 'id' on tool row."
|
|
)
|
|
logger.error(
|
|
"tool_load_failed",
|
|
extra={
|
|
"tool_name": tool_data.get("name"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
"call_id": call_id,
|
|
},
|
|
)
|
|
tool_call_data["result"] = error_message
|
|
tool_call_data["status"] = "error"
|
|
if proposed_ok:
|
|
_mark_failed(
|
|
call_id, error_message, message_id=self.message_id, user_id=self.user
|
|
)
|
|
yield {"type": "tool_call", "data": {**tool_call_data}}
|
|
self.tool_calls.append(tool_call_data)
|
|
return error_message, call_id
|
|
|
|
resolved_arguments = (
|
|
{"query_params": query_params, "headers": headers, "body": body}
|
|
if tool_data["name"] == "api_tool"
|
|
else parameters
|
|
)
|
|
try:
|
|
if tool_data["name"] == "api_tool":
|
|
logger.debug(
|
|
"Executing api: %s with query_params: %s, headers: %s, body: %s",
|
|
action_name,
|
|
_redact_args_for_log(query_params),
|
|
_redact_args_for_log(headers),
|
|
_redact_args_for_log(body),
|
|
)
|
|
result = tool.execute_action(action_name, **body)
|
|
else:
|
|
logger.debug(
|
|
"Executing tool: %s with args: %s",
|
|
action_name,
|
|
_redact_args_for_log(call_args),
|
|
)
|
|
result = tool.execute_action(action_name, **parameters)
|
|
except Exception as exc:
|
|
if proposed_ok:
|
|
_mark_failed(
|
|
call_id, str(exc), message_id=self.message_id, user_id=self.user
|
|
)
|
|
raise
|
|
|
|
get_artifact_id = getattr(tool, "get_artifact_id", None) if tool_data["name"] != "api_tool" else None
|
|
|
|
artifact_id = None
|
|
if callable(get_artifact_id):
|
|
try:
|
|
artifact_id = get_artifact_id(action_name, **parameters)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to extract artifact_id from tool %s for action %s",
|
|
tool_data["name"],
|
|
action_name,
|
|
)
|
|
|
|
artifact_id = str(artifact_id).strip() if artifact_id is not None else ""
|
|
if artifact_id:
|
|
tool_call_data["artifact_id"] = artifact_id
|
|
result_full = str(result)
|
|
tool_call_data["resolved_arguments"] = resolved_arguments
|
|
tool_call_data["result_full"] = result_full
|
|
tool_call_data["result"] = truncate_tool_result(result_full)
|
|
# A tool that ran but reported failure in-band persists as ``error``,
|
|
# not ``completed`` -- the model saw an error and will likely retry.
|
|
tool_call_data["status"] = result_status(result)
|
|
|
|
# Tool side effect has run; flip the journal row so the
|
|
# message-finalize path can later confirm it. If the proposed
|
|
# write failed (DB outage), upsert a fresh row in ``executed`` so
|
|
# the reconciler still sees the side effect.
|
|
_mark_executed(
|
|
call_id,
|
|
result_full,
|
|
message_id=self.message_id,
|
|
artifact_id=artifact_id or None,
|
|
proposed_ok=proposed_ok,
|
|
tool_name=tool_data["name"],
|
|
action_name=action_name,
|
|
arguments=call_args,
|
|
tool_id=tool_data.get("id"),
|
|
user_id=self.user,
|
|
agent_id=self.agent_id,
|
|
)
|
|
|
|
stream_tool_call_data = {
|
|
key: value for key, value in tool_call_data.items() if key not in {"result_full", "resolved_arguments"}
|
|
}
|
|
yield {"type": "tool_call", "data": {**stream_tool_call_data}}
|
|
self.tool_calls.append(tool_call_data)
|
|
|
|
return result, call_id
|
|
|
|
def _get_or_load_tool(
|
|
self,
|
|
tool_data: Dict,
|
|
tool_id: str,
|
|
action_name: str,
|
|
headers: Optional[Dict] = None,
|
|
query_params: Optional[Dict] = None,
|
|
):
|
|
"""Load a tool, using cache when possible."""
|
|
cache_key = f"{tool_data['name']}:{tool_id}:{self.user or ''}"
|
|
if cache_key in self._loaded_tools:
|
|
cached = self._loaded_tools[cache_key]
|
|
# A tool cached on an earlier turn carries that turn's attachments;
|
|
# refresh them so a chat attachment added this turn is bridgeable.
|
|
cached_config = getattr(cached, "config", None)
|
|
if isinstance(cached_config, dict) and self.conversation_id:
|
|
# Refresh unconditionally so a turn with no attachments clears the
|
|
# prior turn's list (no stale carryover within the session).
|
|
cached_config["attachments"] = self.attachments or []
|
|
return cached
|
|
|
|
tm = ToolManager(config={})
|
|
|
|
if tool_data["name"] == "api_tool":
|
|
action_config = tool_data["config"]["actions"][action_name]
|
|
tool_config = {
|
|
"url": action_config["url"],
|
|
"method": action_config["method"],
|
|
"headers": headers or {},
|
|
"query_params": query_params or {},
|
|
}
|
|
if "body_content_type" in action_config:
|
|
tool_config["body_content_type"] = action_config.get("body_content_type", "application/json")
|
|
tool_config["body_encoding_rules"] = action_config.get("body_encoding_rules", {})
|
|
else:
|
|
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
|
|
# Credentials are PBKDF2-bound to the tool OWNER's sub, not the
|
|
# invoker's. Decrypt with the tool row's user_id so a team member
|
|
# running an owner's shared tool authenticates with the owner's
|
|
# credentials (deliberate delegation — see teams-spec OQ2), and so
|
|
# the long-standing agent-key path (tools resolved by owner) stops
|
|
# silently decrypt-failing. Falls back to self.user for the
|
|
# agentless path where the tool row carries no user_id.
|
|
tool_owner = tool_data.get("user_id") or self.user
|
|
if tool_config.get("encrypted_credentials") and tool_owner:
|
|
if tool_owner != self.user:
|
|
# Credential delegation: the invoker is running a shared
|
|
# tool with the owner's secrets. Audit it (the agent-run
|
|
# authorization upstream is the access boundary).
|
|
logger.info(
|
|
"tool_credential_delegation",
|
|
extra={
|
|
"invoker": self.user,
|
|
"tool_owner": tool_owner,
|
|
"tool_id": str(tool_data.get("id") or tool_id),
|
|
"tool_name": tool_data.get("name"),
|
|
"agent_id": self.agent_id,
|
|
},
|
|
)
|
|
decrypted = decrypt_credentials(tool_config["encrypted_credentials"], tool_owner)
|
|
tool_config.update(decrypted)
|
|
tool_config["auth_credentials"] = decrypted
|
|
tool_config.pop("encrypted_credentials", None)
|
|
row_id = tool_data.get("id")
|
|
if not row_id:
|
|
logger.error(
|
|
"tool_missing_row_id",
|
|
extra={
|
|
"tool_name": tool_data.get("name"),
|
|
"tool_id": tool_id,
|
|
"action_name": action_name,
|
|
},
|
|
)
|
|
return None
|
|
tool_config["tool_id"] = str(row_id)
|
|
if self.conversation_id:
|
|
tool_config["conversation_id"] = self.conversation_id
|
|
if self.message_id:
|
|
tool_config["message_id"] = self.message_id
|
|
# Carry the request's own attachments so sandbox tools can
|
|
# lazily bridge a referenced chat attachment (conversation
|
|
# scope only; workflow nodes bridge attachments up front).
|
|
if self.attachments:
|
|
tool_config["attachments"] = self.attachments
|
|
# Workflow agent nodes run-scope their artifact tools so a short
|
|
# ref (A1) and edit_artifact resolve against the workflow run.
|
|
if self.workflow_run_id:
|
|
tool_config["workflow_run_id"] = self.workflow_run_id
|
|
if tool_data["name"] == "scheduler":
|
|
# Agent-bound: stamp schedules.agent_id. Agentless: the tool
|
|
# falls back to ``origin_conversation_id`` as the schedule's
|
|
# conversation home.
|
|
tool_config["agent_id"] = str(self.agent_id) if self.agent_id else None
|
|
if tool_data["name"] == "mcp_tool":
|
|
tool_config["query_mode"] = True
|
|
|
|
tool = tm.load_tool(
|
|
tool_data["name"],
|
|
tool_config=tool_config,
|
|
user_id=self.user,
|
|
)
|
|
|
|
# Don't cache api_tool since config varies by action
|
|
if tool_data["name"] != "api_tool":
|
|
self._loaded_tools[cache_key] = tool
|
|
|
|
return tool
|
|
|
|
def get_truncated_tool_calls(self) -> List[Dict]:
|
|
return [
|
|
{
|
|
"tool_name": tool_call.get("tool_name"),
|
|
"call_id": tool_call.get("call_id"),
|
|
"action_name": tool_call.get("action_name"),
|
|
"arguments": tool_call.get("arguments"),
|
|
"artifact_id": tool_call.get("artifact_id"),
|
|
"result": truncate_tool_result(tool_call.get("result")),
|
|
"status": tool_call.get("status", "completed"),
|
|
}
|
|
for tool_call in self.tool_calls
|
|
]
|