phaseo_agent_sdk 0.2.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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +18 -0
  3. data/lib/phaseo_agent_sdk.rb +549 -0
  4. metadata +66 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25053a33c5e6eeb03519f14b3e4141ee76090b4da8b0c1233666b197af380f2d
4
+ data.tar.gz: b61cab9624e56c3b53d6689d4a627424ae1f20c8345156584a513b8f52bcae94
5
+ SHA512:
6
+ metadata.gz: dc5df5160ffdbee38f62ecf988cb1af9984f77457052fbd19884fdb520bb0f6be10ee4781d2b5788bf58466ccd397f65a3e6a5a3f2c2098c9db2e85924cf3db2
7
+ data.tar.gz: b2a0aefa408d1863b6b9d4c037bbef738985e59956c8544525e310cf714fdaab19f9df4659df8dd787b776eb039fe081b97988e8e0a6e95914ab3ee5632ff2c2
data/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # Phaseo Agent SDK (Ruby)
2
+
3
+ `phaseo_agent_sdk` is the native Ruby runtime for building tool-using applications on Phaseo Gateway.
4
+
5
+ It provides:
6
+
7
+ - `PhaseoAgentSdk.create_agent`
8
+ - `PhaseoAgentSdk.define_tool`
9
+ - `PhaseoAgentSdk.create_gateway_agent_client`
10
+ - thread-based concurrent tools, timeouts, and model retry/backoff
11
+ - human-review pauses and `Agent#continue_run`
12
+ - lifecycle events, serializable run state, and Phaseo Devtools capture
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ gem install phaseo_sdk phaseo_agent_sdk
18
+ ```
@@ -0,0 +1,549 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "fileutils"
4
+ require "timeout"
5
+ require "time"
6
+ begin
7
+ require "phaseo_sdk"
8
+ rescue LoadError
9
+ begin
10
+ require "phaseo_sdk"
11
+ rescue LoadError
12
+ require_relative "../../sdk-ruby/lib/index"
13
+ end
14
+ end
15
+
16
+ module PhaseoAgentSdk
17
+ ToolCall = Struct.new(:id, :name, :input, keyword_init: true)
18
+ Message = Struct.new(:role, :content, :tool_calls, :tool_call_id, :name, :is_error, keyword_init: true)
19
+ Tool = Struct.new(:id, :execute, :description, :parameters, :timeout_ms, :input_schema, :output_schema, :event_schema, :require_approval, :on_tool_called, :on_response_received, :next_turn_params, :on_error, keyword_init: true)
20
+ RuntimeContext = Struct.new(:run_id, :agent_id, :step_index, :context, :tool_call, :emit_progress, :set_context, keyword_init: true)
21
+ ModelRequest = Struct.new(:agent_id, :messages, :tools, :model, :instructions, :context, :temperature, :max_output_tokens, :top_p, keyword_init: true)
22
+ ModelResponse = Struct.new(:message, :usage, :request_id, :native_response_id, :provider, :model, :response_meta, :finish_reason, :cost, :warnings, keyword_init: true)
23
+ AgentDefinition = Struct.new(:id, :model, :preset, :instructions, :tools, :max_steps, :parse_output, :human_review, :model_retry, :tool_execution, :stop_when, :output_schema, :require_approval, :temperature, :max_output_tokens, :top_p, :dynamic_tools, keyword_init: true)
24
+ RunStep = Struct.new(:index, :status, :tool_calls, :request_id, :native_response_id, :provider, :model, :model_attempts, :usage, :response_meta, :error, :finish_reason, :warnings, keyword_init: true)
25
+ HumanReviewRequest = Struct.new(:reason, :payload, keyword_init: true)
26
+ PendingToolCall = Struct.new(:call, :kind, :reason, keyword_init: true)
27
+ ToolDecision = Struct.new(:tool_call_id, :reason, keyword_init: true)
28
+ ToolOutput = Struct.new(:tool_call_id, :output, keyword_init: true)
29
+ HumanPause = Struct.new(:reason, :payload, :requested_at, :kind, :pending_tool_calls, keyword_init: true)
30
+ HumanReviewContext = Struct.new(:run_id, :agent_id, :step_index, :input, :context, :messages, :response, :parsed_output, keyword_init: true)
31
+ RunRecord = Struct.new(:id, :agent_id, :status, :input, :context, :messages, :step_count, :result, :error, :pause, :created_at, :updated_at, :stop_reason, :usage, :next_turn_params, keyword_init: true)
32
+ RunResult = Struct.new(:run, :steps, :output, :messages, :usage, keyword_init: true)
33
+ DevtoolsConfig = Struct.new(:enabled, :directory, keyword_init: true)
34
+ class StreamResult
35
+ def initialize(&work)
36
+ @events=[];@mutex=Mutex.new;@condition=ConditionVariable.new;@done=false
37
+ @worker=Thread.new do
38
+ begin;@result=work.call(method(:push));rescue Exception=>error;@error=error;ensure;@mutex.synchronize{@done=true;@condition.broadcast};end
39
+ end
40
+ end
41
+ def push(event)=@mutex.synchronize{@events<<event;@condition.broadcast}
42
+ def result;@worker.join;raise @error if @error;@result;end
43
+ def cancel;@worker.kill if @worker&.alive?;@mutex.synchronize{@done=true;@condition.broadcast};end
44
+ def full_stream
45
+ Enumerator.new do|yielded|;index=0;loop do;event=nil;finished=false;@mutex.synchronize do;@condition.wait(@mutex) while index>=@events.length&&!@done;event=@events[index] if index<@events.length;index+=1 if event;finished=@done&&event.nil?;end;break if finished;yielded<<event if event;end;raise @error if @error;end
46
+ end
47
+ def text_stream = full_stream.lazy.select{|event|event[:type]=="response.output_text.delta"}.map{|event|event[:delta].to_s}
48
+ def reasoning_stream = full_stream.lazy.select{|event|event[:type]=="response.reasoning.delta"}.map{|event|event[:delta].to_s}
49
+ def item_stream = full_stream.lazy.select{|event|event[:type]=="response.item"}.map{|event|event[:item]}
50
+ end
51
+
52
+ def self.define_tool(tool)
53
+ tool
54
+ end
55
+
56
+ def self.create_agent(definition)
57
+ Agent.new(definition.is_a?(AgentDefinition) ? definition : AgentDefinition.new(**definition))
58
+ end
59
+
60
+ def self.create_gateway_agent_client(**options)
61
+ GatewayAgentClient.new(**options)
62
+ end
63
+
64
+ def self.create_agent_devtools(directory: ".phaseo-devtools", enabled: true)
65
+ DevtoolsConfig.new(enabled: enabled, directory: directory)
66
+ end
67
+ def self.step_count_is(limit)=->(state){"step_count:#{limit}" if state[:step_count]>=limit}
68
+ def self.max_tokens_used(limit)=->(state){"max_tokens:#{limit}" if state[:usage][:total_tokens]>=limit}
69
+ def self.max_cost(limit)=->(state){"max_cost:#{limit}" if state[:usage][:cost]>=limit}
70
+ def self.max_duration(milliseconds)=->(state){"max_duration:#{milliseconds}" if state[:elapsed_ms]>=milliseconds}
71
+ def self.has_tool_call(name)=->(state){"tool_call:#{name}" if state[:tool_calls].any?{|call|call.name==name}}
72
+ def self.finish_reason_is(reason)=->(state){"finish_reason:#{reason}" if state[:finish_reason]==reason}
73
+
74
+ class GatewayAgentClient
75
+ def initialize(client: nil, api_key: nil, base_url: nil, model: nil, preset: nil, provider: nil, reasoning: nil,
76
+ temperature: nil, max_output_tokens: nil, parallel_tool_calls: nil, metadata: nil, user: nil,
77
+ response_format: nil, include_meta: nil, web_search_options: nil, plugins: nil, gateway_tools: nil,
78
+ tool_choice: nil, provider_options: nil, prompt_cache_key: nil, request_options: nil)
79
+ api_key ||= ENV["PHASEO_API_KEY"]
80
+ @client = client || PhaseoSdk::Phaseo.new(
81
+ api_key: api_key,
82
+ base_path: base_url || ENV.fetch("PHASEO_BASE_URL", "https://api.phaseo.app/v1")
83
+ )
84
+ @model = model
85
+ @preset = preset
86
+ @provider = provider
87
+ @reasoning = reasoning
88
+ @temperature = temperature
89
+ @max_output_tokens = max_output_tokens
90
+ @parallel_tool_calls = parallel_tool_calls
91
+ @metadata = metadata
92
+ @user = user
93
+ @response_format = response_format
94
+ @include_meta = include_meta
95
+ @web_search_options = web_search_options
96
+ @plugins = plugins
97
+ @gateway_tools = gateway_tools || []
98
+ @tool_choice = tool_choice
99
+ @provider_options = provider_options
100
+ @prompt_cache_key = prompt_cache_key
101
+ @request_options = request_options || {}
102
+ end
103
+
104
+ def generate(request)
105
+ response = @client.create_response(payload(request))
106
+ model_response(response)
107
+ end
108
+
109
+ def stream(request)
110
+ return enum_for(__method__,request) unless block_given?
111
+ @client.stream_response(payload(request).merge(stream:true)).each do |line|
112
+ next unless line.start_with?("data:");data=line.delete_prefix("data:").strip;next if data.empty?||data=="[DONE]";begin;raw=JSON.parse(data);rescue JSON::ParserError;next;end;type=raw["type"].to_s;delta=raw["delta"].is_a?(String)?raw["delta"]:(raw["text"].is_a?(String)?raw["text"]:nil)
113
+ if type=="response.completed";yield(type:"response.completed",response:model_response(raw["response"].is_a?(Hash)?raw["response"]:raw),raw:raw);return;end
114
+ yield(type:"response.item",item:raw["item"],raw:raw) if raw.key?("item");yield(type:type.include?("reasoning")?"response.reasoning.delta":"response.output_text.delta",delta:delta,raw:raw) if delta
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def payload(request)
121
+ {
122
+ **@request_options,
123
+ model: request.model || @model || preset_alias(@preset) || "phaseo/free",
124
+ input: to_responses_input(request.messages),
125
+ instructions: to_instructions(request.messages, request.instructions),
126
+ tools: request.tools.map do |tool|
127
+ {
128
+ type: "function",
129
+ function: {
130
+ name: tool.id,
131
+ description: tool.description,
132
+ parameters: tool.parameters || { type: "object", additionalProperties: true }
133
+ }
134
+ }
135
+ end + @gateway_tools,
136
+ tool_choice: @tool_choice,
137
+ parallel_tool_calls: @parallel_tool_calls,
138
+ temperature: request.temperature || @temperature,
139
+ max_output_tokens: request.max_output_tokens || @max_output_tokens,
140
+ top_p: request.top_p,
141
+ provider: @provider,
142
+ reasoning: @reasoning,
143
+ metadata: @metadata,
144
+ meta: @include_meta,
145
+ user: @user,
146
+ response_format: @response_format,
147
+ web_search_options: @web_search_options,
148
+ plugins: @plugins,
149
+ provider_options: @provider_options,
150
+ prompt_cache_key: @prompt_cache_key
151
+ }.compact
152
+
153
+ end
154
+
155
+ def model_response(response)
156
+ usage=response["usage"].is_a?(Hash)?response["usage"]:{};meta=response["meta"].is_a?(Hash)?response["meta"]:{};cost=[response["cost"],response["cost_usd"],usage["cost"],meta["cost"],meta["cost_usd"]].find{|value|value.is_a?(Numeric)};cost=response["cost_nanos"].to_f/1_000_000_000 if !cost&&response["cost_nanos"].is_a?(Numeric);cost=meta["cost_nanos"].to_f/1_000_000_000 if !cost&&meta["cost_nanos"].is_a?(Numeric)
157
+ ModelResponse.new(
158
+ message: Message.new(
159
+ role: "assistant",
160
+ content: extract_assistant_text(response),
161
+ tool_calls: extract_tool_calls(response)
162
+ ),
163
+ usage: usage,
164
+ request_id: response["request_id"]||response["id"],
165
+ native_response_id:response["native_response_id"]||response["nativeResponseId"],
166
+ provider: response["provider"],
167
+ model: response["model"],
168
+ response_meta: meta,finish_reason:response["finish_reason"]||response["stop_reason"]||response["status"],cost:cost.to_f,warnings:Array(response["warnings"])
169
+ )
170
+ end
171
+
172
+ def preset_alias(value)
173
+ return nil unless value.is_a?(String)
174
+ normalized = value.strip.sub(/\A@+/, "")
175
+ normalized.empty? ? nil : "@#{normalized}"
176
+ end
177
+
178
+ def stringify(value)
179
+ value.is_a?(String) ? value : JSON.generate(value)
180
+ end
181
+
182
+ def to_responses_input(messages)
183
+ messages.filter_map do |message|
184
+ case message.role
185
+ when "system"
186
+ nil
187
+ when "tool"
188
+ {
189
+ type: "function_call_output",
190
+ call_id: message.tool_call_id,
191
+ output: stringify(message.content)
192
+ }
193
+ else
194
+ item = {
195
+ type: "message",
196
+ role: message.role,
197
+ content: stringify(message.content)
198
+ }
199
+ if message.role == "assistant" && Array(message.tool_calls).any?
200
+ item[:tool_calls] = message.tool_calls.map do |tool_call|
201
+ {
202
+ id: tool_call.id,
203
+ type: "function",
204
+ function: {
205
+ name: tool_call.name,
206
+ arguments: JSON.generate(tool_call.input || {})
207
+ }
208
+ }
209
+ end
210
+ end
211
+ item
212
+ end
213
+ end
214
+ end
215
+
216
+ def to_instructions(messages, override)
217
+ system_text = messages
218
+ .select { |message| message.role == "system" && !message.content.to_s.strip.empty? }
219
+ .map { |message| message.content.to_s.strip }
220
+ .join("\n\n")
221
+ return "#{override}\n\n#{system_text}" if override && !system_text.empty?
222
+ override || (system_text.empty? ? nil : system_text)
223
+ end
224
+
225
+ def extract_tool_calls(response)
226
+ items = response["output_items"] || response["output"] || []
227
+ items.filter_map.with_index do |item, index|
228
+ next unless item["type"].to_s.downcase == "function_call"
229
+ ToolCall.new(
230
+ id: item["call_id"] || "tool_call_#{index}",
231
+ name: item["name"] || "tool",
232
+ input: safe_parse_tool_input(item["arguments"])
233
+ )
234
+ end
235
+ end
236
+
237
+ def extract_assistant_text(response)
238
+ items = response["output_items"] || response["output"] || []
239
+ parts = []
240
+ items.each do |item|
241
+ next unless item["type"].to_s.downcase == "message"
242
+ Array(item["content"]).each do |part|
243
+ parts << part["text"] if part["type"].to_s.downcase == "output_text" && part["text"].is_a?(String)
244
+ end
245
+ end
246
+ parts.join("\n\n")
247
+ end
248
+
249
+ def safe_parse_tool_input(raw)
250
+ return {} if raw.nil? || raw.to_s.strip.empty?
251
+ JSON.parse(raw)
252
+ rescue JSON::ParserError
253
+ { "raw" => raw }
254
+ end
255
+ end
256
+
257
+ class Agent
258
+ def initialize(definition)
259
+ @definition = definition
260
+ @definition.tools ||= []
261
+ @definition.max_steps ||= 12
262
+ @definition.stop_when ||= []
263
+ @definition.tools.each { |tool| tool.on_error ||= "fail-run" }
264
+ end
265
+
266
+ def run(input:, client:, context: nil, model: nil, preset: nil, max_steps: nil, model_retry: nil,
267
+ tool_execution: nil, on_event: nil, devtools: nil, state: nil)
268
+ started_at = Time.now
269
+ run_id = SecureRandom.uuid
270
+ created_at = now_iso
271
+ messages = []
272
+ messages << Message.new(role: "system", content: @definition.instructions) if @definition.instructions.is_a?(String) && !@definition.instructions.empty?
273
+ messages << Message.new(role: "user", content: input.is_a?(String) ? input : JSON.pretty_generate(input))
274
+ run_record = RunRecord.new(
275
+ id: run_id, agent_id: @definition.id, status: "queued", input: input, context: context,
276
+ messages: messages, step_count: 0, created_at: created_at, updated_at: created_at,
277
+ usage: { input_tokens: 0, output_tokens: 0, cached_tokens: 0, total_tokens: 0, cost: 0.0 }
278
+ )
279
+ emit(on_event, "run.started", run_record)
280
+ begin
281
+ result = execute(
282
+ run_record, [], client: client, context: context, model: model, preset: preset,
283
+ max_steps: max_steps, model_retry: model_retry, tool_execution: tool_execution,
284
+ on_event: on_event
285
+ )
286
+ capture_devtools(result, "agent.run", started_at, devtools)
287
+ state.save(result) if state
288
+ result
289
+ rescue StandardError => e
290
+ capture_devtools(nil, "agent.run", started_at, devtools, e, run_id)
291
+ raise
292
+ end
293
+ end
294
+
295
+ def stream(input:, client:, context: nil, state: nil)
296
+ StreamResult.new do|emit|
297
+ handler=emit
298
+ if client.respond_to?(:stream)
299
+ source=client
300
+ client=Class.new do
301
+ define_method(:generate) do |request|
302
+ text=String.new;completed=nil
303
+ source.stream(request).each do |event|;text<<event[:delta].to_s if event[:type]=="response.output_text.delta";completed=event[:response] if event[:type]=="response.completed";handler.call(event);end
304
+ completed||ModelResponse.new(message:Message.new(role:"assistant",content:text))
305
+ end
306
+ end.new
307
+ end
308
+ run(input:input,client:client,context:context,on_event:handler,state:state)
309
+ end
310
+ end
311
+
312
+ def continue_run(run: nil, run_id: nil, client:, human_input: nil, context: nil, model: nil, preset: nil, max_steps: nil,
313
+ model_retry: nil, tool_execution: nil, on_event: nil, devtools: nil, approvals: [], rejections: [], tool_outputs: [], state: nil)
314
+ run ||= state&.load(run_id);raise "A run or state accessor with run_id is required" unless run
315
+ raise "Run #{run.run.id} belongs to agent #{run.run.agent_id}" unless run.run.agent_id == @definition.id
316
+ if run.run.status == "waiting_for_human" && human_input.to_s.empty? && Array(run.run.pause&.pending_tool_calls).empty?
317
+ raise "Run #{run.run.id} is waiting for human input"
318
+ end
319
+ started_at = Time.now
320
+ previous_status = run.run.status
321
+ unless human_input.to_s.empty?
322
+ run.run.messages << Message.new(role: "user", content: human_input)
323
+ run.run.pause = nil
324
+ else
325
+ approved=approvals.to_h{|item|[item.is_a?(String)?item:item.tool_call_id,item]}; rejected=rejections.to_h{|item|[item.is_a?(String)?item:item.tool_call_id,item]}; outputs=tool_outputs.to_h{|item|[item.tool_call_id,item.output]}; tools=@definition.tools.to_h{|item|[item.id,item]}
326
+ Array(run.run.pause&.pending_tool_calls).each do |pending|
327
+ call=pending.call; tool=tools[call.name]; runtime=RuntimeContext.new(run_id:run.run.id,agent_id:run.run.agent_id,step_index:run.run.step_count-1,context:run.run.context,tool_call:call,set_context:->(value){run.run.context=value})
328
+ if rejected.key?(call.id); reason=rejected[call.id].is_a?(String) ? "Tool call rejected" : (rejected[call.id].reason||"Tool call rejected"); run.run.messages<<Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:JSON.generate(error:reason),is_error:true); next; end
329
+ if pending.kind=="approval"; raise "Missing approval decision for tool call #{call.id}" unless approved.key?(call.id); run.run.messages<<execute_tool(tool,call,run.run,run.run.step_count-1,on_event);run.run.next_turn_params=tool.next_turn_params if tool.next_turn_params;next
330
+ else; raise "Missing output for tool call #{call.id}" unless outputs.key?(call.id); value=outputs[call.id]; value=tool.on_response_received.call(value,runtime) if tool.on_response_received; end
331
+ value=validate(tool.output_schema,value,"tool output"); run.run.messages<<Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:value.is_a?(String)?value:JSON.generate(value));run.run.next_turn_params=tool.next_turn_params if tool.next_turn_params
332
+ end
333
+ run.run.pause=nil
334
+ end
335
+ run.run.status = "running"
336
+ run.run.updated_at = now_iso
337
+ emit(on_event, "run.resumed", run.run, previous_status: previous_status)
338
+ begin
339
+ result = execute(
340
+ run.run, run.steps.dup, client: client, context: context.nil? ? run.run.context : context,
341
+ model: model, preset: preset, max_steps: max_steps, model_retry: model_retry,
342
+ tool_execution: tool_execution, on_event: on_event
343
+ )
344
+ capture_devtools(result, "agent.continue", started_at, devtools)
345
+ state.save(result) if state
346
+ result
347
+ rescue StandardError => e
348
+ capture_devtools(nil, "agent.continue", started_at, devtools, e, run.run.id)
349
+ raise
350
+ end
351
+ end
352
+
353
+ def continue_stream(**options)
354
+ StreamResult.new do|emit|
355
+ client=options.fetch(:client);if client.respond_to?(:stream);source=client;client=Class.new do;define_method(:generate) do|request|;text=String.new;completed=nil;source.stream(request).each{|event|text<<event[:delta].to_s if event[:type]=="response.output_text.delta";completed=event[:response] if event[:type]=="response.completed";emit.call(event)};completed||ModelResponse.new(message:Message.new(role:"assistant",content:text));end;end.new;end
356
+ continue_run(**options.merge(client:client,on_event:emit))
357
+ end
358
+ end
359
+
360
+ private
361
+
362
+ def execute_tool(tool,call,run,step_index,on_event)
363
+ emit(on_event,"tool.started",run,step_index:step_index,tool_call_id:call.id,tool_name:call.name);progress=proc{|value|checked=validate(tool.event_schema,value,"tool progress event");emit(on_event,"tool.preliminary_result",run,step_index:step_index,tool_call_id:call.id,tool_name:call.name,result:checked)};runtime=RuntimeContext.new(run_id:run.id,agent_id:run.agent_id,step_index:step_index,context:run.context,tool_call:call,emit_progress:progress,set_context:->(value){run.context=value})
364
+ begin;output=tool.timeout_ms ? Timeout.timeout(tool.timeout_ms/1000.0){tool.execute.call(call.input,runtime)}:tool.execute.call(call.input,runtime);output=validate(tool.output_schema,output,"tool output")
365
+ rescue StandardError=>error;emit(on_event,"tool.failed",run,step_index:step_index,tool_call_id:call.id,tool_name:call.name,error:error.message);raise unless tool.on_error=="return-to-model";return Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:JSON.generate(error:error.message),is_error:true);end
366
+ emit(on_event,"tool.completed",run,step_index:step_index,tool_call_id:call.id,tool_name:call.name,output:output);Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:output.is_a?(String)?output:JSON.generate(output))
367
+ end
368
+
369
+ def execute(run, steps, client:, context:, model:, preset:, max_steps:, model_retry:, tool_execution:, on_event:)
370
+ run.status = "running"
371
+ effective_max_steps = max_steps || @definition.max_steps || 12
372
+ retry_config = { max_retries: 0, backoff_ms: 250 }.merge(@definition.model_retry || {}).merge(model_retry || {})
373
+ execution_config = { tool_concurrency: 1 }.merge(@definition.tool_execution || {}).merge(tool_execution || {})
374
+ target_model = [model, preset_alias(preset), @definition.model.is_a?(String) ? @definition.model : nil, preset_alias(@definition.preset)].find { |item| !item.to_s.strip.empty? };next_turn=run.next_turn_params;run.next_turn_params=nil;loop_started=Process.clock_gettime(Process::CLOCK_MONOTONIC)
375
+ tool_index = @definition.tools.to_h { |tool| [tool.id, tool] }
376
+
377
+ (run.step_count...effective_max_steps).each do |step_index|
378
+ turn={number_of_turns:step_index+1,step_index:step_index,messages:run.messages.dup,context:run.context};turn_model=@definition.model.respond_to?(:call) ? @definition.model.call(turn) : target_model;turn_instructions=@definition.instructions.respond_to?(:call) ? @definition.instructions.call(turn) : @definition.instructions;temperature=@definition.temperature.respond_to?(:call) ? @definition.temperature.call(turn) : @definition.temperature;max_output=@definition.max_output_tokens.respond_to?(:call) ? @definition.max_output_tokens.call(turn) : @definition.max_output_tokens;top_p=@definition.top_p.respond_to?(:call) ? @definition.top_p.call(turn) : @definition.top_p;turn_tools=@definition.dynamic_tools.respond_to?(:call) ? @definition.dynamic_tools.call(turn) : @definition.tools;if next_turn;turn_model=next_turn[:model]||turn_model;turn_instructions=next_turn[:instructions]||turn_instructions;temperature=next_turn[:temperature]||temperature;max_output=next_turn[:max_output_tokens]||max_output;top_p=next_turn[:top_p]||top_p;turn_tools=next_turn[:tools]||turn_tools;next_turn=nil;end;tool_index=turn_tools.to_h{|tool|[tool.id,tool]}
379
+ step = RunStep.new(index: step_index, status: "executing_model", tool_calls: [], model_attempts: 0)
380
+ steps << step
381
+ emit(on_event, "step.started", run, step_index: step_index)
382
+ response = nil
383
+ (retry_config[:max_retries].to_i + 1).times do |attempt|
384
+ step.model_attempts = attempt + 1
385
+ emit(on_event, "model.requested", run, step_index: step_index, attempt: attempt + 1, model: turn_model)
386
+ begin
387
+ response = client.generate(
388
+ ModelRequest.new(
389
+ agent_id: @definition.id, model: turn_model, instructions: turn_instructions,
390
+ messages: run.messages, tools: turn_tools, context: run.context,temperature:temperature,max_output_tokens:max_output,top_p:top_p
391
+ )
392
+ )
393
+ break
394
+ rescue StandardError
395
+ raise if attempt >= retry_config[:max_retries].to_i
396
+ sleep(retry_config[:backoff_ms].to_i * (attempt + 1) / 1000.0)
397
+ end
398
+ end
399
+
400
+ run.messages << response.message
401
+ run.step_count = step_index + 1
402
+ run.updated_at = now_iso
403
+ step.tool_calls = Array(response.message.tool_calls)
404
+ step.request_id = response.request_id
405
+ step.native_response_id = response.native_response_id
406
+ step.provider = response.provider
407
+ step.model = response.model || turn_model
408
+ step.usage = response.usage
409
+ step.response_meta = response.response_meta
410
+ step.finish_reason=response.finish_reason;step.warnings=Array(response.warnings)
411
+ current_usage=normalized_usage(response); current_usage.each{|key,value|run.usage[key]=(run.usage[key]||0)+value}
412
+ emit(on_event, "model.completed", run, step_index: step_index, attempt: step.model_attempts, request_id: step.request_id, model: step.model)
413
+
414
+ parsed_output = if step.tool_calls.empty? && @definition.parse_output
415
+ @definition.parse_output.call(response.message.content)
416
+ end
417
+ if @definition.human_review
418
+ review = @definition.human_review.call(
419
+ HumanReviewContext.new(
420
+ run_id: run.id, agent_id: run.agent_id, step_index: step_index, input: run.input,
421
+ context: context, messages: run.messages.dup, response: response, parsed_output: parsed_output
422
+ )
423
+ )
424
+ if review
425
+ run.status = "waiting_for_human"
426
+ run.pause = HumanPause.new(reason: review.reason, payload: review.payload, requested_at: now_iso)
427
+ step.status = "checkpointed"
428
+ emit(on_event, "checkpoint.saved", run, step_index: step_index)
429
+ emit(on_event, "run.waiting_for_human", run, step_index: step_index, pause: run.pause.to_h)
430
+ return RunResult.new(run: run, steps: steps, output: nil, messages: run.messages)
431
+ end
432
+ end
433
+
434
+ if step.tool_calls.empty?
435
+ output = @definition.parse_output ? parsed_output : response.message.content
436
+ output=validate(@definition.output_schema,output,"agent output")
437
+ @definition.stop_when.each do |condition|
438
+ reason=condition.call(step_count:run.step_count,usage:run.usage,tool_calls:[],finish_reason:response.finish_reason,elapsed_ms:(Process.clock_gettime(Process::CLOCK_MONOTONIC)-loop_started)*1000)
439
+ if reason; run.status="stopped";run.stop_reason=reason.to_s;run.result=output;return RunResult.new(run:run,steps:steps,output:output,messages:run.messages,usage:run.usage);end
440
+ end
441
+ run.status = "completed"
442
+ run.result = output
443
+ run.pause = nil
444
+ step.status = "checkpointed"
445
+ emit(on_event, "checkpoint.saved", run, step_index: step_index)
446
+ emit(on_event, "run.completed", run, output: output)
447
+ return RunResult.new(run: run, steps: steps, output: output, messages: run.messages, usage: run.usage)
448
+ end
449
+
450
+ run.status = "waiting_for_tools"
451
+ step.status = "executing_tools"
452
+ pending=[]; calls=[]
453
+ step.tool_calls.each do |call|
454
+ tool=tool_index[call.name];raise "Unknown tool '#{call.name}'" unless tool;call.input=validate(tool.input_schema,call.input,"tool input");runtime=RuntimeContext.new(run_id:run.id,agent_id:run.agent_id,step_index:step_index,context:run.context,tool_call:call)
455
+ if tool.on_tool_called;prefetched=tool.on_tool_called.call(call.input,runtime);if prefetched.nil?;pending<<PendingToolCall.new(call:call,kind:"hitl",reason:"Tool requires human input");else;value=validate(tool.output_schema,prefetched,"tool output");run.messages<<Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:value.is_a?(String)?value:JSON.generate(value));end;next;end
456
+ gated=tool.require_approval.respond_to?(:call) ? tool.require_approval.call(call.input,runtime) : !!tool.require_approval;gated=@definition.require_approval.call(call,runtime) if @definition.require_approval
457
+ if gated;pending<<PendingToolCall.new(call:call,kind:"approval",reason:"Tool requires approval");elsif tool.execute.nil?;pending<<PendingToolCall.new(call:call,kind:"manual",reason:"Tool requires external output");else;calls<<call;end
458
+ end
459
+ messages = Array.new(calls.length);context_updates=Array.new(calls.length);context_was_set=Array.new(calls.length,false);next_turn_updates=Array.new(calls.length)
460
+ queue = Queue.new
461
+ calls.each_with_index { |call, index| queue << [call, index] }
462
+ errors = Queue.new
463
+ execution_config[:tool_concurrency].to_i.clamp(1, [calls.length, 1].max).times.map do
464
+ Thread.new do
465
+ loop do
466
+ call, index = queue.pop(true)
467
+ tool = tool_index[call.name]
468
+ raise "Unknown tool '#{call.name}'" unless tool
469
+ emit(on_event, "tool.started", run, step_index: step_index, tool_call_id: call.id, tool_name: call.name)
470
+ progress=proc{|value|checked=validate(tool.event_schema,value,"tool progress event");emit(on_event,"tool.preliminary_result",run,step_index:step_index,tool_call_id:call.id,tool_name:call.name,result:checked)}
471
+ local_context=run.context;runtime=RuntimeContext.new(run_id:run.id,agent_id:run.agent_id,step_index:step_index,context:local_context,tool_call:call,emit_progress:progress,set_context:->(value){local_context=value;context_updates[index]=value;context_was_set[index]=true})
472
+ begin;output = tool.timeout_ms ? Timeout.timeout(tool.timeout_ms / 1000.0) { tool.execute.call(call.input,runtime) } : tool.execute.call(call.input,runtime);output=validate(tool.output_schema,output,"tool output")
473
+ rescue StandardError=>e;raise unless tool.on_error=="return-to-model";messages[index]=Message.new(role:"tool",name:tool.id,tool_call_id:call.id,content:JSON.generate(error:e.message),is_error:true);emit(on_event,"tool.failed",run,tool_call_id:call.id,tool_name:call.name,error:e.message);next;end
474
+ messages[index] = Message.new(role: "tool", name: tool.id, tool_call_id: call.id, content: output.is_a?(String) ? output : JSON.generate(output))
475
+ emit(on_event, "tool.completed", run, step_index: step_index, tool_call_id: call.id, tool_name: call.name, output: output)
476
+ next_turn_updates[index]=tool.next_turn_params if tool.next_turn_params
477
+ rescue ThreadError
478
+ break
479
+ rescue StandardError => e
480
+ errors << e
481
+ break
482
+ end
483
+ end
484
+ end.each(&:join)
485
+ raise errors.pop unless errors.empty?
486
+ context_updates.each_with_index{|value,index|run.context=value if context_was_set[index]};next_turn_updates.each{|value|next_turn=value if value}
487
+ run.messages.concat(messages)
488
+ unless pending.empty?;run.status="waiting_for_human";run.next_turn_params=next_turn;run.pause=HumanPause.new(reason:"Pending tool calls require input",payload:pending,requested_at:now_iso,kind:pending.any?{|item|item.kind=="approval"}?"tool_approval":pending.first.kind,pending_tool_calls:pending);step.status="checkpointed";emit(on_event,"run.waiting_for_human",run,step_index:step_index,pause:run.pause.to_h);return RunResult.new(run:run,steps:steps,output:nil,messages:run.messages,usage:run.usage);end
489
+ @definition.stop_when.each do|condition|;reason=condition.call(step_count:run.step_count,usage:run.usage,tool_calls:step.tool_calls,finish_reason:response.finish_reason,elapsed_ms:(Process.clock_gettime(Process::CLOCK_MONOTONIC)-loop_started)*1000);if reason;run.status="stopped";run.stop_reason=reason.to_s;run.result=response.message.content;return RunResult.new(run:run,steps:steps,output:run.result,messages:run.messages,usage:run.usage);end;end
490
+ step.status = "checkpointed"
491
+ emit(on_event, "checkpoint.saved", run, step_index: step_index)
492
+ end
493
+
494
+ run.status = "failed"
495
+ run.error = "Max steps exceeded (#{effective_max_steps})"
496
+ emit(on_event, "run.failed", run, error: run.error)
497
+ raise run.error
498
+ end
499
+
500
+ def preset_alias(value)
501
+ normalized = value.to_s.strip.sub(/\A@+/, "")
502
+ normalized.empty? ? nil : "@#{normalized}"
503
+ end
504
+
505
+ def validate(schema,value,label)
506
+ return value unless schema.respond_to?(:call)
507
+ schema.call(value)
508
+ rescue StandardError=>e
509
+ raise ArgumentError,"Invalid #{label}: #{e.message}"
510
+ end
511
+
512
+ def normalized_usage(response)
513
+ raw=response.usage||{};input=(raw["input_tokens"]||raw[:input_tokens]||raw["prompt_tokens"]||0).to_i;output=(raw["output_tokens"]||raw[:output_tokens]||raw["completion_tokens"]||0).to_i
514
+ {input_tokens:input,output_tokens:output,cached_tokens:(raw["cached_tokens"]||raw[:cached_tokens]||0).to_i,total_tokens:(raw["total_tokens"]||raw[:total_tokens]||input+output).to_i,cost:(response.cost||0).to_f}
515
+ end
516
+
517
+ def now_iso
518
+ Time.now.utc.iso8601(6)
519
+ end
520
+
521
+ def emit(handler, type, run, details = {})
522
+ handler&.call({ type: type, run_id: run.id, agent_id: run.agent_id, timestamp: now_iso, status: run.status }.merge(details))
523
+ end
524
+
525
+ def capture_devtools(result, operation, started_at, config, error = nil, run_id = nil)
526
+ enabled = config ? config.enabled : ENV["PHASEO_DEVTOOLS"] == "true"
527
+ return unless enabled
528
+ directory = config&.directory || ENV["PHASEO_DEVTOOLS_DIR"] || ".phaseo-devtools"
529
+ %w[images audio video].each { |kind| FileUtils.mkdir_p(File.join(directory, "assets", kind)) }
530
+ metadata_path = File.join(directory, "metadata.json")
531
+ File.write(metadata_path, JSON.pretty_generate(session_id: SecureRandom.uuid, started_at: (started_at.to_f * 1000).to_i, sdk: "ruby")) unless File.exist?(metadata_path)
532
+ record = result&.run
533
+ entry = {
534
+ id: record&.id || run_id || SecureRandom.uuid, type: operation, timestamp: (started_at.to_f * 1000).to_i,
535
+ request: { agent_id: @definition.id, tool_count: @definition.tools.length },
536
+ response: result ? deep_hash(result) : nil, error: error ? { message: error.message } : nil,
537
+ metadata: { sdk: "ruby", agent_id: @definition.id, run_id: record&.id || run_id, run_status: record&.status }
538
+ }
539
+ File.open(File.join(directory, "generations.jsonl"), "a") { |file| file.puts(JSON.generate(entry)) }
540
+ end
541
+
542
+ def deep_hash(value)
543
+ return value.to_h.transform_values { |item| deep_hash(item) } if value.is_a?(Struct)
544
+ return value.map { |item| deep_hash(item) } if value.is_a?(Array)
545
+ return value.transform_values { |item| deep_hash(item) } if value.is_a?(Hash)
546
+ value
547
+ end
548
+ end
549
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: phaseo_agent_sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Phaseo
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: phaseo_sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.1.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '2.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.1.0
33
+ description:
34
+ email:
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - README.md
40
+ - lib/phaseo_agent_sdk.rb
41
+ homepage: https://phaseo.app
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ source_code_uri: https://github.com/phaseoteam/Phaseo/tree/main/packages/sdk/agent-sdk-ruby
46
+ documentation_uri: https://docs.phaseo.app/v1/sdk-reference/ruby/agent-sdk
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.4.19
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Ruby agent SDK for Phaseo Gateway
66
+ test_files: []