turnkit 0.2.9 → 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.
@@ -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,6 +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 policy_audit = turn.policy_audit
18
+ def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
17
19
  def usage = Usage.from_records(turn_records)
18
20
  def cost = Cost.from_records(turn_records)
19
21
  def steps = turn_records.length
@@ -25,9 +27,8 @@ module TurnKit
25
27
  end
26
28
 
27
29
  def messages
28
- turn_records.flat_map do |record|
29
- conversation = turn.store.load_conversation(record.fetch("conversation_id"))
30
- 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) }
31
32
  end
32
33
  end
33
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
@@ -23,9 +26,10 @@ module TurnKit
23
26
  attr_reader :turn
24
27
 
25
28
  def run(tool_call)
26
- turn.budget.count_tool_execution!
27
- tool = tool_for(tool_call.name)
28
29
  execution = ToolExecution.new(create_execution(tool_call))
30
+ heartbeat!
31
+
32
+ tool = tool_for(tool_call.name)
29
33
 
30
34
  unless tool
31
35
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
@@ -35,6 +39,13 @@ module TurnKit
35
39
  return finish_error(execution, tool_call, tool_call.arguments_error)
36
40
  end
37
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
+
38
49
  context = ToolContext.new(turn: turn, execution: execution)
39
50
  payload = begin
40
51
  normalize_payload(call_tool(tool, tool_call.arguments, context: context))
@@ -56,32 +67,50 @@ module TurnKit
56
67
  end
57
68
 
58
69
  def finish_success(execution, tool_call, payload)
70
+ json = payload.to_json
59
71
  attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
60
- append_result(execution, tool_call, payload)
61
- turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
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)
62
75
  ToolExecution.new(attrs)
63
76
  end
64
77
 
65
78
  def finish_error(execution, tool_call, message, details: nil)
66
79
  error = { "message" => message.to_s, "details" => details }.compact
80
+ json = error.to_json
67
81
  attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
68
- append_result(execution, tool_call, error)
69
- turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
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)
70
85
  ToolExecution.new(attrs)
71
86
  end
72
87
 
73
- def append_result(execution, tool_call, payload)
88
+ def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
74
89
  message = turn.conversation.append_message(
75
90
  role: "tool",
76
91
  kind: "tool_result",
77
- text: payload.to_json,
92
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
78
93
  turn_id: turn.id,
79
94
  tool_execution_id: execution.id,
80
- metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
95
+ metadata: { "tool_name" => tool_call.name }
81
96
  )
82
97
  turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
83
98
  end
84
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
+
85
114
  def tool_for(name)
86
115
  turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
87
116
  end
data/lib/turnkit/turn.rb CHANGED
@@ -36,36 +36,52 @@ 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
48
- emit("model.requested", model: request.model, tool_names: request.tool_names)
54
+ emit_model_requested("model.requested", request)
49
55
  result = call_client(request)
50
- emit("model.completed", model: result.model || model, tool_call_count: result.tool_calls.length)
51
56
  result_cost = Cost.from_usage(result.usage, model: result.model || model)
52
57
 
53
- budget.add_cost!(result_cost.total)
54
58
  add_usage!(result.usage, cost: result_cost)
59
+ emit_model_completed("model.completed", result, result_cost, model: model)
60
+ budget.add_cost!(result_cost.total)
55
61
  persist_assistant_message(result)
56
62
 
57
63
  if result.tool_calls?
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
- update!(status: "completed", output_text: result.text, output_data: result.output_data, completed_at: Clock.now)
66
- emit("turn.completed", status: status, output_text: result.text)
67
- break
72
+ candidate = result.text
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
68
81
  end
82
+
83
+ complete_with_output(candidate, output_data: result.output_data, audit: audit)
84
+ break
69
85
  end
70
86
  reload
71
87
  self
@@ -96,6 +112,10 @@ module TurnKit
96
112
  @record["output_data"]
97
113
  end
98
114
 
115
+ def policy_audit
116
+ (@record["options"] || {})["policy_audit"]
117
+ end
118
+
99
119
  def usage
100
120
  Usage.from_h(@record["usage"] || {})
101
121
  end
@@ -125,6 +145,28 @@ module TurnKit
125
145
  emit_event(Event.new(type: type, turn_id: id, conversation_id: conversation.id, payload: payload))
126
146
  end
127
147
 
148
+ def internal_model_call(model:, messages:, instructions:, tools: [], thinking: nil, output_schema: nil, metadata: {}, purpose:, client: nil)
149
+ request = ModelRequest.new(
150
+ model: model,
151
+ messages: messages,
152
+ tools: tools,
153
+ instructions: instructions,
154
+ thinking: thinking,
155
+ output_schema: output_schema,
156
+ metadata: { purpose: purpose.to_s, turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
157
+ )
158
+ model_client = client || agent.effective_client
159
+ model_client.validate!(model: request.model)
160
+
161
+ emit_model_requested("#{purpose}.model.requested", request)
162
+ result = call_client(request, client: model_client)
163
+ result_cost = Cost.from_usage(result.usage, model: result.model || request.model)
164
+ add_usage!(result.usage, cost: result_cost)
165
+ emit_model_completed("#{purpose}.model.completed", result, result_cost, model: request.model)
166
+ budget.add_cost!(result_cost.total)
167
+ result
168
+ end
169
+
128
170
  private
129
171
  def model_request
130
172
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
@@ -148,7 +190,7 @@ module TurnKit
148
190
  )
149
191
  end
150
192
 
