turnkit 0.2.10 → 0.3.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.
@@ -83,6 +83,7 @@ module TurnKit
83
83
  - Keep every section.
84
84
  - Use terse bullets.
85
85
  - Preserve exact file paths, commands, error strings, IDs, and important values.
86
+ - In Tool Results To Remember, record which skill keys were loaded.
86
87
  - Do not invent facts.
87
88
  - Do not include secrets.
88
89
  - Do not include a greeting or preamble.
data/lib/turnkit/error.rb CHANGED
@@ -5,6 +5,7 @@ module TurnKit
5
5
  class BudgetError < Error; end
6
6
  class ConfigError < Error; end
7
7
  class CompactionError < Error; end
8
+ class InputError < Error; end
8
9
  class ModelAccessError < ConfigError; end
9
10
  class StoreError < Error; end
10
11
  class ToolError < Error; end
@@ -50,7 +50,6 @@ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
50
50
  t.string :kind, null: false
51
51
  t.integer :sequence, null: false
52
52
  t.json :content, null: false, default: []
53
- t.text :text
54
53
  t.string :tool_execution_uid
55
54
  t.string :provider_message_id
56
55
  t.json :metadata, null: false, default: {}
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class LoadSkillTool < Tool
5
+ tool_name "load_skill"
6
+ description "Load the full instructions for an available skill by key."
7
+ parameter :key, :string, required: true, description: "Skill key from <skills_available>."
8
+
9
+ def self.for(skills)
10
+ Class.new(self) do
11
+ tool_name "load_skill"
12
+ @skills = Array(skills).to_h { |skill| [ skill.key, skill ] }
13
+ class << self
14
+ attr_reader :skills
15
+ end
16
+ end
17
+ end
18
+
19
+ def call(key:, context:)
20
+ skill = self.class.skills[key]
21
+ unless skill
22
+ available = self.class.skills.keys.join(", ")
23
+ raise ToolError, "unknown skill: #{key}. Available: #{available}"
24
+ end
25
+
26
+ { "key" => skill.key, "name" => skill.name, "content" => skill.content }
27
+ end
28
+ end
29
+ end
@@ -68,6 +68,17 @@ module TurnKit
68
68
  end
69
69
  end
70
70
 
71
+ def claim_turn(id, from: "pending", to: "running", **attributes)
72
+ attrs = Record.turn_update(attributes.merge(status: to))
73
+ @mutex.synchronize do
74
+ record = @turns.fetch(id)
75
+ return nil unless record["status"] == from
76
+
77
+ record.merge!(attrs.merge("updated_at" => Clock.now))
78
+ duplicate(record)
79
+ end
80
+ end
81
+
71
82
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
72
83
  @mutex.synchronize do
73
84
  rows = @turns.values
@@ -6,7 +6,7 @@ module TurnKit
6
6
  KINDS = %w[text tool_call tool_result context_summary].freeze
7
7
 
8
8
  attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
9
- attr_reader :content, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
9
+ attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
10
10
 
11
11
  def initialize(attributes = {})
12
12
  attrs = stringify(attributes)
@@ -16,8 +16,7 @@ module TurnKit
16
16
  @role = attrs.fetch("role").to_s
17
17
  @kind = attrs.fetch("kind", "text").to_s
18
18
  @sequence = attrs.fetch("sequence").to_i
19
- @content = normalize_content(attrs["content"] || attrs["text"])
20
- @text = attrs["text"] || extract_text(@content)
19
+ @content = normalize_content(attrs["content"].nil? ? attrs["text"] : attrs["content"])
21
20
  @tool_execution_id = attrs["tool_execution_id"]
22
21
  @provider_message_id = attrs["provider_message_id"]
23
22
  @metadata = attrs["metadata"] || {}
@@ -35,7 +34,6 @@ module TurnKit
35
34
  "kind" => kind,
36
35
  "sequence" => sequence,
37
36
  "content" => content,
38
- "text" => text,
39
37
  "tool_execution_id" => tool_execution_id,
40
38
  "provider_message_id" => provider_message_id,
41
39
  "metadata" => metadata,
@@ -59,6 +57,13 @@ module TurnKit
59
57
  kind == "context_summary"
60
58
  end
61
59
 
60
+ def text
61
+ content.filter_map do |part|
62
+ attrs = stringify(part)
63
+ attrs["text"] if attrs["type"] == "text"
64
+ end.join("\n")
65
+ end
66
+
62
67
  def compaction_metadata
63
68
  metadata.fetch("compaction", {})
64
69
  end
@@ -69,13 +74,15 @@ module TurnKit
69
74
  end
70
75
 
71
76
  def normalize_content(value)
72
- return value if value.is_a?(Array)
77
+ return Array(value).map { |part| normalize_part(part) } if value.is_a?(Array)
73
78
 
74
79
  [ { "type" => "text", "text" => value.to_s } ]
75
80
  end
76
81
 
77
- def extract_text(blocks)
78
- Array(blocks).filter_map { |block| block.is_a?(Hash) ? block["text"] || block[:text] : nil }.join("\n")
82
+ def normalize_part(part)
83
+ attrs = part.respond_to?(:to_h) ? part.to_h.transform_keys(&:to_s) : { "type" => "text", "text" => part.to_s }
84
+ attrs["type"] ||= "text"
85
+ attrs
79
86
  end
