turnkit 0.2.8 → 0.2.10

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/agent.rb CHANGED
@@ -3,13 +3,14 @@
3
3
  module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
- attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
6
+ attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions, :max_tool_executions_by_name
7
7
  attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
8
+ attr_reader :output_audit, :output_audit_mode, :output_policy_model
8
9
 
9
10
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
11
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
11
- max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil,
12
- output_schema: nil, on_event: nil)
12
+ max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
13
+ output_schema: nil, output_audit: nil, output_audit_mode: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, on_event: nil)
13
14
  @name = name.to_s
14
15
  @description = description.to_s
15
16
  @model = model
@@ -28,9 +29,13 @@ module TurnKit
28
29
  @cost_limit = cost_limit
29
30
  @max_depth = max_depth
30
31
  @max_tool_executions = max_tool_executions
32
+ @max_tool_executions_by_name = max_tool_executions_by_name
31
33
  @thinking = self.class.normalize_thinking(thinking)
32
34
  @compaction = compaction
33
35
  @output_schema = output_schema
36
+ @output_policy_model = output_policy_model
37
+ @output_audit = normalize_output_policy_options(output_audit: output_audit, output_policy: output_policy, output_policy_model: output_policy_model, output_policy_thinking: output_policy_thinking)
38
+ @output_audit_mode = normalize_output_policy_mode(output_audit_mode: output_audit_mode, output_policy_mode: output_policy_mode)
34
39
  @on_event = on_event
35
40
  raise ArgumentError, "name is required" if @name.empty?
36
41
  validate_tools!
@@ -62,7 +67,7 @@ module TurnKit
62
67
  Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
63
68
  end
64
69
 
65
- def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, **options)
70
+ def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
66
71
  task = task || prompt
67
72
  raise ArgumentError, "task is required" if task.to_s.empty?
68
73
 
@@ -71,6 +76,7 @@ module TurnKit
71
76
  turn = conversation.build_turn(
72
77
  trigger_message_id: message.id,
73
78
  root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
79
+ prompt_mode: prompt_mode,
74
80
  **options
75
81
  )
76
82
  run = Run.new(turn)
@@ -93,6 +99,18 @@ module TurnKit
93
99
  thinking
94
100
  end
95
101
 
102
+ def effective_output_audit
103
+ Array(output_audit).compact
104
+ end
105
+
106
+ def output_policy
107
+ output_audit
108
+ end
109
+
110
+ def output_policy_mode
111
+ output_audit_mode
112
+ end
113
+
96
114
  def effective_client
97
115
  client || TurnKit.client
98
116
  end
@@ -142,6 +160,7 @@ module TurnKit
142
160
  timeout: timeout || TurnKit.timeout,
143
161
  max_depth: max_depth || TurnKit.max_depth,
144
162
  max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
163
+ max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
145
164
  cost_limit: cost_limit || TurnKit.cost_limit,
146
165
  root_started_at: root_started_at
147
166
  )
@@ -169,6 +188,53 @@ module TurnKit
169
188
  effective_tools.each(&:validate_definition!)
170
189
  end
171
190
 