151
- def call_client(request)
193
+ def call_client(request, client: agent.effective_client)
152
194
  kwargs = {
153
195
  model: request.model,
154
196
  messages: request.messages,
@@ -159,9 +201,9 @@ module TurnKit
159
201
  metadata: request.metadata,
160
202
  on_event: ->(event) { emit_event(event) }
161
203
  }
162
- accepted = chat_keyword_names(agent.effective_client)
204
+ accepted = chat_keyword_names(client)
163
205
  kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
164
- agent.effective_client.chat(**kwargs)
206
+ client.chat(**kwargs)
165
207
  end
166
208
 
167
209
  def chat_keyword_names(client)
@@ -176,6 +218,26 @@ module TurnKit
176
218
  MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
177
219
  end
178
220
 
221
+ def emit_model_requested(type, request)
222
+ emit(
223
+ type,
224
+ model: request.model,
225
+ tool_names: request.tool_names,
226
+ message_count: request.messages.length,
227
+ prompt: request.report
228
+ )
229
+ end
230
+
231
+ def emit_model_completed(type, result, cost, model: self.model)
232
+ emit(
233
+ type,
234
+ model: result.model || model,
235
+ tool_call_count: result.tool_calls.length,
236
+ usage: result.usage.to_h,
237
+ cost: cost.to_h
238
+ )
239
+ end
240
+
179
241
  def thinking_from_options
180
242
  options = (@record["options"] || {}).transform_keys(&:to_s)
181
243
  return Agent.normalize_thinking(options["thinking"]) if options.key?("thinking")
@@ -203,9 +265,9 @@ module TurnKit
203
265
  message = conversation.append_message(
204
266
  role: "assistant",
205
267
  kind: "tool_call",
206
- text: result.text,
268
+ content: result.parts,
207
269
  turn_id: id,
208
- metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
270
+ metadata: {}
209
271
  )
210
272
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
211
273
  result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
@@ -215,12 +277,73 @@ module TurnKit
215
277
  end
216
278
  end
217
279
 
218
- def complete_from_terminal_tool(runner, execution)
280
+ def append_terminal_completion(runner, execution)
219
281
  message = runner.completion_message(execution)
220
282
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
221
283
  emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
222
- update!(status: "completed", output_text: message, completed_at: Clock.now)
223
- emit("turn.completed", status: status, output_text: message)
284
+ message
285
+ end
286
+
287
+ def complete_with_output(text, output_data: nil, audit: nil)
288
+ attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
289
+ if audit && !audit.clean? && agent.output_policy_mode == :fail
290
+ attrs[:status] = "failed"
291
+ attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "policy_audit" => audit.to_h }
292
+ else
293
+ attrs[:status] = "completed"
294
+ end
295
+ update!(attrs)
296
+ persist_policy_audit(audit) if audit
297
+
298
+ if failed?
299
+ emit("turn.failed", error: @record["error"])
300
+ else
301
+ emit("turn.completed", status: status, output_text: text)
302
+ end
303
+ end
304
+
305
+ def check_policy(text, output_data: nil)
306
+ constraints = agent.effective_output_policy
307
+ return nil if constraints.empty?
308
+
309
+ output = output_data.nil? ? text : output_data
310
+ TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
311
+ end
312
+
313
+ def persist_policy_audit(audit)
314
+ options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
315
+ update!(options: options)
316
+ emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
317
+ end
318
+
319
+ def should_revise?(audit, revisions_used)
320
+ audit && !audit.clean? && revisions_used < agent.output_retries
321
+ end
322
+
323
+ def append_revision_message(audit, attempt:, terminal_tool_name: nil)
324
+ text = <<~TEXT.strip
325
+ The previous output failed policy checks.
326
+
327
+ Revise the previous output. Do not introduce new claims.
328
+ Do not deviate from the skill or policy below.
329
+
330
+ #{revision_policy_blocks}
331
+
332
+ Violations:
333
+ #{audit.violations.each_with_index.map { |violation, index| "#{index + 1}. #{violation.rule}: #{violation.message}" }.join("\n")}
334
+ #{terminal_tool_name ? "\nResubmit via #{terminal_tool_name}." : ""}
335
+ TEXT
336
+ message = conversation.append_message(role: "user", kind: "text", text: text, turn_id: id, metadata: { "source" => "output_policy", "attempt" => attempt })
337
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
338
+ end
339
+
340
+ def revision_policy_blocks
341
+ agent.effective_output_policy.filter_map do |policy|
342
+ next unless policy.respond_to?(:content)
343
+
344
+ key = policy.respond_to?(:name) ? policy.name : "output_policy"
345
+ "<skill key=\"#{key}\">\n#{policy.content}\n</skill>"
346
+ end.join("\n\n")
224
347
  end
225
348
 
226
349
  def add_usage!(usage, cost: nil)
@@ -239,6 +362,27 @@ module TurnKit
239
362
  update!(attributes)
240
363
  end
241
364
 
365
+ def count_iteration!
366
+ budget.count_iteration!
367
+ options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
368
+ update!(options: options)
369
+ end
370
+
371
+ def heartbeat!
372
+ update!(heartbeat_at: Clock.now)
373
+ end
374
+
375
+ def budget_limits
376
+ {
377
+ max_iterations: agent.max_iterations || TurnKit.max_iterations,
378
+ timeout: agent.timeout || TurnKit.timeout,
379
+ max_depth: agent.max_depth || TurnKit.max_depth,
380
+ max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
381
+ max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
382
+ max_spend: agent.max_spend || TurnKit.max_spend
383
+ }
384
+ end
385
+
242
386
  def aggregate_cost(current, cost)
243
387
  return cost unless current
244
388