80
87
 
81
88
  def validate!
@@ -40,9 +40,10 @@ module TurnKit
40
40
  def to_h
41
41
  case message.kind
42
42
  when "tool_call"
43
- { role: :assistant, content: message.text, tool_calls: message.metadata.fetch("tool_calls", []) }
43
+ { role: :assistant, content: projected_content, tool_calls: tool_call_parts }
44
44
  when "tool_result"
45
- { role: :tool, content: message.text, tool_call_id: message.metadata["tool_call_id"] }
45
+ part = message.content.find { |candidate| candidate.fetch("type") == "tool_result" }
46
+ { role: :tool, content: part&.fetch("text", message.text) || message.text, tool_call_id: part&.fetch("tool_call_id", nil) }
46
47
  else
47
48
  { role: message.role.to_sym, content: message.text }
48
49
  end
@@ -50,5 +51,19 @@ module TurnKit
50
51
 
51
52
  private
52
53
  attr_reader :message
54
+
55
+ def projected_content
56
+ parts = message.content.reject { |part| %w[tool_call provider].include?(part.fetch("type")) }
57
+ ordered = parts.select { |part| part.fetch("type") == "thinking" } + parts.select { |part| part.fetch("type") == "text" }
58
+ ordered.filter_map { |part| part.fetch("text", nil) }.join("\n")
59
+ end
60
+
61
+ def tool_call_parts
62
+ message.content.filter_map do |part|
63
+ next unless part.fetch("type") == "tool_call"
64
+
65
+ { "id" => part.fetch("id"), "name" => part.fetch("name"), "arguments" => part["arguments"] || {} }
66
+ end
67
+ end
53
68
  end
54
69
  end
@@ -27,6 +27,10 @@ module TurnKit
27
27
  new(name: name || File.basename(path, File.extname(path)), content: File.read(path), **options)
28
28
  end
29
29
 
30
+ def self.from_skill(skill, **options)
31
+ new(name: skill.key, content: skill.content, **options)
32
+ end
33
+
30
34
  def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
31
35
  @name = name.to_s
32
36
  @content = content.to_s
@@ -78,6 +82,8 @@ module TurnKit
78
82
 
79
83
  Set approved to true only when the output satisfies the policy. For each violation, include a concise rule and message. Do not repair the output. Do not wrap the JSON in Markdown. Do not include commentary before or after the JSON.
80
84
 
85
+ The policy may be a skill; treat its output-facing rules as normative and ignore process steps that are not observable in the output.
86
+
81
87
  Policy:
82
88
  #{content}
83
89
  TEXT
@@ -2,19 +2,44 @@
2
2
 
3
3
  module TurnKit
4
4
  class Result
5
- attr_reader :text, :tool_calls, :usage, :model, :finish_reason, :output_data
5
+ attr_reader :parts, :usage, :model, :finish_reason, :output_data
6
6
 