191
+ def normalize_output_policy_options(output_audit:, output_policy:, output_policy_model:, output_policy_thinking:)
192
+ raise ArgumentError, "use output_policy: or output_audit:, not both" if output_audit && output_policy
193
+
194
+ output_policy.nil? ? output_audit : normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
195
+ end
196
+
197
+ def normalize_output_policy(value, model: nil, thinking: nil)
198
+ case value
199
+ when nil
200
+ nil
201
+ when Array
202
+ value.map { |item| normalize_output_policy(item, model: model, thinking: thinking) }.compact
203
+ when String
204
+ output_policy_from_path(value, model: model, thinking: thinking)
205
+ when Pathname
206
+ output_policy_from_path(value.to_s, model: model, thinking: thinking)
207
+ else
208
+ return value if value.respond_to?(:call) || value.respond_to?(:check)
209
+
210
+ raise ArgumentError, "output_policy must be a policy file path, a #call/#check object, or an array of those"
211
+ end
212
+ end
213
+
214
+ def output_policy_from_path(path, model: nil, thinking: nil)
215
+ unless path.match?(/\.(md|markdown|txt)\z/i)
216
+ raise ArgumentError, "output_policy string must be a .md, .markdown, or .txt file path"
217
+ end
218
+
219
+ TurnKit::OutputPolicy.from_file(
220
+ path,
221
+ model: model || TurnKit.output_policy_model,
222
+ thinking: thinking || TurnKit.output_policy_thinking
223
+ )
224
+ end
225
+
226
+ def normalize_output_policy_mode(output_audit_mode:, output_policy_mode:)
227
+ if output_audit_mode && output_policy_mode && output_audit_mode.to_sym != output_policy_mode.to_sym
228
+ raise ArgumentError, "use output_policy_mode: or output_audit_mode:, not both"
229
+ end
230
+
231
+ value = output_policy_mode || output_audit_mode || :report
232
+ mode = value.to_sym
233
+ raise ArgumentError, "unknown output_policy_mode: #{value}" unless %i[report fail].include?(mode)
234
+
235
+ mode
236
+ end
237
+
172
238
  def task_message(task, input)
173
239
  text = task.to_s
174
240
  return text if input.nil?
@@ -2,32 +2,40 @@
2
2
 
3
3
  module TurnKit
4
4
  class Budget
5
- attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :cost_limit
5
+ attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :cost_limit
6
6
 
7
- def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, cost_limit: nil, root_started_at: Clock.now)
7
+ def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, cost_limit: nil, root_started_at: Clock.now)
8
8
  @root_started_at = root_started_at
9
9
  @max_iterations = max_iterations
10
10
  @timeout = timeout
11
11
  @max_depth = max_depth
12
12
  @max_tool_executions = max_tool_executions
13
+ @max_tool_executions_by_name = normalize_tool_limits(max_tool_executions_by_name)
13
14
  @cost_limit = cost_limit
14
15
  @iterations = 0
15
16
  @tool_executions = 0
17
+ @tool_executions_by_name = Hash.new(0)
16
18
  @cost = 0
17
19
  @mutex = Mutex.new
18
20
  end
19
21
 
20
22
  def count_iteration!
21
23
  @mutex.synchronize do
24
+ raise BudgetError, "maximum iterations reached" if max_iterations && @iterations >= max_iterations
25
+
22
26
  @iterations += 1
23
- raise Error, "maximum iterations reached" if max_iterations && @iterations > max_iterations
24
27
  end
25
28
  end
26
29
 
27
- def count_tool_execution!
30
+ def count_tool_execution!(name = nil)
28
31
  @mutex.synchronize do
32
+ key = name.to_s if name
33
+ limit = max_tool_executions_by_name[key] if key
34
+ raise BudgetError, "maximum tool executions reached" if max_tool_executions && @tool_executions >= max_tool_executions
35
+ raise BudgetError, "maximum executions reached for tool #{key}" if limit && @tool_executions_by_name[key] >= limit
36
+
29
37
  @tool_executions += 1
30
- raise Error, "maximum tool executions reached" if max_tool_executions && @tool_executions > max_tool_executions
38
+ @tool_executions_by_name[key] += 1 if key
31
39
  end
32
40
  end
33
41
 
@@ -40,13 +48,20 @@ module TurnKit
40
48
 
41
49
  @mutex.synchronize do
42
50
  @cost += cost.to_f
43
- raise Error, "cost limit reached" if @cost > cost_limit
51
+ raise BudgetError, "cost limit reached" if @cost > cost_limit
44
52
  end
45
53
  end
46
54
 
47
55
  def check!(depth:)
48
- raise Error, "maximum sub-agent depth reached" if max_depth && depth > max_depth
49
- raise Error, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
56
+ raise BudgetError, "maximum sub-agent depth reached" if max_depth && depth > max_depth
57
+ raise BudgetError, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
50
58
  end
