turnkit 0.2.10 → 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/turnkit/skill.rb CHANGED
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
3
5
  module TurnKit
4
6
  class Skill
5
7
  attr_reader :key, :name, :description, :content
6
8
 
7
9
  def self.from_file(path, key: nil, name: nil, description: "")
8
- content = File.read(path)
10
+ content, metadata = parse_file(File.read(path))
9
11
  base = File.basename(path, File.extname(path))
10
- new(key: key || base, name: name || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description, content: content)
12
+ new(key: key || base, name: name || metadata["name"] || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description.to_s.empty? ? metadata["description"].to_s : description, content: content)
11
13
  end
12
14
 
13
15
  def self.from_directory(path, pattern: "*.md")
@@ -23,5 +25,17 @@ module TurnKit
23
25
  raise ArgumentError, "name is required" if @name.empty?
24
26
  raise ArgumentError, "content is required" if @content.empty?
25
27
  end
28
+
29
+ def self.parse_file(content)
30
+ text = content.to_s
31
+ return [ text, {} ] unless text.start_with?("---\n")
32
+
33
+ _, frontmatter, body = text.split(/^---\s*$/, 3)
34
+ return [ text, {} ] unless body
35
+
36
+ [ body.sub(/\A\n/, ""), YAML.safe_load(frontmatter, permitted_classes: [ Symbol ], aliases: false) || {} ]
37
+ rescue Psych::SyntaxError
38
+ [ text, {} ]
39
+ end
26
40
  end
27
41
  end
data/lib/turnkit/store.rb CHANGED
@@ -12,6 +12,12 @@ module TurnKit
12
12
  def create_turn(_attributes) = raise(NotImplementedError)
13
13
  def load_turn(_id) = raise(NotImplementedError)
14
14
  def update_turn(_id, _attributes) = raise(NotImplementedError)
15
+ def claim_turn(id, from: "pending", to: "running", **attributes)
16
+ turn = load_turn(id)
17
+ return nil unless turn["status"] == from
18
+
19
+ update_turn(id, attributes.merge(status: to))
20
+ end
15
21
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
16
22
 
17
23
  def create_tool_execution(_attributes) = raise(NotImplementedError)
@@ -38,7 +38,6 @@ module TurnKit
38
38
  kind: message.fetch("kind"),
39
39
  sequence: message.fetch("sequence"),
40
40
  content: message.fetch("content"),
41
- text: message.fetch("text"),
42
41
  tool_execution_uid: message["tool_execution_id"],
43
42
  provider_message_id: message["provider_message_id"],
44
43
  metadata: message.fetch("metadata")
@@ -93,6 +92,15 @@ module TurnKit
93
92
  turn_hash(record)
94
93
  end
95
94
 
95
+ def claim_turn(id, from: "pending", to: "running", **attributes)
96
+ attrs = Record.turn_update(attributes.merge(status: to))
97
+ attrs.delete("output_data") unless turn_has_attribute?("output_data")
98
+ affected = turn_class.where(uid: id, status: from).update_all(attrs.merge(updated_at: Clock.now))
99
+ return nil if affected.zero?
100
+
101
+ load_turn(id)
102
+ end
103
+
96
104
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
97
105
  scope = turn_class.all
98
106
  scope = scope.where(root_turn_uid: root_turn_id) if root_turn_id
@@ -185,7 +193,7 @@ module TurnKit
185
193
  {
186
194
  "id" => record.uid, "conversation_id" => record.conversation_uid, "turn_id" => record.turn_uid,
187
195
  "role" => record.role, "kind" => record.kind, "sequence" => record.sequence, "content" => record.content,
188
- "text" => record.text, "tool_execution_id" => record.tool_execution_uid,
196
+ "tool_execution_id" => record.tool_execution_uid,
189
197
  "provider_message_id" => record.provider_message_id, "metadata" => record.metadata || {}, "created_at" => record.created_at
190
198
  }
191
199
  end
@@ -42,7 +42,8 @@ module TurnKit
42
42
  agent: sub_agent,
43
43
  on_event: parent_turn.agent.effective_on_event
44
44
  )
45
- { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data }.compact
45
+ error = child.store.load_turn(child.id)["error"] if child.failed?
46
+ { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data, "error" => error }.compact
46
47
  end
