language-operator 0.1.56 → 0.1.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/lib/language_operator/agent/executor.rb +11 -0
- data/lib/language_operator/agent/task_executor.rb +24 -4
- data/lib/language_operator/cli/commands/agent.rb +3 -1
- data/lib/language_operator/cli/commands/tool.rb +183 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +3 -3
- data/lib/language_operator/client/mcp_connector.rb +31 -0
- data/lib/language_operator/dsl/adapter.rb +2 -2
- data/lib/language_operator/instrumentation/task_tracer.rb +64 -2
- data/lib/language_operator/kubernetes/resource_builder.rb +3 -1
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/tool_loader.rb +8 -3
- data/lib/language_operator/version.rb +1 -1
- data/synth/003/Makefile +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afc74193530a3f556debc6b31b0a43769da67eaf9c956d0a7b4f17477792bf94
|
|
4
|
+
data.tar.gz: ffc401b931b0b3bd427c92a36847986442b738f2508acd1206547aa4d14c92eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5079fbb3621d2e3d0f00a9381bc7b6fa09c3e28ae029d3e6cde4b7e0ac42643c7881953dd66549f0379414c0a74053dfed301e832812d82902d0de553f791158
|
|
7
|
+
data.tar.gz: f24aea2d8cb473e7e9633fe713e663f637391c258695eacdd091b94dc12b206606408162f1d3cf3ae60484aa8097177bd4a858d392b132e07f6aaafadf63f35a
|
data/Gemfile.lock
CHANGED
|
@@ -107,6 +107,17 @@ module LanguageOperator
|
|
|
107
107
|
)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Capture thinking blocks before stripping (for observability)
|
|
111
|
+
thinking_blocks = result_text.scan(%r{\[THINK\](.*?)\[/THINK\]}m).flatten
|
|
112
|
+
if thinking_blocks.any?
|
|
113
|
+
logger.info('LLM thinking captured',
|
|
114
|
+
event: 'llm_thinking',
|
|
115
|
+
iteration: @iteration_count,
|
|
116
|
+
thinking_steps: thinking_blocks.length,
|
|
117
|
+
thinking: thinking_blocks,
|
|
118
|
+
thinking_preview: thinking_blocks.first&.[](0..500))
|
|
119
|
+
end
|
|
120
|
+
|
|
110
121
|
# Log the actual LLM response content (strip [THINK] blocks)
|
|
111
122
|
cleaned_response = result_text.gsub(%r{\[THINK\].*?\[/THINK\]}m, '').strip
|
|
112
123
|
response_preview = cleaned_response.length > 500 ? "#{cleaned_response[0..500]}..." : cleaned_response
|
|
@@ -155,15 +155,24 @@ module LanguageOperator
|
|
|
155
155
|
|
|
156
156
|
logger.info('Sending prompt to LLM',
|
|
157
157
|
task: task.name,
|
|
158
|
-
prompt_length: prompt.length
|
|
158
|
+
prompt_length: prompt.length,
|
|
159
|
+
available_tools: @agent.respond_to?(:tools) ? @agent.tools.map(&:name) : 'N/A')
|
|
159
160
|
|
|
160
161
|
# Execute LLM call within traced span
|
|
161
162
|
outputs = tracer.in_span('gen_ai.chat', attributes: neural_task_attributes(task, prompt, validated_inputs)) do |span|
|
|
162
163
|
# Call LLM with full tool access
|
|
164
|
+
logger.debug('Calling LLM with prompt', task: task.name, prompt_preview: prompt[0..200])
|
|
163
165
|
response = @agent.send_message(prompt)
|
|
164
166
|
|
|
167
|
+
# Check for tool calls and log details
|
|
168
|
+
has_tool_calls = response.respond_to?(:tool_calls) && response.tool_calls&.any?
|
|
169
|
+
tool_call_count = has_tool_calls ? response.tool_calls.length : 0
|
|
170
|
+
|
|
165
171
|
logger.info('LLM response received, extracting content',
|
|
166
|
-
task: task.name
|
|
172
|
+
task: task.name,
|
|
173
|
+
response_class: response.class.name,
|
|
174
|
+
has_tool_calls: has_tool_calls,
|
|
175
|
+
tool_call_count: tool_call_count)
|
|
167
176
|
|
|
168
177
|
response_text = response.is_a?(String) ? response : response.content
|
|
169
178
|
|
|
@@ -322,6 +331,17 @@ module LanguageOperator
|
|
|
322
331
|
# @return [Hash] Parsed outputs
|
|
323
332
|
# @raise [RuntimeError] If parsing fails
|
|
324
333
|
def parse_neural_response(response_text, task)
|
|
334
|
+
# Capture thinking blocks before stripping (for observability)
|
|
335
|
+
thinking_blocks = response_text.scan(%r{\[THINK\](.*?)\[/THINK\]}m).flatten
|
|
336
|
+
if thinking_blocks.any?
|
|
337
|
+
logger.info('LLM thinking captured',
|
|
338
|
+
event: 'llm_thinking',
|
|
339
|
+
task: task.name,
|
|
340
|
+
thinking_steps: thinking_blocks.length,
|
|
341
|
+
thinking: thinking_blocks,
|
|
342
|
+
thinking_preview: thinking_blocks.first&.[](0..500))
|
|
343
|
+
end
|
|
344
|
+
|
|
325
345
|
# Strip thinking tags that some models add (e.g., [THINK]...[/THINK])
|
|
326
346
|
cleaned_text = response_text.gsub(%r{\[THINK\].*?\[/THINK\]}m, '').strip
|
|
327
347
|
|
|
@@ -367,8 +387,8 @@ module LanguageOperator
|
|
|
367
387
|
def default_config
|
|
368
388
|
{
|
|
369
389
|
timeout_symbolic: 30.0, # Default timeout for symbolic tasks (seconds)
|
|
370
|
-
timeout_neural:
|
|
371
|
-
timeout_hybrid:
|
|
390
|
+
timeout_neural: 360.0, # Default timeout for neural tasks (seconds)
|
|
391
|
+
timeout_hybrid: 360.0, # Default timeout for hybrid tasks (seconds)
|
|
372
392
|
max_retries: 3, # Default max retry attempts
|
|
373
393
|
retry_delay_base: 1.0, # Base delay for exponential backoff
|
|
374
394
|
retry_delay_max: 10.0 # Maximum delay between retries
|
|
@@ -44,6 +44,7 @@ module LanguageOperator
|
|
|
44
44
|
option :persona, type: :string, desc: 'Persona to use for the agent'
|
|
45
45
|
option :tools, type: :array, desc: 'Tools to make available to the agent'
|
|
46
46
|
option :models, type: :array, desc: 'Models to make available to the agent'
|
|
47
|
+
option :workspace, type: :boolean, default: true, desc: 'Enable workspace for state persistence'
|
|
47
48
|
option :dry_run, type: :boolean, default: false, desc: 'Preview what would be created without applying'
|
|
48
49
|
option :wizard, type: :boolean, default: false, desc: 'Use interactive wizard mode'
|
|
49
50
|
def create(description = nil)
|
|
@@ -98,7 +99,8 @@ module LanguageOperator
|
|
|
98
99
|
cluster: ctx.namespace,
|
|
99
100
|
persona: options[:persona],
|
|
100
101
|
tools: options[:tools] || [],
|
|
101
|
-
models: models
|
|
102
|
+
models: models,
|
|
103
|
+
workspace: options[:workspace]
|
|
102
104
|
)
|
|
103
105
|
|
|
104
106
|
# Dry-run mode: preview without applying
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'erb'
|
|
5
5
|
require 'net/http'
|
|
6
|
+
require 'json'
|
|
6
7
|
require_relative '../base_command'
|
|
7
8
|
require_relative '../formatters/progress_formatter'
|
|
8
9
|
require_relative '../formatters/table_formatter'
|
|
@@ -66,6 +67,140 @@ module LanguageOperator
|
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
70
|
+
desc 'inspect NAME', 'Show detailed tool information'
|
|
71
|
+
option :cluster, type: :string, desc: 'Override current cluster context'
|
|
72
|
+
def inspect(name)
|
|
73
|
+
handle_command_error('inspect tool') do
|
|
74
|
+
tool = get_resource_or_exit('LanguageTool', name)
|
|
75
|
+
|
|
76
|
+
puts "Tool: #{name}"
|
|
77
|
+
puts " Cluster: #{ctx.name}"
|
|
78
|
+
puts " Namespace: #{ctx.namespace}"
|
|
79
|
+
puts
|
|
80
|
+
|
|
81
|
+
# Status
|
|
82
|
+
status = tool.dig('status', 'phase') || 'Unknown'
|
|
83
|
+
puts "Status: #{status}"
|
|
84
|
+
puts
|
|
85
|
+
|
|
86
|
+
# Spec details
|
|
87
|
+
puts 'Configuration:'
|
|
88
|
+
puts " Type: #{tool.dig('spec', 'type') || 'mcp'}"
|
|
89
|
+
puts " Image: #{tool.dig('spec', 'image')}"
|
|
90
|
+
puts " Deployment Mode: #{tool.dig('spec', 'deploymentMode') || 'sidecar'}"
|
|
91
|
+
puts " Port: #{tool.dig('spec', 'port') || 8080}"
|
|
92
|
+
puts " Replicas: #{tool.dig('spec', 'replicas') || 1}"
|
|
93
|
+
puts
|
|
94
|
+
|
|
95
|
+
# Resources
|
|
96
|
+
resources = tool.dig('spec', 'resources')
|
|
97
|
+
if resources
|
|
98
|
+
puts 'Resources:'
|
|
99
|
+
if resources['requests']
|
|
100
|
+
puts ' Requests:'
|
|
101
|
+
puts " CPU: #{resources['requests']['cpu']}"
|
|
102
|
+
puts " Memory: #{resources['requests']['memory']}"
|
|
103
|
+
end
|
|
104
|
+
if resources['limits']
|
|
105
|
+
puts ' Limits:'
|
|
106
|
+
puts " CPU: #{resources['limits']['cpu']}"
|
|
107
|
+
puts " Memory: #{resources['limits']['memory']}"
|
|
108
|
+
end
|
|
109
|
+
puts
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# RBAC
|
|
113
|
+
rbac = tool.dig('spec', 'rbac')
|
|
114
|
+
if rbac && rbac['clusterRole']
|
|
115
|
+
rules = rbac.dig('clusterRole', 'rules') || []
|
|
116
|
+
puts "RBAC Permissions (#{rules.length} rules):"
|
|
117
|
+
rules.each_with_index do |rule, idx|
|
|
118
|
+
puts " Rule #{idx + 1}:"
|
|
119
|
+
puts " API Groups: #{rule['apiGroups'].join(', ')}"
|
|
120
|
+
puts " Resources: #{rule['resources'].join(', ')}"
|
|
121
|
+
puts " Verbs: #{rule['verbs'].join(', ')}"
|
|
122
|
+
end
|
|
123
|
+
puts
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Egress rules
|
|
127
|
+
egress = tool.dig('spec', 'egress') || []
|
|
128
|
+
if egress.any?
|
|
129
|
+
puts "Network Egress (#{egress.length} rules):"
|
|
130
|
+
egress.each_with_index do |rule, idx|
|
|
131
|
+
puts " Rule #{idx + 1}: #{rule['description']}"
|
|
132
|
+
puts " DNS: #{rule['dns'].join(', ')}" if rule['dns']
|
|
133
|
+
puts " CIDR: #{rule['cidr']}" if rule['cidr']
|
|
134
|
+
if rule['ports']
|
|
135
|
+
ports_str = rule['ports'].map { |p| "#{p['port']}/#{p['protocol']}" }.join(', ')
|
|
136
|
+
puts " Ports: #{ports_str}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
puts
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Try to fetch MCP capabilities
|
|
143
|
+
capabilities = fetch_mcp_capabilities(name, tool, ctx.namespace)
|
|
144
|
+
if capabilities && capabilities['tools'] && capabilities['tools'].any?
|
|
145
|
+
puts "MCP Tools (#{capabilities['tools'].length}):"
|
|
146
|
+
capabilities['tools'].each_with_index do |mcp_tool, idx|
|
|
147
|
+
tool_name = mcp_tool['name']
|
|
148
|
+
|
|
149
|
+
# Generate a meaningful name if empty
|
|
150
|
+
if tool_name.nil? || tool_name.empty?
|
|
151
|
+
# Try to derive from description (first few words)
|
|
152
|
+
if mcp_tool['description']
|
|
153
|
+
# Take first 3-4 words and convert to snake_case
|
|
154
|
+
words = mcp_tool['description'].split(/\s+/).first(4)
|
|
155
|
+
derived_name = words.join('_').downcase.gsub(/[^a-z0-9_]/, '')
|
|
156
|
+
tool_name = "#{name}_#{derived_name}".gsub(/__+/, '_').sub(/_$/, '')
|
|
157
|
+
else
|
|
158
|
+
tool_name = "#{name}_tool_#{idx + 1}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
puts " #{tool_name}"
|
|
163
|
+
puts " Description: #{mcp_tool['description']}" if mcp_tool['description']
|
|
164
|
+
next unless mcp_tool['inputSchema'] && mcp_tool['inputSchema']['properties']
|
|
165
|
+
|
|
166
|
+
params = mcp_tool['inputSchema']['properties'].keys
|
|
167
|
+
required = mcp_tool['inputSchema']['required'] || []
|
|
168
|
+
param_list = params.map { |p| required.include?(p) ? "#{p}*" : p }
|
|
169
|
+
puts " Parameters: #{param_list.join(', ')}"
|
|
170
|
+
end
|
|
171
|
+
puts ' (* = required)'
|
|
172
|
+
puts
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Get agents using this tool
|
|
176
|
+
agents = ctx.client.list_resources('LanguageAgent', namespace: ctx.namespace)
|
|
177
|
+
agents_using = agents.select do |agent|
|
|
178
|
+
tools = agent.dig('spec', 'tools') || []
|
|
179
|
+
tools.include?(name)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if agents_using.any?
|
|
183
|
+
puts "Agents using this tool (#{agents_using.count}):"
|
|
184
|
+
agents_using.each do |agent|
|
|
185
|
+
puts " - #{agent.dig('metadata', 'name')}"
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
puts 'No agents using this tool'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
puts
|
|
192
|
+
puts 'Labels:'
|
|
193
|
+
labels = tool.dig('metadata', 'labels') || {}
|
|
194
|
+
if labels.empty?
|
|
195
|
+
puts ' (none)'
|
|
196
|
+
else
|
|
197
|
+
labels.each do |key, value|
|
|
198
|
+
puts " #{key}: #{value}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
69
204
|
desc 'delete NAME', 'Delete a tool'
|
|
70
205
|
option :cluster, type: :string, desc: 'Override current cluster context'
|
|
71
206
|
option :force, type: :boolean, default: false, desc: 'Skip confirmation'
|
|
@@ -465,6 +600,54 @@ module LanguageOperator
|
|
|
465
600
|
end
|
|
466
601
|
end
|
|
467
602
|
end
|
|
603
|
+
|
|
604
|
+
private
|
|
605
|
+
|
|
606
|
+
# Fetch MCP capabilities from a running tool server
|
|
607
|
+
#
|
|
608
|
+
# @param name [String] Tool name
|
|
609
|
+
# @param tool [Hash] Tool resource
|
|
610
|
+
# @param namespace [String] Kubernetes namespace
|
|
611
|
+
# @return [Hash, nil] MCP capabilities or nil if unavailable
|
|
612
|
+
def fetch_mcp_capabilities(name, tool, namespace)
|
|
613
|
+
return nil unless tool.dig('status', 'phase') == 'Running'
|
|
614
|
+
|
|
615
|
+
# Get the service endpoint
|
|
616
|
+
port = tool.dig('spec', 'port') || 80
|
|
617
|
+
|
|
618
|
+
# Try to query the MCP server using kubectl port-forward
|
|
619
|
+
# This is a fallback approach since we can't directly connect from CLI
|
|
620
|
+
begin
|
|
621
|
+
# Try to find a pod for this tool
|
|
622
|
+
label_selector = "app.kubernetes.io/name=#{name}"
|
|
623
|
+
pods = ctx.client.list_resources('Pod', namespace: namespace, label_selector: label_selector)
|
|
624
|
+
|
|
625
|
+
return nil if pods.empty?
|
|
626
|
+
|
|
627
|
+
pod_name = pods.first.dig('metadata', 'name')
|
|
628
|
+
|
|
629
|
+
# Query the MCP server using JSON-RPC protocol
|
|
630
|
+
# MCP uses the tools/list method to list available tools
|
|
631
|
+
json_rpc_request = {
|
|
632
|
+
jsonrpc: '2.0',
|
|
633
|
+
id: 1,
|
|
634
|
+
method: 'tools/list',
|
|
635
|
+
params: {}
|
|
636
|
+
}.to_json
|
|
637
|
+
|
|
638
|
+
result = `kubectl exec -n #{namespace} #{pod_name} -- curl -s -X POST \
|
|
639
|
+
http://localhost:#{port}/mcp/tools/list -H "Content-Type: application/json" \
|
|
640
|
+
-d '#{json_rpc_request}' 2>/dev/null`
|
|
641
|
+
|
|
642
|
+
return nil if result.empty?
|
|
643
|
+
|
|
644
|
+
response = JSON.parse(result)
|
|
645
|
+
response['result']
|
|
646
|
+
rescue StandardError
|
|
647
|
+
# Silently fail - capabilities are optional information
|
|
648
|
+
nil
|
|
649
|
+
end
|
|
650
|
+
end
|
|
468
651
|
end
|
|
469
652
|
end
|
|
470
653
|
end
|
|
@@ -51,15 +51,15 @@ spec:
|
|
|
51
51
|
<% rbac['clusterRole']['rules'].each do |rule| -%>
|
|
52
52
|
- apiGroups:
|
|
53
53
|
<% rule['apiGroups'].each do |group| -%>
|
|
54
|
-
- <%= group %>
|
|
54
|
+
- "<%= group %>"
|
|
55
55
|
<% end -%>
|
|
56
56
|
resources:
|
|
57
57
|
<% rule['resources'].each do |resource| -%>
|
|
58
|
-
- <%= resource %>
|
|
58
|
+
- "<%= resource %>"
|
|
59
59
|
<% end -%>
|
|
60
60
|
verbs:
|
|
61
61
|
<% rule['verbs'].each do |verb| -%>
|
|
62
|
-
- <%= verb %>
|
|
62
|
+
- "<%= verb %>"
|
|
63
63
|
<% end -%>
|
|
64
64
|
<% end -%>
|
|
65
65
|
<% end -%>
|
|
@@ -27,6 +27,13 @@ module LanguageOperator
|
|
|
27
27
|
tool_count = client.tools.length
|
|
28
28
|
all_tools.concat(client.tools)
|
|
29
29
|
|
|
30
|
+
# Debug: inspect tool objects
|
|
31
|
+
if @debug
|
|
32
|
+
logger.debug('MCP tool objects inspection',
|
|
33
|
+
server: server_config['name'],
|
|
34
|
+
tools_inspect: client.tools.map { |t| { class: t.class.name, name: t.name, methods: t.methods.grep(/name/) } })
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
logger.info('MCP server connected',
|
|
31
38
|
server: server_config['name'],
|
|
32
39
|
tool_count: tool_count,
|
|
@@ -54,6 +61,9 @@ module LanguageOperator
|
|
|
54
61
|
|
|
55
62
|
@chat.with_tools(*all_tools) unless all_tools.empty?
|
|
56
63
|
|
|
64
|
+
# Set up callbacks to log tool invocations
|
|
65
|
+
setup_tool_callbacks
|
|
66
|
+
|
|
57
67
|
logger.info('Chat session initialized', with_tools: !all_tools.empty?)
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -118,6 +128,27 @@ module LanguageOperator
|
|
|
118
128
|
|
|
119
129
|
chat_params
|
|
120
130
|
end
|
|
131
|
+
|
|
132
|
+
# Set up callbacks to log tool calls and results
|
|
133
|
+
#
|
|
134
|
+
# @return [void]
|
|
135
|
+
def setup_tool_callbacks
|
|
136
|
+
@chat.on_tool_call do |tool_call|
|
|
137
|
+
logger.info('Tool call initiated by LLM',
|
|
138
|
+
event: 'tool_call_initiated',
|
|
139
|
+
tool_name: tool_call.name,
|
|
140
|
+
tool_id: tool_call.id,
|
|
141
|
+
arguments: tool_call.arguments,
|
|
142
|
+
arguments_json: tool_call.arguments.to_json)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@chat.on_tool_result do |result|
|
|
146
|
+
logger.info('Tool call result received',
|
|
147
|
+
event: 'tool_result_received',
|
|
148
|
+
result: result,
|
|
149
|
+
result_preview: result.to_s[0..500])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
121
152
|
end
|
|
122
153
|
end
|
|
123
154
|
end
|
|
@@ -20,8 +20,8 @@ module LanguageOperator
|
|
|
20
20
|
|
|
21
21
|
# Create a dynamic class that extends MCP::Tool
|
|
22
22
|
Class.new(MCP::Tool) do
|
|
23
|
-
# Set the tool name
|
|
24
|
-
|
|
23
|
+
# Set the tool name using the MCP SDK's method
|
|
24
|
+
tool_name tool_def.name
|
|
25
25
|
|
|
26
26
|
# Set the description
|
|
27
27
|
description tool_def.description
|
|
@@ -26,6 +26,7 @@ module LanguageOperator
|
|
|
26
26
|
# Instrumentation adds <5% overhead with default settings
|
|
27
27
|
# Overhead may increase to ~10% with full data capture enabled
|
|
28
28
|
#
|
|
29
|
+
# rubocop:disable Metrics/ModuleLength
|
|
29
30
|
module TaskTracer
|
|
30
31
|
# Maximum length for captured data before truncation
|
|
31
32
|
MAX_CAPTURED_LENGTH = 1000
|
|
@@ -169,6 +170,11 @@ module LanguageOperator
|
|
|
169
170
|
return unless response.respond_to?(:tool_calls)
|
|
170
171
|
return unless response.tool_calls&.any?
|
|
171
172
|
|
|
173
|
+
logger&.info('Tool calls detected in LLM response',
|
|
174
|
+
event: 'tool_calls_detected',
|
|
175
|
+
tool_call_count: response.tool_calls.length,
|
|
176
|
+
tool_names: response.tool_calls.map { |tc| extract_tool_name(tc) })
|
|
177
|
+
|
|
172
178
|
response.tool_calls.each do |tool_call|
|
|
173
179
|
record_single_tool_call(tool_call, parent_span)
|
|
174
180
|
end
|
|
@@ -182,11 +188,26 @@ module LanguageOperator
|
|
|
182
188
|
# @param parent_span [OpenTelemetry::Trace::Span] Parent span
|
|
183
189
|
def record_single_tool_call(tool_call, _parent_span)
|
|
184
190
|
tool_name = extract_tool_name(tool_call)
|
|
191
|
+
tool_id = tool_call.respond_to?(:id) ? tool_call.id : nil
|
|
192
|
+
|
|
193
|
+
# Extract and log tool arguments
|
|
194
|
+
arguments = extract_tool_arguments(tool_call)
|
|
185
195
|
|
|
196
|
+
logger&.info('Tool invoked by LLM',
|
|
197
|
+
event: 'tool_call_invoked',
|
|
198
|
+
tool_name: tool_name,
|
|
199
|
+
tool_id: tool_id,
|
|
200
|
+
arguments: arguments,
|
|
201
|
+
arguments_json: (arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments.to_s))
|
|
202
|
+
|
|
203
|
+
start_time = Time.now
|
|
186
204
|
tracer.in_span("execute_tool #{tool_name}", attributes: build_tool_call_attributes(tool_call)) do |tool_span|
|
|
187
205
|
# Tool execution already completed by ruby_llm
|
|
188
206
|
# Just record the metadata
|
|
189
|
-
|
|
207
|
+
if tool_call.respond_to?(:result) && tool_call.result
|
|
208
|
+
duration_ms = ((Time.now - start_time) * 1000).round(2)
|
|
209
|
+
record_tool_result(tool_call.result, tool_span, tool_name, tool_id, duration_ms)
|
|
210
|
+
end
|
|
190
211
|
end
|
|
191
212
|
rescue StandardError => e
|
|
192
213
|
logger&.warn('Failed to record tool call span', error: e.message, tool: tool_name)
|
|
@@ -206,6 +227,34 @@ module LanguageOperator
|
|
|
206
227
|
end
|
|
207
228
|
end
|
|
208
229
|
|
|
230
|
+
# Extract tool arguments from tool call object
|
|
231
|
+
#
|
|
232
|
+
# @param tool_call [Object] Tool call object
|
|
233
|
+
# @return [Hash, String] Tool arguments
|
|
234
|
+
def extract_tool_arguments(tool_call)
|
|
235
|
+
if tool_call.respond_to?(:arguments)
|
|
236
|
+
args = tool_call.arguments
|
|
237
|
+
parse_json_args(args)
|
|
238
|
+
elsif tool_call.respond_to?(:function) && tool_call.function.respond_to?(:arguments)
|
|
239
|
+
args = tool_call.function.arguments
|
|
240
|
+
parse_json_args(args)
|
|
241
|
+
else
|
|
242
|
+
{}
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Parse JSON arguments safely
|
|
247
|
+
#
|
|
248
|
+
# @param args [String, Object] Arguments to parse
|
|
249
|
+
# @return [Hash, String] Parsed arguments or original
|
|
250
|
+
def parse_json_args(args)
|
|
251
|
+
return args unless args.is_a?(String)
|
|
252
|
+
|
|
253
|
+
JSON.parse(args)
|
|
254
|
+
rescue JSON::ParserError
|
|
255
|
+
args
|
|
256
|
+
end
|
|
257
|
+
|
|
209
258
|
# Build attributes for tool call span
|
|
210
259
|
#
|
|
211
260
|
# @param tool_call [Object] Tool call object
|
|
@@ -244,13 +293,25 @@ module LanguageOperator
|
|
|
244
293
|
#
|
|
245
294
|
# @param result [Object] Tool call result
|
|
246
295
|
# @param span [OpenTelemetry::Trace::Span] The span to update
|
|
247
|
-
|
|
296
|
+
# @param tool_name [String] Tool name (for logging)
|
|
297
|
+
# @param tool_id [String] Tool call ID (for logging)
|
|
298
|
+
# @param duration_ms [Float] Execution duration in milliseconds (for logging)
|
|
299
|
+
def record_tool_result(result, span, tool_name = nil, tool_id = nil, duration_ms = nil)
|
|
248
300
|
result_str = result.is_a?(String) ? result : JSON.generate(result)
|
|
249
301
|
span.set_attribute('gen_ai.tool.call.result.size', result_str.bytesize)
|
|
250
302
|
|
|
251
303
|
if (sanitized_result = sanitize_data(result, :tool_results))
|
|
252
304
|
span.set_attribute('gen_ai.tool.call.result', sanitized_result)
|
|
253
305
|
end
|
|
306
|
+
|
|
307
|
+
# Log tool execution completion
|
|
308
|
+
logger&.info('Tool execution completed',
|
|
309
|
+
event: 'tool_call_completed',
|
|
310
|
+
tool_name: tool_name,
|
|
311
|
+
tool_id: tool_id,
|
|
312
|
+
result_size: result_str.bytesize,
|
|
313
|
+
result: sanitize_data(result, :tool_results),
|
|
314
|
+
duration_ms: duration_ms)
|
|
254
315
|
rescue StandardError => e
|
|
255
316
|
logger&.warn('Failed to record tool result', error: e.message)
|
|
256
317
|
end
|
|
@@ -281,5 +342,6 @@ module LanguageOperator
|
|
|
281
342
|
logger&.warn('Failed to record output metadata', error: e.message)
|
|
282
343
|
end
|
|
283
344
|
end
|
|
345
|
+
# rubocop:enable Metrics/ModuleLength
|
|
284
346
|
end
|
|
285
347
|
end
|
|
@@ -25,7 +25,7 @@ module LanguageOperator
|
|
|
25
25
|
|
|
26
26
|
# Build a LanguageAgent resource
|
|
27
27
|
def language_agent(name, instructions:, cluster: nil, schedule: nil, persona: nil, tools: [], models: [],
|
|
28
|
-
mode: nil, labels: {})
|
|
28
|
+
mode: nil, workspace: true, labels: {})
|
|
29
29
|
# Determine mode: reactive, scheduled, or autonomous
|
|
30
30
|
spec_mode = mode || (schedule ? 'scheduled' : 'autonomous')
|
|
31
31
|
|
|
@@ -41,6 +41,8 @@ module LanguageOperator
|
|
|
41
41
|
spec['toolRefs'] = tools.map { |t| { 'name' => t } } unless tools.empty?
|
|
42
42
|
# Convert model names to modelRef objects
|
|
43
43
|
spec['modelRefs'] = models.map { |m| { 'name' => m } } unless models.empty?
|
|
44
|
+
# Enable workspace by default for state persistence
|
|
45
|
+
spec['workspace'] = { 'enabled' => workspace } if workspace
|
|
44
46
|
|
|
45
47
|
{
|
|
46
48
|
'apiVersion' => 'langop.io/v1alpha1',
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://github.com/language-operator/language-operator-gem/schema/agent-dsl.json",
|
|
4
4
|
"title": "Language Operator Agent DSL",
|
|
5
5
|
"description": "Schema for defining autonomous AI agents using the Language Operator DSL",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.58",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|
|
@@ -173,7 +173,7 @@ module LanguageOperator
|
|
|
173
173
|
#
|
|
174
174
|
# @param tool_def [LanguageOperator::Dsl::ToolDefinition] Tool definition from DSL
|
|
175
175
|
# @return [Class] MCP::Tool subclass
|
|
176
|
-
# rubocop:disable Metrics/MethodLength
|
|
176
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
177
177
|
def self.create_mcp_tool(tool_def)
|
|
178
178
|
# Capture tool name and tracer for use in the dynamic class
|
|
179
179
|
tool_name = tool_def.name
|
|
@@ -181,6 +181,9 @@ module LanguageOperator
|
|
|
181
181
|
|
|
182
182
|
# Create a dynamic MCP::Tool class
|
|
183
183
|
Class.new(MCP::Tool) do
|
|
184
|
+
# Set tool name (required for MCP protocol)
|
|
185
|
+
tool_name tool_def.name
|
|
186
|
+
|
|
184
187
|
description tool_def.description || "Tool: #{tool_def.name}"
|
|
185
188
|
|
|
186
189
|
# Build input schema from parameters
|
|
@@ -210,7 +213,9 @@ module LanguageOperator
|
|
|
210
213
|
'tool.type' => 'custom'
|
|
211
214
|
}) do |span|
|
|
212
215
|
# Execute the tool's block
|
|
213
|
-
|
|
216
|
+
# Convert symbol keys to string keys for consistency with DSL expectations
|
|
217
|
+
string_params = params.transform_keys(&:to_s)
|
|
218
|
+
result = @execute_block.call(string_params)
|
|
214
219
|
|
|
215
220
|
# Set success attribute
|
|
216
221
|
span.set_attribute('tool.result', 'success')
|
|
@@ -232,6 +237,6 @@ module LanguageOperator
|
|
|
232
237
|
end
|
|
233
238
|
end
|
|
234
239
|
end
|
|
235
|
-
# rubocop:enable Metrics/MethodLength
|
|
240
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
236
241
|
end
|
|
237
242
|
end
|
data/synth/003/Makefile
CHANGED