59
+
60
+ private
61
+ def normalize_tool_limits(value)
62
+ value.to_h.transform_keys(&:to_s).transform_values do |limit|
63
+ limit.nil? ? nil : Integer(limit)
64
+ end
65
+ end
51
66
  end
52
67
  end
@@ -117,6 +117,8 @@ module TurnKit
117
117
  return unless force || over_threshold?(messages, policy)
118
118
 
119
119
  compact!(turn.conversation, agent: turn.agent, turn: turn, focus: focus, auto: true, overrides: policy, force: true)
120
+ rescue BudgetError
121
+ raise
120
122
  rescue StandardError => error
121
123
  TurnKit.logger&.warn("TurnKit compaction failed: #{error.class}: #{error.message}")
122
124
  nil
@@ -144,12 +146,15 @@ module TurnKit
144
146
  target_tokens: summary_budget(selected_tokens, policy),
145
147
  fallback_model: turn&.model || conversation.model || agent.effective_model,
146
148
  conversation_id: conversation.id,
147
- turn_id: turn&.id
149
+ turn_id: turn&.id,
150
+ turn: turn
148
151
  )
149
152
 
150
153
  append_summary(conversation, turn: turn, summary: summary, selected: selected, policy: policy, focus: focus, auto: auto, input_tokens: selected_tokens)
151
154
  rescue CompactionError
152
155
  raise
156
+ rescue BudgetError
157
+ raise
153
158
  rescue StandardError => error
154
159
  raise CompactionError, "#{error.class}: #{error.message}"
155
160
  end
@@ -350,18 +355,24 @@ module TurnKit
350
355
  index
351
356
  end
352
357
 
353
- def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:)
358
+ def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:, turn: nil)
354
359
  client = policy["client"] || agent.effective_client
355
360
  model = policy["model"] || fallback_model
356
361
  safe_messages = messages.map { |message| sanitize_message(message, policy) }
357
362
  prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
358
- result = client.chat(
363
+ attrs = {
359
364
  model: model,
360
365
  messages: MessageProjection.for(safe_messages) + [ { role: :user, content: prompt } ],
361
366
  tools: [],
362
367
  instructions: COMPACTION_SYSTEM_PROMPT,
363
368
  metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
364
- )
369
+ }
370
+ result = if turn
371
+ turn.internal_model_call(**attrs, purpose: "compaction", client: policy["client"])
372
+ else
373
+ client.validate!(model: model)
374
+ client.chat(**attrs)
375
+ end
365
376
  text = result.text.to_s.strip
366
377
  raise CompactionError, "compaction model returned an empty summary" if text.empty?
367
378
 
@@ -26,17 +26,18 @@ module TurnKit
26
26
  async ? turn : turn.run!
27
27
  end
28
28
 
29
- def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
30
- build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, on_event: on_event).run!
29
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil)
30
+ build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, prompt_mode: prompt_mode, on_event: on_event).run!
31
31
  end
32
32
 
33
- def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
33
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil)
34
34
  snapshot = latest_message_sequence
35
35
  effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
36
36
  options = { "trigger_message_id" => trigger_message_id }.compact
37
37
  options["thinking"] = effective_thinking
38
38
  options["compact"] = compact unless compact.nil?
39
39
  options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
