mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-06-07 05:45:13 +02:00
Tool services - dynamically pluggable tool implementations for agent frameworks (#658)
* New schema * Tool service implementation * Base class * Joke service, for testing * Update unit tests for tool services
This commit is contained in:
parent
0b83c08ae4
commit
a38ca9474f
13 changed files with 1396 additions and 45 deletions
|
|
@ -122,6 +122,7 @@ triples-write-falkordb = "trustgraph.storage.triples.falkordb:run"
|
|||
triples-write-memgraph = "trustgraph.storage.triples.memgraph:run"
|
||||
triples-write-neo4j = "trustgraph.storage.triples.neo4j:run"
|
||||
wikipedia-lookup = "trustgraph.external.wikipedia:run"
|
||||
joke-service = "trustgraph.tool_service.joke:run"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["trustgraph*"]
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from ... base import RowEmbeddingsQueryClientSpec, EmbeddingsClientSpec
|
|||
|
||||
from ... schema import AgentRequest, AgentResponse, AgentStep, Error
|
||||
|
||||
from . tools import KnowledgeQueryImpl, TextCompletionImpl, McpToolImpl, PromptImpl, StructuredQueryImpl, RowEmbeddingsQueryImpl
|
||||
from . tools import KnowledgeQueryImpl, TextCompletionImpl, McpToolImpl, PromptImpl, StructuredQueryImpl, RowEmbeddingsQueryImpl, ToolServiceImpl
|
||||
from . agent_manager import AgentManager
|
||||
from ..tool_filter import validate_tool_config, filter_tools_by_group_and_state, get_next_state
|
||||
|
||||
|
|
@ -51,6 +51,9 @@ class Processor(AgentService):
|
|||
additional_context="",
|
||||
)
|
||||
|
||||
# Track active tool service clients for cleanup
|
||||
self.tool_service_clients = {}
|
||||
|
||||
self.config_handlers.append(self.on_tools_config)
|
||||
|
||||
self.register_specification(
|
||||
|
|
@ -110,6 +113,16 @@ class Processor(AgentService):
|
|||
|
||||
tools = {}
|
||||
|
||||
# Load tool-service configurations first
|
||||
tool_services = {}
|
||||
if "tool-service" in config:
|
||||
for service_id, service_value in config["tool-service"].items():
|
||||
service_data = json.loads(service_value)
|
||||
tool_services[service_id] = service_data
|
||||
logger.debug(f"Loaded tool-service config: {service_id}")
|
||||
|
||||
logger.info(f"Loaded {len(tool_services)} tool-service configurations")
|
||||
|
||||
# Load tool configurations from the new location
|
||||
if "tool" in config:
|
||||
for tool_id, tool_value in config["tool"].items():
|
||||
|
|
@ -177,6 +190,59 @@ class Processor(AgentService):
|
|||
limit=int(data.get("limit", 10)) # Max results
|
||||
)
|
||||
arguments = RowEmbeddingsQueryImpl.get_arguments()
|
||||
elif impl_id == "tool-service":
|
||||
# Dynamic tool service - look up the service config
|
||||
service_ref = data.get("service")
|
||||
if not service_ref:
|
||||
raise RuntimeError(
|
||||
f"Tool {name} has type 'tool-service' but no 'service' reference"
|
||||
)
|
||||
if service_ref not in tool_services:
|
||||
raise RuntimeError(
|
||||
f"Tool {name} references unknown tool-service '{service_ref}'"
|
||||
)
|
||||
|
||||
service_config = tool_services[service_ref]
|
||||
request_queue = service_config.get("request-queue")
|
||||
response_queue = service_config.get("response-queue")
|
||||
if not request_queue or not response_queue:
|
||||
raise RuntimeError(
|
||||
f"Tool-service '{service_ref}' must define 'request-queue' and 'response-queue'"
|
||||
)
|
||||
|
||||
# Build config values from tool config
|
||||
# Extract any config params defined by the service
|
||||
config_params = service_config.get("config-params", [])
|
||||
config_values = {}
|
||||
for param in config_params:
|
||||
param_name = param.get("name") if isinstance(param, dict) else param
|
||||
if param_name in data:
|
||||
config_values[param_name] = data[param_name]
|
||||
elif isinstance(param, dict) and param.get("required", False):
|
||||
raise RuntimeError(
|
||||
f"Tool {name} missing required config param '{param_name}'"
|
||||
)
|
||||
|
||||
# Arguments come from tool config
|
||||
config_args = data.get("arguments", [])
|
||||
arguments = [
|
||||
Argument(
|
||||
name=arg.get("name"),
|
||||
type=arg.get("type"),
|
||||
description=arg.get("description")
|
||||
)
|
||||
for arg in config_args
|
||||
]
|
||||
|
||||
# Store queues for the implementation
|
||||
impl = functools.partial(
|
||||
ToolServiceImpl,
|
||||
request_queue=request_queue,
|
||||
response_queue=response_queue,
|
||||
config_values=config_values,
|
||||
arguments=arguments,
|
||||
processor=self,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Tool type {impl_id} not known"
|
||||
|
|
|
|||
|
|
@ -202,3 +202,116 @@ class PromptImpl:
|
|||
id=self.template_id,
|
||||
variables=arguments
|
||||
)
|
||||
|
||||
|
||||
# This tool implementation invokes a dynamically configured tool service
|
||||
class ToolServiceImpl:
|
||||
"""
|
||||
Implementation for dynamically pluggable tool services.
|
||||
|
||||
Tool services are external Pulsar services that can be invoked as agent tools.
|
||||
The service is configured via a tool-service descriptor that defines the queues,
|
||||
and a tool descriptor that provides config values and argument definitions.
|
||||
"""
|
||||
|
||||
def __init__(self, context, request_queue, response_queue, config_values=None, arguments=None, processor=None):
|
||||
"""
|
||||
Initialize a tool service implementation.
|
||||
|
||||
Args:
|
||||
context: The context function (provides user info)
|
||||
request_queue: Full Pulsar topic for requests
|
||||
response_queue: Full Pulsar topic for responses
|
||||
config_values: Dict of config values (e.g., {"collection": "customers"})
|
||||
arguments: List of Argument objects defining the tool's parameters
|
||||
processor: The Processor instance (for pubsub access)
|
||||
"""
|
||||
self.context = context
|
||||
self.request_queue = request_queue
|
||||
self.response_queue = response_queue
|
||||
self.config_values = config_values or {}
|
||||
self.arguments = arguments or []
|
||||
self.processor = processor
|
||||
self._client = None
|
||||
|
||||
def get_arguments(self):
|
||||
return self.arguments
|
||||
|
||||
async def _get_or_create_client(self):
|
||||
"""Get or create the tool service client."""
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
|
||||
# Check if processor already has a client for this queue pair
|
||||
client_key = f"{self.request_queue}|{self.response_queue}"
|
||||
if client_key in self.processor.tool_service_clients:
|
||||
self._client = self.processor.tool_service_clients[client_key]
|
||||
return self._client
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from trustgraph.base.tool_service_client import ToolServiceClient
|
||||
from trustgraph.base.metrics import ProducerMetrics, SubscriberMetrics
|
||||
from trustgraph.schema import ToolServiceRequest, ToolServiceResponse
|
||||
import uuid
|
||||
|
||||
request_metrics = ProducerMetrics(
|
||||
processor=self.processor.id,
|
||||
flow="tool-service",
|
||||
name=self.request_queue
|
||||
)
|
||||
response_metrics = SubscriberMetrics(
|
||||
processor=self.processor.id,
|
||||
flow="tool-service",
|
||||
name=self.response_queue
|
||||
)
|
||||
|
||||
# Create unique subscription for responses
|
||||
subscription = f"{self.processor.id}--tool-service--{uuid.uuid4()}"
|
||||
|
||||
self._client = ToolServiceClient(
|
||||
backend=self.processor.pubsub,
|
||||
subscription=subscription,
|
||||
consumer_name=self.processor.id,
|
||||
request_topic=self.request_queue,
|
||||
request_schema=ToolServiceRequest,
|
||||
request_metrics=request_metrics,
|
||||
response_topic=self.response_queue,
|
||||
response_schema=ToolServiceResponse,
|
||||
response_metrics=response_metrics,
|
||||
)
|
||||
|
||||
# Start the client
|
||||
await self._client.start()
|
||||
|
||||
# Register for cleanup
|
||||
self.processor.tool_service_clients[client_key] = self._client
|
||||
|
||||
logger.debug(f"Created tool service client for {self.request_queue}")
|
||||
return self._client
|
||||
|
||||
async def invoke(self, **arguments):
|
||||
logger.debug(f"Tool service invocation: {self.request_queue}...")
|
||||
logger.debug(f"Config: {self.config_values}")
|
||||
logger.debug(f"Arguments: {arguments}")
|
||||
|
||||
# Get user from context if available
|
||||
user = "trustgraph"
|
||||
if hasattr(self.context, '_user'):
|
||||
user = self.context._user
|
||||
|
||||
# Get or create the client
|
||||
client = await self._get_or_create_client()
|
||||
|
||||
# Call the tool service
|
||||
response = await client.call(
|
||||
user=user,
|
||||
config=self.config_values,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
logger.debug(f"Tool service response: {response}")
|
||||
|
||||
if isinstance(response, str):
|
||||
return response
|
||||
else:
|
||||
return json.dumps(response)
|
||||
|
|
|
|||
1
trustgraph-flow/trustgraph/tool_service/__init__.py
Normal file
1
trustgraph-flow/trustgraph/tool_service/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Tool service implementations
|
||||
2
trustgraph-flow/trustgraph/tool_service/joke/__init__.py
Normal file
2
trustgraph-flow/trustgraph/tool_service/joke/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Joke tool service
|
||||
from .service import run
|
||||
204
trustgraph-flow/trustgraph/tool_service/joke/service.py
Normal file
204
trustgraph-flow/trustgraph/tool_service/joke/service.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""
|
||||
Joke Tool Service - An example dynamic tool service.
|
||||
|
||||
This service demonstrates the tool service integration by:
|
||||
- Using the 'user' field to personalize responses
|
||||
- Using config params (style) to customize joke style
|
||||
- Using arguments (topic) to generate topic-specific jokes
|
||||
|
||||
Example tool-service config:
|
||||
{
|
||||
"id": "joke-service",
|
||||
"topic": "joke",
|
||||
"config-params": [
|
||||
{"name": "style", "required": false}
|
||||
]
|
||||
}
|
||||
|
||||
Example tool config:
|
||||
{
|
||||
"type": "tool-service",
|
||||
"name": "tell-joke",
|
||||
"description": "Tell a joke on a given topic",
|
||||
"service": "joke-service",
|
||||
"style": "pun",
|
||||
"arguments": [
|
||||
{
|
||||
"name": "topic",
|
||||
"type": "string",
|
||||
"description": "The topic for the joke (e.g., programming, animals, food)"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
import random
|
||||
import logging
|
||||
|
||||
from ... base import DynamicToolService
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
default_ident = "joke-service"
|
||||
default_topic = "joke"
|
||||
|
||||
# Joke database organized by topic and style
|
||||
JOKES = {
|
||||
"programming": {
|
||||
"pun": [
|
||||
"Why do programmers prefer dark mode? Because light attracts bugs!",
|
||||
"Why do Java developers wear glasses? Because they can't C#!",
|
||||
"A SQL query walks into a bar, walks up to two tables and asks... 'Can I join you?'",
|
||||
"Why was the JavaScript developer sad? Because he didn't Node how to Express himself!",
|
||||
],
|
||||
"dad-joke": [
|
||||
"I told my computer I needed a break, and now it won't stop sending me Kit-Kat ads.",
|
||||
"My son asked me to explain what a linked list is. I said 'I'll tell you, and then I'll tell you again, and again...'",
|
||||
"I asked my computer for a joke about UDP. I'm not sure if it got it.",
|
||||
],
|
||||
"one-liner": [
|
||||
"There are only 10 types of people: those who understand binary and those who don't.",
|
||||
"A programmer's wife tells him: 'Go to the store and get a loaf of bread. If they have eggs, get a dozen.' He returns with 12 loaves.",
|
||||
"99 little bugs in the code, 99 little bugs. Take one down, patch it around, 127 little bugs in the code.",
|
||||
],
|
||||
},
|
||||
"llama": {
|
||||
"pun": [
|
||||
"Why did the llama get a ticket? Because he was caught spitting in a no-spitting zone!",
|
||||
"What do you call a llama who's a great musician? A llama del Rey!",
|
||||
"Why did the llama cross the road? To prove he wasn't a chicken!",
|
||||
],
|
||||
"dad-joke": [
|
||||
"What did the llama say when he got kicked out of the zoo? 'Alpaca my bags!'",
|
||||
"Why don't llamas ever get lost? Because they always know the way to the Andes!",
|
||||
"What do you call a llama with no legs? A woolly rug!",
|
||||
],
|
||||
"one-liner": [
|
||||
"Llamas are great at meditation. They're always saying 'Dalai Llama.'",
|
||||
"I asked a llama for directions. He said 'No probllama!'",
|
||||
"Never trust a llama. They're always up to something woolly.",
|
||||
],
|
||||
},
|
||||
"animals": {
|
||||
"pun": [
|
||||
"What do you call a fish without eyes? A fsh!",
|
||||
"Why don't scientists trust atoms? Because they make up everything... just like that cat who blamed the dog!",
|
||||
"What do you call a bear with no teeth? A gummy bear!",
|
||||
],
|
||||
"dad-joke": [
|
||||
"I tried to catch some fog earlier. I mist. My dog wasn't impressed either.",
|
||||
"What do you call a dog that does magic tricks? A Labracadabrador!",
|
||||
"Why do cows wear bells? Because their horns don't work!",
|
||||
],
|
||||
"one-liner": [
|
||||
"I'm reading a book about anti-gravity. It's impossible to put down, unlike my cat.",
|
||||
"A horse walks into a bar. The bartender asks 'Why the long face?'",
|
||||
"What's orange and sounds like a parrot? A carrot!",
|
||||
],
|
||||
},
|
||||
"food": {
|
||||
"pun": [
|
||||
"I'm on a seafood diet. I see food and I eat it!",
|
||||
"Why did the tomato turn red? Because it saw the salad dressing!",
|
||||
"What do you call cheese that isn't yours? Nacho cheese!",
|
||||
],
|
||||
"dad-joke": [
|
||||
"I used to hate facial hair, but then it grew on me. Speaking of growing, have you tried my garden salad?",
|
||||
"Why don't eggs tell jokes? They'd crack each other up!",
|
||||
"I told my wife she was drawing her eyebrows too high. She looked surprised, then made me a sandwich.",
|
||||
],
|
||||
"one-liner": [
|
||||
"I'm reading a book about submarines and sandwiches. It's a sub-genre.",
|
||||
"Broken puppets for sale. No strings attached. Also, free spaghetti!",
|
||||
"I ordered a chicken and an egg online. I'll let you know which comes first.",
|
||||
],
|
||||
},
|
||||
"default": {
|
||||
"pun": [
|
||||
"Time flies like an arrow. Fruit flies like a banana!",
|
||||
"I used to be a banker, but I lost interest.",
|
||||
"I'm reading a book on the history of glue. I can't put it down!",
|
||||
],
|
||||
"dad-joke": [
|
||||
"I'm afraid for the calendar. Its days are numbered.",
|
||||
"I only know 25 letters of the alphabet. I don't know y.",
|
||||
"Did you hear about the claustrophobic astronaut? He just needed a little space.",
|
||||
],
|
||||
"one-liner": [
|
||||
"I told my wife she was drawing her eyebrows too high. She looked surprised.",
|
||||
"I'm not lazy, I'm on energy-saving mode.",
|
||||
"Parallel lines have so much in common. It's a shame they'll never meet.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Processor(DynamicToolService):
|
||||
"""
|
||||
Joke tool service that demonstrates the tool service integration.
|
||||
"""
|
||||
|
||||
def __init__(self, **params):
|
||||
super(Processor, self).__init__(**params)
|
||||
logger.info("Joke service initialized")
|
||||
|
||||
async def invoke(self, user, config, arguments):
|
||||
"""
|
||||
Generate a joke based on the topic and style.
|
||||
|
||||
Args:
|
||||
user: The user requesting the joke
|
||||
config: Config values including 'style' (pun, dad-joke, one-liner)
|
||||
arguments: Arguments including 'topic' (programming, animals, food)
|
||||
|
||||
Returns:
|
||||
A personalized joke string
|
||||
"""
|
||||
# Get style from config (default: random)
|
||||
style = config.get("style", random.choice(["pun", "dad-joke", "one-liner"]))
|
||||
|
||||
# Get topic from arguments (default: random)
|
||||
topic = arguments.get("topic", "").lower()
|
||||
|
||||
# Map topic to our categories
|
||||
if "program" in topic or "code" in topic or "computer" in topic or "software" in topic:
|
||||
category = "programming"
|
||||
elif "llama" in topic:
|
||||
category = "llama"
|
||||
elif "animal" in topic or "dog" in topic or "cat" in topic or "bird" in topic:
|
||||
category = "animals"
|
||||
elif "food" in topic or "eat" in topic or "cook" in topic or "drink" in topic:
|
||||
category = "food"
|
||||
else:
|
||||
category = "default"
|
||||
|
||||
# Normalize style
|
||||
if style not in ["pun", "dad-joke", "one-liner"]:
|
||||
style = random.choice(["pun", "dad-joke", "one-liner"])
|
||||
|
||||
# Get jokes for this category and style
|
||||
jokes = JOKES.get(category, JOKES["default"]).get(style, JOKES["default"]["pun"])
|
||||
|
||||
# Pick a random joke
|
||||
joke = random.choice(jokes)
|
||||
|
||||
# Personalize the response
|
||||
response = f"Hey {user}! Here's a {style} for you:\n\n{joke}"
|
||||
|
||||
logger.debug(f"Generated joke for user={user}, style={style}, topic={topic}")
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def add_args(parser):
|
||||
DynamicToolService.add_args(parser)
|
||||
# Override the topic default for this service
|
||||
for action in parser._actions:
|
||||
if '--topic' in action.option_strings:
|
||||
action.default = default_topic
|
||||
break
|
||||
|
||||
|
||||
def run():
|
||||
Processor.launch(default_ident, __doc__)
|
||||
Loading…
Add table
Add a link
Reference in a new issue