praxo-core 0.3.0 → 0.4.0

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.
data/lib/praxo/agent.rb CHANGED
@@ -1,292 +1,352 @@
1
- require_relative 'errors'
2
- require_relative 'json'
3
- require_relative 'models'
4
- require_relative 'runtime'
5
-
6
- module Praxo
7
- module Agent
8
- class FuncTool
9
- attr_reader :name, :description
10
-
11
- def initialize(name, description, function)
12
- @name = name
13
- @description = description
14
- @function = function
15
- end
16
-
17
- def invoke(arguments)
18
- @function.call(arguments)
19
- end
20
- end
21
-
22
- class ToolRegistry
23
- def initialize
24
- @tools = {}
25
- end
26
-
27
- def register(tool)
28
- @tools[tool.name] = tool
29
- tool
30
- end
31
-
32
- def get(name)
33
- Praxo.error('TOOL_NOT_FOUND', "tool not found: #{name}") unless @tools.key?(name)
34
- @tools[name]
35
- end
36
-
37
- def specs
38
- @tools.values.map do |tool|
39
- Models.tool_spec(name: tool.name, description: tool.description)
40
- end.sort_by(&:name)
41
- end
42
- end
43
-
44
- class StaticModel
45
- def initialize(content)
46
- @content = content.to_s
47
- end
48
-
49
- def generate(_request)
50
- Models.response(message: Models.message_assistant(@content))
51
- end
52
- end
53
-
54
- class AgentOptions
55
- attr_accessor :model, :tools, :events, :policy, :system_prompt, :context,
56
- :max_steps, :max_tokens, :tool_choice, :middleware,
57
- :allowed_tools, :cancellation, :on_stream,
58
- :max_parallel_tools
59
-
60
- def initialize(model)
61
- @model = model
62
- @tools = nil
63
- @events = nil
64
- @policy = nil
65
- @system_prompt = ''
66
- @context = nil
67
- @max_steps = 8
68
- @max_tokens = 0
69
- @tool_choice = nil
70
- @middleware = []
71
- @allowed_tools = []
72
- @cancellation = nil
73
- @on_stream = nil
74
- @max_parallel_tools = 1
75
- end
76
- end
77
-
78
- class Runner
79
- def initialize(options)
80
- @options = options
81
- end
82
-
83
- def run(session, prompt)
84
- options = @options
85
- max_steps = options.max_steps.to_i.positive? ? options.max_steps : 8
86
- run_id = "run-#{(Time.now.to_f * 1000).to_i}"
87
- messages = session.messages.dup
88
- unless options.system_prompt.to_s.empty?
89
- messages.unshift(Models.message_text(Models::SYSTEM, options.system_prompt))
90
- end
91
- messages << Models.message_text(Models::USER, prompt.to_s)
92
- usage = Models.usage
93
- entered = []
94
- begin
95
- before_run(options, entered, run_id, prompt)
96
- rescue StandardError => e
97
- after_run(options, entered, nil, e)
98
- raise
99
- end
100
- emit(options, 'run.started', session.id, run_id)
101
- emit_stream(options, Models.stream_part(
102
- run_id: run_id, session_id: session.id, kind: 'values',
103
- step: 0, data: prompt.to_s
104
- ))
105
- after_run_invoked = false
106
- begin
107
- (1..max_steps).each do |step|
108
- check_cancelled(options)
109
- authorize(options, Models.action(kind: 'model.generate', session_id: session.id))
110
- if options.context
111
- compacted, ok = options.context.compact(messages)
112
- Praxo.error('AGENT_CONTEXT_BUDGET', 'context budget') unless ok
113
- messages = compacted
114
- end
115
- request = Models.request(
116
- messages: messages,
117
- tools: options.tools ? options.tools.specs : [],
118
- tool_choice: options.tool_choice
119
- )
120
- response = options.model.generate(request)
121
- check_cancelled(options)
122
- usage = usage.plus(response.usage || Models.usage)
123
- total = usage.input_tokens.to_i + usage.output_tokens.to_i
124
- if options.max_tokens.to_i.positive? && total > options.max_tokens
125
- Praxo.error('AGENT_MAX_TOKENS', 'token budget')
126
- end
127
- message = response.message
128
- messages << message
129
- calls = message.tool_calls || []
130
- if calls.empty?
131
- result = Models.run_result(
132
- run_id: run_id, output: message.content.to_s, steps: step,
133
- usage: usage
134
- )
135
- emit_stream(options, Models.stream_part(
136
- run_id: run_id, session_id: session.id, kind: 'messages',
137
- step: step, data: message.content.to_s
138
- ))
139
- after_run_invoked = true
140
- cleanup_error = after_run(options, entered, result, nil)
141
- raise cleanup_error if cleanup_error
142
- emit(options, 'run.completed', session.id, run_id)
143
- return result
144
- end
145
- messages.concat(invoke_tools(options, calls, run_id, session.id))
146
- end
147
- Praxo.error('AGENT_MAX_STEPS', 'maximum steps')
148
- rescue StandardError => e
149
- after_run(options, entered, nil, e) unless after_run_invoked
150
- emit(options, 'run.failed', session.id, run_id, { 'error' => e.message })
151
- raise
152
- end
153
- end
154
-
155
- private
156
-
157
- def invoke_tools(options, calls, run_id, session_id)
158
- limit = [options.max_parallel_tools.to_i, 1].max
159
- results = []
160
- calls.each_slice(limit) do |batch|
161
- check_cancelled(options)
162
- prepared = batch.map do |call|
163
- prepare_tool(options, call, run_id, session_id)
164
- end
165
- if limit <= 1
166
- prepared.each do |tool, call|
167
- results << execute_tool(tool, call)
168
- check_cancelled(options)
169
- end
170
- else
171
- outcomes = prepared.map do |tool, call|
172
- Thread.new do
173
- begin
174
- [:ok, execute_tool(tool, call)]
175
- rescue StandardError => caught
176
- [:error, caught]
177
- end
178
- end
179
- end.map(&:value)
180
- outcomes.each do |status, payload|
181
- raise payload if status == :error
182
- results << payload
183
- end
184
- check_cancelled(options)
185
- end
186
- end
187
- results
188
- end
189
-
190
- def invoke_tool(options, call, run_id, session_id)
191
- tool, prepared = prepare_tool(options, call, run_id, session_id)
192
- execute_tool(tool, prepared)
193
- end
194
-
195
- def prepare_tool(options, call, run_id, session_id)
196
- original_name = call.name
197
- original_arguments = call.arguments
198
- unless allowed?(options, original_name)
199
- Praxo.error('AGENT_TOOL_NOT_ALLOWED', "tool #{original_name} is not allowed")
200
- end
201
- authorize(options, Models.action(
202
- kind: 'tool.invoke', session_id: session_id,
203
- tool_name: original_name, arguments: original_arguments
204
- ))
205
- options.middleware.each do |middleware|
206
- next unless middleware.respond_to?(:before_tool)
207
- call = middleware.before_tool(call) || call
208
- end
209
- unless allowed?(options, call.name)
210
- Praxo.error('AGENT_TOOL_NOT_ALLOWED', "tool #{call.name} is not allowed")
211
- end
212
- if call.name != original_name || call.arguments != original_arguments
213
- authorize(options, Models.action(
214
- kind: 'tool.invoke', session_id: session_id,
215
- tool_name: call.name, arguments: call.arguments
216
- ))
217
- end
218
- Praxo.error('TOOL_NOT_FOUND', 'tool is not registered') unless options.tools
219
- tool = options.tools.get(call.name)
220
- emit(options, 'tool.call', session_id, run_id,
221
- { 'name' => call.name, 'arguments' => call.arguments || {} })
222
- [tool, call]
223
- end
224
-
225
- def execute_tool(tool, call)
226
- value = tool.invoke(call.arguments || {})
227
- Models.message_tool(Json.stringify(value), call.id.to_s)
228
- end
229
-
230
- def check_cancelled(options)
231
- return unless options.cancellation
232
- cancelled = if options.cancellation.respond_to?(:cancelled?)
233
- options.cancellation.cancelled?
234
- elsif options.cancellation.respond_to?(:call)
235
- options.cancellation.call
236
- else
237
- options.cancellation
238
- end
239
- Praxo.error('AGENT_CANCELLED', 'run cancelled') if cancelled
240
- end
241
-
242
- def emit_stream(options, part)
243
- return unless options.on_stream
244
- options.on_stream.call(part)
245
- end
246
-
247
- def allowed?(options, name)
248
- options.allowed_tools.empty? || options.allowed_tools.include?(name)
249
- end
250
-
251
- def before_run(options, entered, run_id, prompt)
252
- state = { run_id: run_id, prompt: prompt }
253
- options.middleware.each do |middleware|
254
- entered << middleware
255
- middleware.before_run(state) if middleware.respond_to?(:before_run)
256
- end
257
- end
258
-
259
- def after_run(options, entered, result, error)
260
- first_error = nil
261
- entered.each do |middleware|
262
- next unless middleware.respond_to?(:after_run)
263
- begin
264
- middleware.after_run(result, error)
265
- rescue StandardError => caught
266
- first_error ||= caught
267
- end
268
- end
269
- first_error
270
- end
271
-
272
- def authorize(options, action)
273
- return unless options.policy
274
- decision = options.policy.call(action)
275
- Praxo.error('AGENT_POLICY_DENIED', 'policy denied the action') unless decision&.allowed
276
- end
277
-
278
- def emit(options, type, session_id, run_id, data = nil)
279
- return unless options.events
280
- options.events.emit(Models.event(
281
- type: type, session_id: session_id, run_id: run_id, data: data
282
- ))
283
- end
284
- end
285
-
286
- module_function
287
-
288
- def run_agent(options, session, prompt)
289
- Runner.new(options).run(session, prompt)
290
- end
291
- end
292
- end
1
+ require_relative 'errors'
2
+ require_relative 'json'
3
+ require_relative 'models'
4
+ require_relative 'runtime'
5
+
6
+ module Praxo
7
+ module Agent
8
+ class FuncTool
9
+ attr_reader :name, :description
10
+
11
+ def initialize(name, description, function)
12
+ @name = name
13
+ @description = description
14
+ @function = function
15
+ end
16
+
17
+ def invoke(arguments)
18
+ @function.call(arguments)
19
+ end
20
+ end
21
+
22
+ class ToolRegistry
23
+ def initialize
24
+ @tools = {}
25
+ end
26
+
27
+ def register(tool)
28
+ @tools[tool.name] = tool
29
+ tool
30
+ end
31
+
32
+ def get(name)
33
+ Praxo.error('TOOL_NOT_FOUND', "tool not found: #{name}") unless @tools.key?(name)
34
+ @tools[name]
35
+ end
36
+
37
+ def specs
38
+ @tools.values.map do |tool|
39
+ Models.tool_spec(name: tool.name, description: tool.description)
40
+ end.sort_by(&:name)
41
+ end
42
+ end
43
+
44
+ class StaticModel
45
+ def initialize(content)
46
+ @content = content.to_s
47
+ end
48
+
49
+ def generate(_request)
50
+ Models.response(message: Models.message_assistant(@content))
51
+ end
52
+ end
53
+
54
+ INTERRUPT_APPROVE = 'approve'
55
+ INTERRUPT_EDIT = 'edit'
56
+ INTERRUPT_REJECT = 'reject'
57
+ INTERRUPT_RESPOND = 'respond'
58
+
59
+ RespondTool = Struct.new(:value) do
60
+ def invoke(_arguments)
61
+ value
62
+ end
63
+ end
64
+
65
+ class AgentOptions
66
+ attr_accessor :model, :tools, :events, :policy, :system_prompt, :context,
67
+ :max_steps, :max_tokens, :tool_choice, :middleware,
68
+ :allowed_tools, :cancellation, :on_stream,
69
+ :max_parallel_tools, :interrupt_on, :on_interrupt_decision,
70
+ :on_interrupt
71
+
72
+ def initialize(model)
73
+ @model = model
74
+ @tools = nil
75
+ @events = nil
76
+ @policy = nil
77
+ @system_prompt = ''
78
+ @context = nil
79
+ @max_steps = 8
80
+ @max_tokens = 0
81
+ @tool_choice = nil
82
+ @middleware = []
83
+ @allowed_tools = []
84
+ @cancellation = nil
85
+ @on_stream = nil
86
+ @max_parallel_tools = 1
87
+ @interrupt_on = []
88
+ @on_interrupt_decision = nil
89
+ @on_interrupt = nil
90
+ end
91
+ end
92
+
93
+ class Runner
94
+ def initialize(options)
95
+ @options = options
96
+ end
97
+
98
+ def run(session, prompt)
99
+ options = @options
100
+ max_steps = options.max_steps.to_i.positive? ? options.max_steps : 8
101
+ run_id = "run-#{(Time.now.to_f * 1000).to_i}"
102
+ messages = session.messages.dup
103
+ unless options.system_prompt.to_s.empty?
104
+ messages.unshift(Models.message_text(Models::SYSTEM, options.system_prompt))
105
+ end
106
+ messages << Models.message_text(Models::USER, prompt.to_s)
107
+ usage = Models.usage
108
+ entered = []
109
+ begin
110
+ before_run(options, entered, run_id, prompt)
111
+ rescue StandardError => e
112
+ after_run(options, entered, nil, e)
113
+ raise
114
+ end
115
+ emit(options, 'run.started', session.id, run_id)
116
+ emit_stream(options, Models.stream_part(
117
+ run_id: run_id, session_id: session.id, kind: 'values',
118
+ step: 0, data: prompt.to_s
119
+ ))
120
+ after_run_invoked = false
121
+ begin
122
+ (1..max_steps).each do |step|
123
+ check_cancelled(options)
124
+ authorize(options, Models.action(kind: 'model.generate', session_id: session.id))
125
+ if options.context
126
+ compacted, ok = options.context.compact(messages)
127
+ Praxo.error('AGENT_CONTEXT_BUDGET', 'context budget') unless ok
128
+ messages = compacted
129
+ end
130
+ request = Models.request(
131
+ messages: messages,
132
+ tools: options.tools ? options.tools.specs : [],
133
+ tool_choice: options.tool_choice
134
+ )
135
+ response = options.model.generate(request)
136
+ check_cancelled(options)
137
+ usage = usage.plus(response.usage || Models.usage)
138
+ total = usage.input_tokens.to_i + usage.output_tokens.to_i
139
+ if options.max_tokens.to_i.positive? && total > options.max_tokens
140
+ Praxo.error('AGENT_MAX_TOKENS', 'token budget')
141
+ end
142
+ message = response.message
143
+ messages << message
144
+ calls = message.tool_calls || []
145
+ if calls.empty?
146
+ result = Models.run_result(
147
+ run_id: run_id, output: message.content.to_s, steps: step,
148
+ usage: usage
149
+ )
150
+ emit_stream(options, Models.stream_part(
151
+ run_id: run_id, session_id: session.id, kind: 'messages',
152
+ step: step, data: message.content.to_s
153
+ ))
154
+ after_run_invoked = true
155
+ cleanup_error = after_run(options, entered, result, nil)
156
+ raise cleanup_error if cleanup_error
157
+ emit(options, 'run.completed', session.id, run_id)
158
+ return result
159
+ end
160
+ messages.concat(invoke_tools(options, calls, run_id, session.id))
161
+ end
162
+ Praxo.error('AGENT_MAX_STEPS', 'maximum steps')
163
+ rescue StandardError => e
164
+ after_run(options, entered, nil, e) unless after_run_invoked
165
+ emit(options, 'run.failed', session.id, run_id, { 'error' => e.message })
166
+ raise
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def invoke_tools(options, calls, run_id, session_id)
173
+ limit = [options.max_parallel_tools.to_i, 1].max
174
+ results = []
175
+ calls.each_slice(limit) do |batch|
176
+ check_cancelled(options)
177
+ prepared = batch.map do |call|
178
+ prepare_tool(options, call, run_id, session_id)
179
+ end
180
+ if limit <= 1
181
+ prepared.each do |tool, call|
182
+ results << execute_tool(tool, call)
183
+ check_cancelled(options)
184
+ end
185
+ else
186
+ outcomes = prepared.map do |tool, call|
187
+ Thread.new do
188
+ begin
189
+ [:ok, execute_tool(tool, call)]
190
+ rescue StandardError => caught
191
+ [:error, caught]
192
+ end
193
+ end
194
+ end.map(&:value)
195
+ outcomes.each do |status, payload|
196
+ raise payload if status == :error
197
+ results << payload
198
+ end
199
+ check_cancelled(options)
200
+ end
201
+ end
202
+ results
203
+ end
204
+
205
+ def invoke_tool(options, call, run_id, session_id)
206
+ tool, prepared = prepare_tool(options, call, run_id, session_id)
207
+ execute_tool(tool, prepared)
208
+ end
209
+
210
+ def prepare_tool(options, call, run_id, session_id)
211
+ original_name = call.name
212
+ original_arguments = call.arguments
213
+ unless allowed?(options, original_name)
214
+ Praxo.error('AGENT_TOOL_NOT_ALLOWED', "tool #{original_name} is not allowed")
215
+ end
216
+ authorize(options, Models.action(
217
+ kind: 'tool.invoke', session_id: session_id,
218
+ tool_name: original_name, arguments: original_arguments
219
+ ))
220
+ options.middleware.each do |middleware|
221
+ next unless middleware.respond_to?(:before_tool)
222
+ call = middleware.before_tool(call) || call
223
+ end
224
+ unless allowed?(options, call.name)
225
+ Praxo.error('AGENT_TOOL_NOT_ALLOWED', "tool #{call.name} is not allowed")
226
+ end
227
+ interrupt = apply_interrupt(options, call, run_id, session_id)
228
+ call = interrupt[:call]
229
+ return [RespondTool.new(interrupt[:value]), call] if interrupt[:skip]
230
+ if call.name != original_name || call.arguments != original_arguments
231
+ authorize(options, Models.action(
232
+ kind: 'tool.invoke', session_id: session_id,
233
+ tool_name: call.name, arguments: call.arguments
234
+ ))
235
+ end
236
+ Praxo.error('TOOL_NOT_FOUND', 'tool is not registered') unless options.tools
237
+ tool = options.tools.get(call.name)
238
+ emit(options, 'tool.call', session_id, run_id,
239
+ { 'name' => call.name, 'arguments' => call.arguments || {} })
240
+ [tool, call]
241
+ end
242
+
243
+ def apply_interrupt(options, call, run_id, session_id)
244
+ return { call: call, skip: false } unless options.interrupt_on.include?(call.name)
245
+ if options.on_interrupt_decision.nil? && options.on_interrupt.nil?
246
+ Praxo.error('AGENT_INTERRUPT_UNHANDLED',
247
+ 'agent: approval handler not configured')
248
+ end
249
+ emit(options, 'tool.interrupt', session_id, run_id,
250
+ { 'name' => call.name, 'arguments' => call.arguments || {} })
251
+ interrupt = Models.interrupt(
252
+ run_id: run_id, session_id: session_id, call: call
253
+ )
254
+ if options.on_interrupt_decision
255
+ decision = options.on_interrupt_decision.call(interrupt)
256
+ case decision.action
257
+ when INTERRUPT_APPROVE
258
+ return { call: call, skip: false }
259
+ when INTERRUPT_EDIT
260
+ if decision.arguments.nil?
261
+ Praxo.error('AGENT_INTERRUPT_DECISION_INVALID',
262
+ 'agent: edit requires arguments')
263
+ end
264
+ call.arguments = decision.arguments
265
+ return { call: call, skip: false }
266
+ when INTERRUPT_REJECT
267
+ Praxo.error('AGENT_APPROVAL_DENIED',
268
+ "agent: tool call denied by approval: #{call.name}: " \
269
+ "#{decision.reason}")
270
+ when INTERRUPT_RESPOND
271
+ return { call: call, skip: true,
272
+ value: decision.response || '' }
273
+ else
274
+ Praxo.error('AGENT_INTERRUPT_DECISION_INVALID',
275
+ "agent: invalid interrupt decision: #{decision.action}")
276
+ end
277
+ end
278
+ unless options.on_interrupt.call(interrupt)
279
+ Praxo.error('AGENT_APPROVAL_DENIED',
280
+ "agent: tool call denied by approval: #{call.name}")
281
+ end
282
+ { call: call, skip: false }
283
+ end
284
+
285
+ def execute_tool(tool, call)
286
+ value = tool.invoke(call.arguments || {})
287
+ Models.message_tool(Json.stringify(value), call.id.to_s)
288
+ end
289
+
290
+ def check_cancelled(options)
291
+ return unless options.cancellation
292
+ cancelled = if options.cancellation.respond_to?(:cancelled?)
293
+ options.cancellation.cancelled?
294
+ elsif options.cancellation.respond_to?(:call)
295
+ options.cancellation.call
296
+ else
297
+ options.cancellation
298
+ end
299
+ Praxo.error('AGENT_CANCELLED', 'run cancelled') if cancelled
300
+ end
301
+
302
+ def emit_stream(options, part)
303
+ return unless options.on_stream
304
+ options.on_stream.call(part)
305
+ end
306
+
307
+ def allowed?(options, name)
308
+ options.allowed_tools.empty? || options.allowed_tools.include?(name)
309
+ end
310
+
311
+ def before_run(options, entered, run_id, prompt)
312
+ state = { run_id: run_id, prompt: prompt }
313
+ options.middleware.each do |middleware|
314
+ entered << middleware
315
+ middleware.before_run(state) if middleware.respond_to?(:before_run)
316
+ end
317
+ end
318
+
319
+ def after_run(options, entered, result, error)
320
+ first_error = nil
321
+ entered.each do |middleware|
322
+ next unless middleware.respond_to?(:after_run)
323
+ begin
324
+ middleware.after_run(result, error)
325
+ rescue StandardError => caught
326
+ first_error ||= caught
327
+ end
328
+ end
329
+ first_error
330
+ end
331
+
332
+ def authorize(options, action)
333
+ return unless options.policy
334
+ decision = options.policy.call(action)
335
+ Praxo.error('AGENT_POLICY_DENIED', 'policy denied the action') unless decision&.allowed
336
+ end
337
+
338
+ def emit(options, type, session_id, run_id, data = nil)
339
+ return unless options.events
340
+ options.events.emit(Models.event(
341
+ type: type, session_id: session_id, run_id: run_id, data: data
342
+ ))
343
+ end
344
+ end
345
+
346
+ module_function
347
+
348
+ def run_agent(options, session, prompt)
349
+ Runner.new(options).run(session, prompt)
350
+ end
351
+ end
352
+ end