40
+ options["prompt_mode"] = prompt_mode.to_sym if prompt_mode
40
41
  record = store.create_turn(
41
42
  "conversation_id" => id,
42
43
  "agent_name" => agent.name,
data/lib/turnkit/error.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class Error < StandardError; end
5
+ class BudgetError < Error; end
5
6
  class ConfigError < Error; end
6
7
  class CompactionError < Error; end
7
8
  class ModelAccessError < ConfigError; end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class OutputAudit
5
+ Violation = Struct.new(:rule, :message, :metadata, keyword_init: true) do
6
+ def to_h
7
+ { "rule" => rule.to_s, "message" => message.to_s, "metadata" => metadata || {} }
8
+ end
9
+ end
10
+
11
+ Result = Struct.new(:violations, keyword_init: true) do
12
+ def clean?
13
+ violations.empty?
14
+ end
15
+
16
+ def messages
17
+ violations.map(&:message)
18
+ end
19
+
20
+ def to_h
21
+ { "clean" => clean?, "violations" => violations.map(&:to_h) }
22
+ end
23
+ end
24
+
25
+ def self.check(output, constraints: [], context: {})
26
+ new(output, constraints: constraints, context: context).check
27
+ end
28
+
29
+ def initialize(output, constraints: [], context: {})
30
+ @output = output
31
+ @constraints = Array(constraints)
32
+ @context = context || {}
33
+ end
34
+
35
+ def check
36
+ Result.new(violations: constraints.flat_map { |constraint| normalize(check_constraint(constraint)) })
37
+ end
38
+
39
+ private
40
+ attr_reader :output, :constraints, :context
41
+
42
+ def check_constraint(constraint)
43
+ if constraint.respond_to?(:check)
44
+ call_with_optional_context(constraint.method(:check))
45
+ elsif constraint.respond_to?(:call)
46
+ callable = constraint.is_a?(Proc) ? constraint : constraint.method(:call)
47
+ call_with_optional_context(callable)
48
+ else
49
+ raise ArgumentError, "output constraints must respond to #call or #check"
50
+ end
51
+ end
52
+
53
+ def call_with_optional_context(method)
54
+ parameters = method.parameters
55
+ return method.call(output) unless parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
56
+ return method.call(output, **context) if parameters.any? { |kind, _| kind == :keyrest }
57
+
58
+ accepted = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
59
+ method.call(output, **context.slice(*accepted))
60
+ end
61
+
62
+ def normalize(value)
63
+ case value
64
+ when nil, false, true
65
+ []
66
+ when Violation
67
+ [ value ]
68
+ when Result
69
+ value.violations
70
+ when String
71
+ [ Violation.new(rule: "output_constraint", message: value, metadata: {}) ]
72
+ when Hash
73
+ [ violation_from_hash(value) ]
74
+ else
75
+ if value.respond_to?(:to_ary)
76
+ value.to_ary.flat_map { |item| normalize(item) }
77
+ else
78
+ raise ArgumentError, "output constraint returned unsupported value: #{value.class}"
79
+ end
80
+ end
81
+ end
82
+
83
+ def violation_from_hash(value)
84
+ attrs = value.transform_keys(&:to_s)
85
+ Violation.new(
86
+ rule: attrs["rule"] || "output_constraint",
87
+ message: attrs["message"] || attrs["error"] || "output constraint failed",
88
+ metadata: attrs["metadata"] || attrs.reject { |key, _| %w[rule message error].include?(key) }
89
+ )
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class OutputPolicy
5
+ DEFAULT_SCHEMA = {
6
+ type: "object",
7
+ properties: {
8
+ approved: { type: "boolean" },
9
+ violations: {
10
+ type: "array",
11
+ items: {
12
+ type: "object",
13
+ properties: {
14
+ rule: { type: "string" },
15
+ message: { type: "string" }
16
+ },
17
+ required: [ "rule", "message" ]
18
+ }
19
+ }
20
+ },
21
+ required: [ "approved", "violations" ]
22
+ }.freeze
23
+
24
+ attr_reader :name, :content, :model, :thinking, :client
25
+
26
+ def self.from_file(path, name: nil, **options)
27
+ new(name: name || File.basename(path, File.extname(path)), content: File.read(path), **options)
28
+ end
29
+
30
+ def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
31
+ @name = name.to_s
32
+ @content = content.to_s
33
+ @model = model
34
+ @thinking = Agent.normalize_thinking(thinking)
35
+ @client = client
36
+ raise ArgumentError, "content is required" if @content.empty?
37
+ end
38
+
39
+ def call(output, run: nil, turn: nil)
40
+ model_name = model || turn&.model || run&.turn&.model || TurnKit.default_model
41
+ result = if turn
42
+ turn.internal_model_call(
43
+ model: model_name,
44
+ messages: audit_messages(output),
45
+ tools: [],
46
+ instructions: audit_instructions,
47
+ thinking: thinking,
48
+ output_schema: DEFAULT_SCHEMA,
49
+ metadata: { output_policy: name },
50
+ purpose: "output_policy",
51
+ client: client
52
+ )
53
+ else
54
+ audit_client = client || TurnKit.client
55
+ audit_client.validate!(model: model_name)
56
+ chat(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
57
+ end
58
+ data = result.output_data || parse_json(result.text)
59
+ return if data.fetch("approved", false)
60
+
61
+ Array(data["violations"]).map do |violation|
62
+ attrs = violation.transform_keys(&:to_s)
63
+ OutputAudit::Violation.new(
64
+ rule: attrs["rule"] || name,
65
+ message: attrs["message"] || "output policy failed",
66
+ metadata: attrs.reject { |key, _| %w[rule message].include?(key) }
67
+ )
68
+ end
69
+ end
70
+
71
+ private
72
+ def audit_instructions
73
+ <<~TEXT
74
+ You audit model outputs against the policy below.
75
+
76
+ Return only a JSON object matching this shape:
77
+ {"approved":true,"violations":[]}
78
+
79
+ 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
+
81
+ Policy:
82
+ #{content}
83
+ TEXT
84
+ end
85
+
86
+ def audit_messages(output)
87
+ [ { role: :user, content: JSON.generate(output: output) } ]
88
+ end
89
+
90
+ def chat(client, **kwargs)
91
+ accepted = chat_keyword_names(client)
92
+ kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
93
+ client.chat(**kwargs)
94
+ end
95
+
96
+ def chat_keyword_names(client)
97
+ client.method(:chat).parameters.filter_map do |kind, name|
98
+ return [ :keyrest ] if kind == :keyrest
99
+
100
+ name if %i[key keyreq].include?(kind)
101
+ end
102
+ end
103
+
104
+ def parse_json(value)
105
+ JSON.parse(extract_json(value.to_s))
106
+ rescue JSON::ParserError
107
+ { "approved" => false, "violations" => [ { "rule" => name, "message" => "output policy returned invalid JSON" } ] }
108
+ end
109
+
110
+ def extract_json(value)
111
+ text = value.strip
112
+ return text if text.start_with?("{") && text.end_with?("}")
113
+
114
+ fenced = text[/```(?:json)?\s*(\{.*?\})\s*```/m, 1]
115
+ return fenced if fenced
116
+
117
+ object = text[/\{.*\}/m]
118
+ object || text
119
+ end
120
+ end
121
+ 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 output_audit = turn.output_audit
18
+ def output_audit_clean? = output_audit.nil? || output_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
@@ -23,10 +23,17 @@ module TurnKit
23
23
  attr_reader :turn
24
24
 
25
25
  def run(tool_call)
26
- turn.budget.count_tool_execution!
27
- tool = tool_for(tool_call.name)
28
26
  execution = ToolExecution.new(create_execution(tool_call))
29
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
34
+
35
+ tool = tool_for(tool_call.name)
36
+
30
37
  unless tool
31
38
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
32
39
  end
@@ -58,7 +65,7 @@ module TurnKit
58
65
  def finish_success(execution, tool_call, payload)
59
66
  attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
60
67
  append_result(execution, tool_call, payload)
61
- turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
68
+ turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: payload.to_json.length)
62
69
  ToolExecution.new(attrs)
63
70
  end
64
71
 
@@ -66,7 +73,7 @@ module TurnKit
66
73
  error = { "message" => message.to_s, "details" => details }.compact
67
74
  attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
68
75
  append_result(execution, tool_call, error)
69
- turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
76
+ turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: error.to_json.length)
70
77
  ToolExecution.new(attrs)
71
78
  end
72
79