Files
Artemis-Eapil/artemis/tools/tool_wrapper.py
T
somew1nd 12d7902a82 fix: correct task scheduling and simplify agent internals
Fix queue cancellation, concurrency limits and worker status updates.

Use direct Explorer imports, split the Diagnoser loop into helpers and remove unused tool registration and prompt-builder code.

Correct model caching, endpoint selection and retry handling.
2026-09-02 22:58:00 -07:00

171 lines
6.3 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from collections.abc import Callable
import functools
import inspect
import time
from typing import Annotated, Any, get_args, get_origin
import uuid
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.prebuilt import InjectedState
from pydantic import BaseModel
from artemis.context import ArtemisContext
from artemis.data_engine import engine as engine_mod
from artemis.data_engine.trace import CURRENT_TRACE_ID, smart_serialize
from artemis.graph.state import State
from artemis.tools.types import CyFunctionDetector
class ToolWrapper(BaseModel):
"""Wrapper holding a tool factory and lifecycle callbacks."""
model_config = {"ignored_types": (CyFunctionDetector,)}
tool_fn_getter: Callable[[ArtemisContext], BaseTool]
on_success_fn: Callable[..., str]
on_failure_fn: Callable[..., str]
is_available_fn: Callable[[ArtemisContext], bool] | None = None
class CompositeToolWrapper(ToolWrapper):
"""Wrapper holding a composite tool factory and lifecycle callbacks."""
composite_tools_fn_getter: Callable[[ArtemisContext], list[BaseTool]]
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def invoke_tool_with_injection(
tool: BaseTool,
args: dict,
tool_call_id: str,
state: State | None = None,
record_trace: bool = True,
) -> Any:
"""Invokes a LangChain tool, manually injecting arguments marked with
InjectedState or InjectedToolCallId if calling the underlying function
directly.
Automatically records type='tool' trace in DataEngine.
"""
final_args = dict(args)
def _real_callable(candidate: Any) -> Callable | None:
"""Return a routine or partial whose signature supports direct injection."""
target = candidate.func if isinstance(candidate, functools.partial) else candidate
if inspect.isroutine(target):
return candidate
return None
# For StructuredTool, the original function might be in tool.func or tool.coroutine
func = None
if getattr(tool, "coroutine", None):
func = _real_callable(tool.coroutine)
if func is None and getattr(tool, "func", None):
func = _real_callable(tool.func)
if func:
try:
sig = inspect.signature(func)
for param_name, param in sig.parameters.items():
annotation = param.annotation
# Handle Annotated types
if get_origin(annotation) is Annotated:
args_list = get_args(annotation)
if InjectedState in args_list:
final_args[param_name] = state
elif InjectedToolCallId in args_list:
final_args[param_name] = tool_call_id
except (ValueError, TypeError):
# If inspection fails, fall back to just using provided args
pass
trace_id = uuid.uuid4()
parent_id = CURRENT_TRACE_ID.get()
token = CURRENT_TRACE_ID.set(trace_id)
start_time = time.time()
live_engine = getattr(engine_mod, "_CURRENT_DATA_ENGINE", None)
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
live_engine.record_trace(
type="tool",
name=tool.name,
payload={"args": {k: smart_serialize(v) for k, v in args.items()}},
status="running",
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
execution_status = "running"
execution_result = None
execution_error = None
try:
# Call the function
if inspect.iscoroutinefunction(func):
result = await func(**final_args)
else:
result = func(**final_args)
execution_status = "success"
execution_result = result
return result
except Exception as e:
execution_status = "failed"
execution_error = e
raise e
finally:
duration = time.time() - start_time
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
payload = {"args": {k: smart_serialize(v) for k, v in args.items()}}
if execution_status == "success":
payload["result"] = smart_serialize(get_tool_result_content(execution_result))
elif execution_status == "failed":
payload["error"] = str(execution_error)
else:
execution_status = "terminated"
payload["error"] = "Execution terminated unexpectedly."
live_engine.record_trace(
type="tool",
name=tool.name,
payload=payload,
status=execution_status,
duration=duration,
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
CURRENT_TRACE_ID.reset(token)
else:
# Fallback to ainvoke if no direct func available; tools that declare
# a "state" argument still receive the graph state.
tool_args = getattr(tool, "args", None)
if state is not None and isinstance(tool_args, dict) and "state" in tool_args:
final_args["state"] = state
return await tool.ainvoke(final_args)
def get_tool_result_content(result: Any) -> Any:
"""Extracts the content from a tool result, handling ToolMessage returns."""
if isinstance(result, ToolMessage):
return result.content
return result