47
48
  end
48
49
  end
@@ -194,7 +194,7 @@ module TurnKit
194
194
 
195
195
  tagged(
196
196
  "skills_available",
197
- "Load or follow a skill when the task matches its description.\n\n#{entries.join("\n")}"
197
+ "These skills are listed but not loaded. When a task matches a skill description, call load_skill with the skill key before relying on it.\n\n#{entries.join("\n")}"
198
198
  )
199
199
  end
200
200
 
data/lib/turnkit/tool.rb CHANGED
@@ -106,7 +106,7 @@ module TurnKit
106
106
  next
107
107
  end
108
108
 
109
- validate_value!(value, param)
109
+ SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: name)
110
110
  normalized[name] = value
111
111
  end
112
112
  normalized
@@ -169,26 +169,7 @@ module TurnKit
169
169
  end
170
170
 
171
171
  def validate_value!(value, param)
172
- return if value.nil? && !param.fetch(:required)
173
-
174
- case param.fetch(:type)
175
- when :string, :enum
176
- raise ToolValidationError, "#{param.fetch(:name)} must be a string" unless value.is_a?(String)
177
- when :integer
178
- raise ToolValidationError, "#{param.fetch(:name)} must be an integer" unless value.is_a?(Integer)
179
- when :number
180
- raise ToolValidationError, "#{param.fetch(:name)} must be a number" unless value.is_a?(Numeric)
181
- when :boolean
182
- raise ToolValidationError, "#{param.fetch(:name)} must be a boolean" unless value == true || value == false
183
- when :array
184
- raise ToolValidationError, "#{param.fetch(:name)} must be an array" unless value.is_a?(Array)
185
- when :object
186
- raise ToolValidationError, "#{param.fetch(:name)} must be an object" unless value.is_a?(Hash)
187
- end
188
-
189
- if param[:enum] && !Array(param[:enum]).include?(value)
190
- raise ToolValidationError, "#{param.fetch(:name)} must be one of: #{Array(param[:enum]).join(", ")}"
191
- end
172
+ SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: param.fetch(:name))
192
173
  end
193
174
 
194
175
  def accepts_turnkit_context?(instance)
@@ -4,10 +4,10 @@ module TurnKit
4
4
  class ToolCall
5
5
  attr_reader :id, :name, :arguments, :arguments_error
6
6
 
7
- def initialize(id:, name:, arguments: {})
7
+ def initialize(id:, name:, arguments: {}, arguments_error: nil)
8
8
  @id = id.to_s
9
9
  @name = name.to_s
10
- @arguments_error = nil
10
+ @arguments_error = arguments_error
11
11
  @arguments = normalize_arguments(arguments)
12
12
  end
13
13
 
@@ -23,7 +23,7 @@ module TurnKit
23
23
  {}
24
24
  end
25
25
  rescue JSON::ParserError
26
- @arguments_error = "invalid JSON arguments"
26
+ @arguments_error ||= "invalid JSON arguments"
27
27
  {}
28
28
  end
29
29
  end
@@ -7,9 +7,12 @@ module TurnKit
7
7
  end
8
8
 
9
9
  def dispatch(tool_calls)
10
- tool_calls.each do |tool_call|
10
+ tool_calls.each_with_index do |tool_call, index|
11
11
  execution = run(tool_call)
12
- return execution if execution.completed? && tool_for(tool_call.name)&.ends_turn?
12
+ if execution.completed? && tool_for(tool_call.name)&.ends_turn?
13
+ skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
14
+ return execution
15
+ end
13
16
  end
14
17
  nil
15
18
  end
@@ -24,13 +27,7 @@ module TurnKit
24
27
 
25
28
  def run(tool_call)
26
29
  execution = ToolExecution.new(create_execution(tool_call))
27
-
28
- begin
29
- turn.budget.count_tool_execution!(tool_call.name)
30
- rescue BudgetError => error
31
- finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
32
- raise
33
- end
30
+ heartbeat!
34
31
 
35
32
  tool = tool_for(tool_call.name)
36
33
 
@@ -42,9 +39,19 @@ module TurnKit
42
39
  return finish_error(execution, tool_call, tool_call.arguments_error)