7
- def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
8
- @text = text.to_s
9
- @tool_calls = Array(tool_calls)
7
+ def initialize(text: "", tool_calls: [], parts: nil, usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
8
+ @parts = parts ? normalize_parts(parts) : synthesize_parts(text: text, tool_calls: tool_calls)
10
9
  @usage = usage || Usage.new
11
10
  @model = model
12
11
  @finish_reason = finish_reason
13
12
  @output_data = output_data
14
13
  end
15
14
 
15
+ def text
16
+ parts.filter_map { |part| part["text"] if part["type"] == "text" }.join("\n")
17
+ end
18
+
19
+ def tool_calls
20
+ parts.filter_map do |part|
21
+ next unless part["type"] == "tool_call"
22
+
23
+ ToolCall.new(id: part.fetch("id"), name: part.fetch("name"), arguments: part["arguments"] || {}, arguments_error: part["arguments_error"])
24
+ end
25
+ end
26
+
16
27
  def tool_calls?
17
28
  tool_calls.any?
18
29
  end
30
+
31
+ private
32
+ def synthesize_parts(text:, tool_calls:)
33
+ parts = []
34
+ parts << { "type" => "text", "text" => text.to_s } unless text.to_s.empty?
35
+ Array(tool_calls).each do |call|
36
+ parts << { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments, "arguments_error" => call.arguments_error }.compact
37
+ end
38
+ parts
39
+ end
40
+
41
+ def normalize_parts(value)
42
+ Array(value).map { |part| part.to_h.transform_keys(&:to_s) }
43
+ end
19
44
  end
20
45
  end
data/lib/turnkit/run.rb CHANGED
@@ -14,8 +14,8 @@ module TurnKit
14
14
  def output = output_text
15
15
  def output_text = turn.output_text
16
16
  def output_data = turn.output_data
17
- def output_audit = turn.output_audit
18
- def output_audit_clean? = output_audit.nil? || output_audit.fetch("clean", false)
17
+ def policy_audit = turn.policy_audit
18
+ def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
19
19
  def usage = Usage.from_records(turn_records)
20
20
  def cost = Cost.from_records(turn_records)
21
21
  def steps = turn_records.length
@@ -27,9 +27,8 @@ module TurnKit
27
27
  end
28
28
 
29
29
  def messages
30
- turn_records.flat_map do |record|
31
- conversation = turn.store.load_conversation(record.fetch("conversation_id"))
32
- turn.store.list_messages(conversation.fetch("id"))
30
+ turn_records.map { |record| record.fetch("conversation_id") }.uniq.flat_map do |conversation_id|
31
+ turn.store.list_messages(conversation_id).map { |attrs| Message.new(attrs) }
33
32
  end
34
33
  end
35
34
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module SchemaCheck
5
+ module_function
6
+
7
+ def validate!(value, schema, error_class: ToolValidationError, label: "input")
8
+ schema = stringify_schema(schema || {})
9
+ type = schema["type"] || "object"
10
+ validate_type!(value, type, schema, error_class: error_class, label: label)
11
+ validate_enum!(value, schema, error_class: error_class, label: label)
12
+
13
+ if type == "object" && schema["properties"]
14
+ attrs = value.respond_to?(:to_h) ? value.to_h.transform_keys(&:to_s) : {}
15
+ required = Array(schema["required"]).map(&:to_s)
16
+ missing = required.reject { |name| attrs.key?(name) }
17
+ raise error_class, "#{label} missing required field#{missing.length == 1 ? "" : "s"}: #{missing.join(", ")}" if missing.any?
18
+
19
+ schema.fetch("properties", {}).each do |name, child_schema|
20
+ next unless attrs.key?(name)
21
+
22
+ validate!(attrs[name], child_schema, error_class: error_class, label: "#{label}.#{name}")
23
+ end
24
+ elsif type == "array" && schema["items"] && value.is_a?(Array)
25
+ value.each_with_index do |item, index|
26
+ validate!(item, schema["items"], error_class: error_class, label: "#{label}[#{index}]")
27
+ end
28
+ end
29
+
30
+ true
31
+ end
32
+
33
+ def stringify_schema(value)
34
+ case value
35
+ when Hash
36
+ value.transform_keys(&:to_s).transform_values { |nested| stringify_schema(nested) }
37
+ when Array
38
+ value.map { |nested| stringify_schema(nested) }
39
+ when Symbol
40
+ value.to_s
41
+ else
42
+ value
43
+ end
44
+ end
45
+
46
+ def validate_type!(value, type, schema, error_class:, label:)
47
+ return if value.nil? && !Array(schema["required"]).include?(label.to_s)
48
+
49
+ valid = case type.to_s
50
+ when "string" then value.is_a?(String)
51
+ when "integer" then value.is_a?(Integer)
52
+ when "number" then value.is_a?(Numeric)
53
+ when "boolean" then value == true || value == false
54
+ when "array" then value.is_a?(Array)
55
+ when "object" then value.is_a?(Hash)
56
+ else true
57
+ end
58
+ raise error_class, "#{label} must be a #{type}" unless valid
59
+ end
60
+
61
+ def validate_enum!(value, schema, error_class:, label:)
62
+ enum = schema["enum"]
63
+ return unless enum && !Array(enum).include?(value)
64
+
65
+ raise error_class, "#{label} must be one of: #{Array(enum).join(", ")}"
66
+ end
67
+ end
68
+ end
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,6 +39,13 @@ 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))
@@ -63,32 +67,50 @@ module TurnKit
63
67
  end
64
68
 
65
69
  def finish_success(execution, tool_call, payload)
70
+ json = payload.to_json
66
71
  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)
72
+ append_result(execution, tool_call, payload, json: json, error: false)
73
+ heartbeat!
74
+ turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
69
75
  ToolExecution.new(attrs)
70
76
  end
71
77
 
72
78
  def finish_error(execution, tool_call, message, details: nil)
73
79
  error = { "message" => message.to_s, "details" => details }.compact
80
+ json = error.to_json
74
81
  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)
82
+ append_result(execution, tool_call, error, json: json, error: true)
83
+ heartbeat!
84
+ turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
77
85
  ToolExecution.new(attrs)
78
86
  end
79
87
 
80
- def append_result(execution, tool_call, payload)
88
+ def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
81
89
  message = turn.conversation.append_message(
82
90
  role: "tool",
83
91
  kind: "tool_result",
84
- text: payload.to_json,
92
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
85
93
  turn_id: turn.id,
86
94
  tool_execution_id: execution.id,
87
- metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
95
+ metadata: { "tool_name" => tool_call.name }
88
96
  )
89
97
  turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
90
98
  end
91
99
 
100
+ def skip_remaining(calls, terminal:)
101
+ calls.each do |call|
102
+ payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
103
+ execution = ToolExecution.new(create_execution(call))
104
+ attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
105
+ append_result(ToolExecution.new(attrs), call, payload)
106
+ turn.emit("tool_call.skipped", id: call.id, name: call.name)
107
+ end
108
+ end
109
+
110
+ def heartbeat!
111
+ turn.send(:heartbeat!)
112
+ end
113
+
92
114
  def tool_for(name)
93
115
  turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
94
116
  end