43
40
  end
44
41
 
42
+ begin
43
+ turn.budget.count_tool_execution!(tool_call.name)
44
+ rescue BudgetError => error
45
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
46
+ raise
47
+ end
48
+
45
49
  context = ToolContext.new(turn: turn, execution: execution)
46
50
  payload = begin
47
51
  normalize_payload(call_tool(tool, tool_call.arguments, context: context))
52
+ rescue BudgetError => error
53
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
54
+ raise
48
55
  rescue StandardError => error
49
56
  return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
50
57
  end
@@ -63,32 +70,50 @@ module TurnKit
63
70
  end
64
71
 
65
72
  def finish_success(execution, tool_call, payload)
73
+ json = payload.to_json
66
74
  attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
67
- append_result(execution, tool_call, payload)
68
- turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: payload.to_json.length)
75
+ append_result(execution, tool_call, payload, json: json, error: false)
76
+ heartbeat!
77
+ turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
69
78
  ToolExecution.new(attrs)
70
79
  end
71
80
 
72
81
  def finish_error(execution, tool_call, message, details: nil)
73
82
  error = { "message" => message.to_s, "details" => details }.compact
83
+ json = error.to_json
74
84
  attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
75
- append_result(execution, tool_call, error)
76
- turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: error.to_json.length)
85
+ append_result(execution, tool_call, error, json: json, error: true)
86
+ heartbeat!
87
+ turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
77
88
  ToolExecution.new(attrs)
78
89
  end
79
90
 
80
- def append_result(execution, tool_call, payload)
91
+ def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
81
92
  message = turn.conversation.append_message(
82
93
  role: "tool",
83
94
  kind: "tool_result",
84
- text: payload.to_json,
95
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
85
96
  turn_id: turn.id,
86
97
  tool_execution_id: execution.id,
87
- metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
98
+ metadata: { "tool_name" => tool_call.name }
88
99
  )
89
100
  turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
90
101
  end
91
102
 
103
+ def skip_remaining(calls, terminal:)
104
+ calls.each do |call|
105
+ payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
106
+ execution = ToolExecution.new(create_execution(call))
107
+ attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
108
+ append_result(ToolExecution.new(attrs), call, payload)
109
+ turn.emit("tool_call.skipped", id: call.id, name: call.name)
110
+ end
111
+ end
112
+
113
+ def heartbeat!
114
+ turn.send(:heartbeat!)
115
+ end
116
+
92
117
  def tool_for(name)
93
118
  turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
94
119
  end
data/lib/turnkit/turn.rb CHANGED
@@ -36,12 +36,18 @@ module TurnKit
36
36
  @on_event = block if block
37
37
  return self unless status == "pending"
38
38
 
39
- update!(status: "running", started_at: Clock.now, heartbeat_at: Clock.now)
39
+ claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
40
+ return self unless claimed
41
+
42
+ @record = claimed
43
+ @started_at = @record["started_at"]
40
44
  emit("turn.started", status: status, model: model)
41
45
  agent.effective_client.validate!(model: model)
46
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
47
+ revisions_used = 0
42
48
  loop do
43
49
  budget.check!(depth: depth)
44
- budget.count_iteration!
50
+ count_iteration!
45
51
  TurnKit::Compaction.maybe_compact!(self)
46
52
 
47
53
  request = model_request
@@ -58,13 +64,24 @@ module TurnKit
58
64
  runner = ToolRunner.new(self)
59
65
  terminal = runner.dispatch(result.tool_calls)
60
66
  if terminal
61
- complete_from_terminal_tool(runner, terminal)
62
- break
67
+ candidate = append_terminal_completion(runner, terminal)
68
+ else
69
+ next
63
70
  end
64
71
  else
65
- complete_with_output(result.text, output_data: result.output_data)
66
- break
72
+ candidate = result.text
67
73
  end
74
+
75
+ audit = check_policy(candidate, output_data: result.output_data)
76
+ if should_revise?(audit, revisions_used)
77
+ revisions_used += 1
78
+ append_revision_message(audit, attempt: revisions_used, terminal_tool_name: terminal&.tool_name)
79
+ emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used)
80
+ next
81
+ end
82
+
83
+ complete_with_output(candidate, output_data: result.output_data, audit: audit)
84
+ break
68
85
  end
69
86
  reload
70
87
  self
@@ -95,8 +112,8 @@ module TurnKit
95
112
  @record["output_data"]
96
113
  end
97
114
 
98
- def output_audit
99
- (@record["options"] || {})["output_audit"]
115
+ def policy_audit
116
+ (@record["options"] || {})["policy_audit"]
100
117
  end
101
118
 
102
119
  def usage
@@ -150,6 +167,59 @@ module TurnKit
150
167
  result
151
168
  end
152
169
 
170
+ def paint(prompt, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: {}, client: nil)
171
+ claimed_standalone = false
172
+ case status
173
+ when "pending"
174
+ claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
175
+ raise Error, "turn is already running" unless claimed
176
+
177
+ @record = claimed
178
+ @started_at = @record["started_at"]
179
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
180
+ claimed_standalone = true
181
+ emit("turn.started", status: status, model: model)
182
+ when "running"
183
+ # Image tools call this while their parent turn is running.
184
+ else
185
+ raise Error, "cannot paint for #{status} turn"
186
+ end
187
+
188
+ image_client = client || agent.effective_client
189
+ request = {
190
+ prompt: prompt,
191
+ model: model,
192
+ provider: provider,
193
+ size: size,
194
+ assume_model_exists: assume_model_exists,
195
+ input_images: input_images,
196
+ mask: mask,
197
+ params: params || {},
198
+ metadata: { turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
199
+ }
200
+
201
+ image_client.validate!(model: model)
202
+ emit("image.requested", request.except(:input_images, :mask))
203
+ result = call_image_client(image_client, request)
204
+ result_cost = Cost.from_usage(result.usage, model: result.model || model)
205
+ add_usage!(result.usage, cost: result_cost)
206
+ budget.add_cost!(result_cost.total)
207
+ image = result.images.first
208
+ raise Error, "image client returned no image" unless image
209
+ raise Error, "image client returned image without url or data" if image.url.to_s.empty? && image.data.to_s.empty?
210
+
211
+ persist_image_message(image)
212
+ emit("image.completed", image: image.to_h, model: image.model || model, provider: image.provider || provider&.to_s, mime_type: image.mime_type, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
213
+ complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if claimed_standalone
214
+ image
215
+ rescue StandardError => error
216
+ if claimed_standalone
217
+ update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
218
+ emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
219
+ end
220
+ raise
221
+ end
222
+
153
223
  private
154
224
  def model_request
155
225
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
@@ -197,6 +267,16 @@ module TurnKit
197
267
  end
198
268
  end
199
269
 
270
+ def call_image_client(client, request)
271
+ kwargs = request.merge(on_event: ->(event) { emit_event(event) })
272
+ accepted = client.method(:paint).parameters.filter_map do |kind, name|
273
+ return client.paint(**kwargs) if kind == :keyrest
274
+
275
+ name if %i[key keyreq].include?(kind)
276
+ end
277
+ client.paint(**kwargs.slice(*accepted))
278
+ end
279
+
200
280
  def llm_messages
201
281
  MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
202
282
  end
@@ -248,36 +328,43 @@ module TurnKit
248
328
  message = conversation.append_message(
249
329
  role: "assistant",
250
330
  kind: "tool_call",
251
- text: result.text,
331
+ content: result.parts,
252
332
  turn_id: id,
253
- metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
333
+ metadata: {}
254
334
  )
255
335
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
256
336
  result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
337
+ elsif result.image?
338
+ message = conversation.append_message(role: "assistant", kind: "image", content: result.images.map { |image| image.to_h.merge("type" => "image") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
339
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
257
340
  else
258
341
  message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
259
342
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
260
343
  end
261
344
  end
262
345
 
263
- def complete_from_terminal_tool(runner, execution)
346
+ def persist_image_message(image)
347
+ message = conversation.append_message(role: "assistant", kind: "image", content: [ image.to_h.merge("type" => "image") ], turn_id: id, metadata: { "output_data" => { "type" => "image", "images" => [ image.to_h ] } })
348
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
349
+ end
350
+
351
+ def append_terminal_completion(runner, execution)
264
352
  message = runner.completion_message(execution)
265
353
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
266
354
  emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
267
- complete_with_output(message)
355
+ message
268
356
  end
269
357
 
270
- def complete_with_output(text, output_data: nil)
271
- audit = audit_output(text, output_data: output_data)
358
+ def complete_with_output(text, output_data: nil, audit: nil)
272
359
  attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
273
- if audit && !audit.clean? && agent.output_audit_mode == :fail
360
+ if audit && !audit.clean? && agent.output_policy_mode == :fail
274
361
  attrs[:status] = "failed"
275
- attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "output_audit" => audit.to_h }
362
+ attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "policy_audit" => audit.to_h }
276
363
  else
277
364
  attrs[:status] = "completed"
278
365
  end
279
366
  update!(attrs)
280
- persist_output_audit(audit) if audit
367
+ persist_policy_audit(audit) if audit
281
368
 
282
369
  if failed?
283
370
  emit("turn.failed", error: @record["error"])
@@ -286,18 +373,48 @@ module TurnKit
286
373
  end
287
374
  end
288
375
 
289
- def audit_output(text, output_data: nil)
290
- constraints = agent.effective_output_audit
376
+ def check_policy(text, output_data: nil)
377
+ constraints = agent.effective_output_policy
291
378
  return nil if constraints.empty?
292
379
 
293
380
  output = output_data.nil? ? text : output_data
294
- TurnKit.audit_output(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
381
+ TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
295
382
  end
296
383
 
297
- def persist_output_audit(audit)
298
- options = (@record["options"] || {}).merge("output_audit" => audit.to_h)
384
+ def persist_policy_audit(audit)
385
+ options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
299
386
  update!(options: options)
300
- emit("output_audit.completed", clean: audit.clean?, violation_count: audit.violations.length)
387
+ emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
388
+ end
389
+
390
+ def should_revise?(audit, revisions_used)
391
+ audit && !audit.clean? && revisions_used < agent.output_retries
392
+ end
393
+
394
+ def append_revision_message(audit, attempt:, terminal_tool_name: nil)
395
+ text = <<~TEXT.strip
396
+ The previous output failed policy checks.
397
+
398
+ Revise the previous output. Do not introduce new claims.
399
+ Do not deviate from the skill or policy below.
400
+
401
+ #{revision_policy_blocks}
402
+
403
+ Violations:
404
+ #{audit.violations.each_with_index.map { |violation, index| "#{index + 1}. #{violation.rule}: #{violation.message}" }.join("\n")}
405
+ #{terminal_tool_name ? "\nResubmit via #{terminal_tool_name}." : ""}
406
+ TEXT
407
+ message = conversation.append_message(role: "user", kind: "text", text: text, turn_id: id, metadata: { "source" => "output_policy", "attempt" => attempt })
408
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
409
+ end
410
+
411
+ def revision_policy_blocks
412
+ agent.effective_output_policy.filter_map do |policy|
413
+ next unless policy.respond_to?(:content)
414
+
415
+ key = policy.respond_to?(:name) ? policy.name : "output_policy"
416
+ "<skill key=\"#{key}\">\n#{policy.content}\n</skill>"
417
+ end.join("\n\n")
301
418
  end
302
419
 
303
420
  def add_usage!(usage, cost: nil)
@@ -316,6 +433,27 @@ module TurnKit
316
433
  update!(attributes)
317
434
  end
318
435
 
436
+ def count_iteration!
437
+ budget.count_iteration!
438
+ options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
439
+ update!(options: options)
440
+ end
441
+
442
+ def heartbeat!
443
+ update!(heartbeat_at: Clock.now)
444
+ end
445
+
446
+ def budget_limits
447
+ {
448
+ max_iterations: agent.max_iterations || TurnKit.max_iterations,
449
+ timeout: agent.timeout || TurnKit.timeout,
450
+ max_depth: agent.max_depth || TurnKit.max_depth,
451
+ max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
452
+ max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
453
+ max_spend: agent.max_spend || TurnKit.max_spend
454
+ }
455
+ end
456
+
319
457
  def aggregate_cost(current, cost)
320
458
  return cost unless current
321
459
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.10"
4
+ VERSION = "0.4.0"
5
5
  end