turnkit 0.2.5 → 0.2.7

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.
@@ -3,7 +3,28 @@
3
3
  module TurnKit
4
4
  module Adapters
5
5
  class RubyLLM < Client
6
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
6
+ KEY_BY_PROVIDER = {
7
+ openai: "OPENAI_API_KEY",
8
+ gemini: "GEMINI_API_KEY",
9
+ anthropic: "ANTHROPIC_API_KEY",
10
+ openrouter: "OPENROUTER_API_KEY"
11
+ }.freeze
12
+
13
+ def validate!(model:)
14
+ require "ruby_llm"
15
+
16
+ raise ModelAccessError, "model is required" if model.to_s.empty?
17
+
18
+ configure_from_environment
19
+ provider = provider_for(model)
20
+ key_name = KEY_BY_PROVIDER[provider]
21
+ return true unless key_name
22
+ return true if ENV[key_name].to_s != "" || config_key_present?(provider)
23
+
24
+ raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
25
+ end
26
+
27
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
7
28
  require "ruby_llm"
8
29
 
9
30
  configure_from_environment
@@ -12,6 +33,7 @@ module TurnKit
12
33
  add_instructions(chat, instructions, model: model)
13
34
  chat.with_temperature(temperature) if temperature
14
35
  apply_thinking(chat, thinking)
36
+ chat.with_schema(normalize_schema(output_schema)) if output_schema
15
37
  Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
16
38
  Array(messages).each { |message| add_message(chat, message) }
17
39
 
@@ -28,11 +50,39 @@ module TurnKit
28
50
  config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
29
51
  end
30
52
 
53
+ def provider_for(model)
54
+ value = model.to_s.downcase
55
+ return :openrouter if value.start_with?("openrouter/")
56
+ return :anthropic if value.start_with?("anthropic/", "claude")
57
+ return :gemini if value.start_with?("gemini/", "gemini")
58
+ return :openai if value.start_with?("openai/", "gpt", "o1", "o3", "o4")
59
+
60
+ nil
61
+ end
62
+
63
+ def config_key_present?(provider)
64
+ value = ::RubyLLM.config.public_send("#{provider}_api_key") if ::RubyLLM.config.respond_to?("#{provider}_api_key")
65
+ value.to_s != ""
66
+ end
67
+
31
68
  def apply_thinking(chat, thinking)
32
69
  thinking = Agent.normalize_thinking(thinking)
33
70
  chat.with_thinking(**thinking) if thinking
34
71
  end
35
72
 
73
+ def normalize_schema(schema)
74
+ case schema
75
+ when Hash
76
+ normalized = schema.transform_keys(&:to_s).transform_values { |value| normalize_schema(value) }
77
+ normalized["additionalProperties"] = false if normalized["type"] == "object" && !normalized.key?("additionalProperties")
78
+ normalized
79
+ when Array
80
+ schema.map { |value| normalize_schema(value) }
81
+ else
82
+ schema
83
+ end
84
+ end
85
+
36
86
  def complete_without_tool_execution(chat)
37
87
  provider = chat.instance_variable_get(:@provider)
38
88
  provider.complete(
@@ -110,9 +160,7 @@ module TurnKit
110
160
  Class.new(::RubyLLM::Tool) do
111
161
  define_singleton_method(:name) { tool.tool_name }
112
162
  description tool.description
113
- tool.parameters.each do |param|
114
- param(param.fetch(:name).to_sym, type: param.fetch(:type), required: param.fetch(:required), desc: param.fetch(:description))
115
- end
163
+ params tool.input_schema
116
164
 
117
165
  define_method(:execute) do |**arguments|
118
166
  raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
@@ -133,13 +181,29 @@ module TurnKit
133
181
  cost: response_cost(response)
134
182
  )
135
183
  Result.new(
136
- text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
184
+ text: response_text(response),
185
+ output_data: response_data(response),
137
186
  tool_calls: tool_calls,
138
187
  usage: usage,
139
188
  model: response.respond_to?(:model_id) ? response.model_id : model
140
189
  )
141
190
  end
142
191
 
192
+ def response_text(response)
193
+ content = response.respond_to?(:content) ? response.content : response
194
+ content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
195
+ end
196
+
197
+ def response_data(response)
198
+ content = response.respond_to?(:content) ? response.content : nil
199
+ return content if content.is_a?(Hash) || content.is_a?(Array)
200
+ return nil unless content.is_a?(String)
201
+
202
+ JSON.parse(content)
203
+ rescue JSON::ParserError
204
+ nil
205
+ end
206
+
143
207
  def token_value(response, method)
144
208
  response.respond_to?(method) ? response.public_send(method).to_i : 0
145
209
  end
data/lib/turnkit/agent.rb CHANGED
@@ -4,11 +4,12 @@ module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
6
  attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
8
8
 
9
9
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
10
  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)
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
13
  @name = name.to_s
13
14
  @description = description.to_s
14
15
  @model = model
@@ -28,7 +29,11 @@ module TurnKit
28
29
  @max_depth = max_depth
29
30
  @max_tool_executions = max_tool_executions
30
31
  @thinking = self.class.normalize_thinking(thinking)
32
+ @compaction = compaction
33
+ @output_schema = output_schema
34
+ @on_event = on_event
31
35
  raise ArgumentError, "name is required" if @name.empty?
36
+ validate_tools!
32
37
  end
33
38
 
34
39
  def self.normalize_thinking(value)
@@ -85,6 +90,10 @@ module TurnKit
85
90
  tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
86
91
  end
87
92
 
93
+ def effective_on_event
94
+ on_event || TurnKit.on_event
95
+ end
96
+
88
97
  def effective_available_skills
89
98
  (Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
90
99
  end
@@ -128,5 +137,14 @@ module TurnKit
128
137
  parts << SystemPrompt.loaded_skills_text(skills)
129
138
  parts.reject(&:empty?).join("\n\n")
130
139
  end
140
+
141
+ private
142
+ def validate_tools!
143
+ names = effective_tools.map(&:tool_name)
144
+ duplicate = names.find { |name| names.count(name) > 1 }
145
+ raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
146
+
147
+ effective_tools.each(&:validate_definition!)
148
+ end
131
149
  end
132
150
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module TurnKit
4
4
  class Client
5
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
5
+ def validate!(model:)
6
+ true
7
+ end
8
+
9
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
6
10
  raise NotImplementedError
7
11
  end
8
12
  end
@@ -0,0 +1,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Compaction
5
+ DEFAULTS = {
6
+ "enabled" => true,
7
+ "threshold" => 0.75,
8
+ "context_limit" => 128_000,
9
+ "reserved_tokens" => 20_000,
10
+ "head_messages" => 0,
11
+ "tail_messages" => 12,
12
+ "tail_tokens" => 8_000,
13
+ "summary_ratio" => 0.20,
14
+ "min_summary_tokens" => 1_000,
15
+ "max_summary_tokens" => 12_000,
16
+ "tool_output_max_chars" => 2_000,
17
+ "model" => nil,
18
+ "client" => nil
19
+ }.freeze
20
+
21
+ KNOWN_KEYS = DEFAULTS.keys.freeze
22
+
23
+ COMPACTION_SYSTEM_PROMPT = <<~TEXT.strip
24
+ You are an anchored context summarization assistant for TurnKit conversations.
25
+
26
+ Summarize only the conversation history you are given. Recent turns may be kept verbatim outside your summary, so focus on older context that still matters for continuing the work.
27
+
28
+ If a previous summary is provided, update it by preserving still-true details, removing stale details, and merging in new facts.
29
+
30
+ Produce only the requested Markdown summary. Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context.
31
+
32
+ Write in the same language the user was using.
33
+
34
+ Never include API keys, tokens, passwords, secrets, credentials, or connection strings. Replace secret values with [REDACTED].
35
+ TEXT
36
+
37
+ SUMMARY_TEMPLATE = <<~TEXT.strip
38
+ Use this exact structure:
39
+
40
+ ## Active Task
41
+ - [latest unfulfilled user request, preferably verbatim]
42
+
43
+ ## Goal
44
+ - [what the user is trying to accomplish overall]
45
+
46
+ ## Constraints & Preferences
47
+ - [user/developer preferences, specs, constraints, important choices]
48
+
49
+ ## Completed Actions
50
+ - [completed work and outcomes]
51
+
52
+ ## Active State
53
+ - [current state, records/files touched, test status, running tool/turn state]
54
+
55
+ ## In Progress
56
+ - [work underway, or "(none)"]
57
+
58
+ ## Blocked
59
+ - [blockers, exact errors, missing information, or "(none)"]
60
+
61
+ ## Key Decisions
62
+ - [important decisions and why]
63
+
64
+ ## Resolved Questions
65
+ - [questions already answered]
66
+
67
+ ## Pending User Asks
68
+ - [unanswered or unfulfilled asks]
69
+
70
+ ## Relevant Files
71
+ - [file/path/resource and why it matters, or "(none)"]
72
+
73
+ ## Tool Results To Remember
74
+ - [important tool output summaries, or "(none)"]
75
+
76
+ ## Remaining Work
77
+ - [likely next work, framed as context, not instructions]
78
+
79
+ ## Critical Context
80
+ - [specific values, IDs, commands, errors, constraints; redact secrets]
81
+
82
+ Rules:
83
+ - Keep every section.
84
+ - Use terse bullets.
85
+ - Preserve exact file paths, commands, error strings, IDs, and important values.
86
+ - Do not invent facts.
87
+ - Do not include secrets.
88
+ - Do not include a greeting or preamble.
89
+ TEXT
90
+
91
+ module_function
92
+
93
+ def enabled_for?(agent, overrides = {})
94
+ policy_for(agent, overrides)["enabled"]
95
+ end
96
+
97
+ def policy_for(agent, overrides = {})
98
+ global = normalize_config(TurnKit.compaction)
99
+ local = normalize_config(agent.compaction)
100
+ override = normalize_config(overrides)
101
+
102
+ return DEFAULTS.merge("enabled" => false) if global == false
103
+ return DEFAULTS.merge("enabled" => false) if local == false
104
+ return DEFAULTS.merge("enabled" => false) if override == false
105
+
106
+ DEFAULTS.merge(global || {}).merge(local || {}).merge(override || {})
107
+ end
108
+
109
+ def maybe_compact!(turn, force: nil, focus: nil)
110
+ return if turn.compact == false
111
+
112
+ force = turn.compact == true if force.nil?
113
+ policy = policy_for(turn.agent)
114
+ return unless policy["enabled"]
115
+
116
+ messages = project(turn.conversation.messages_for_turn(turn))
117
+ return unless force || over_threshold?(messages, policy)
118
+
119
+ compact!(turn.conversation, agent: turn.agent, turn: turn, focus: focus, auto: true, overrides: policy, force: true)
120
+ rescue StandardError => error
121
+ TurnKit.logger&.warn("TurnKit compaction failed: #{error.class}: #{error.message}")
122
+ nil
123
+ end
124
+
125
+ def compact!(conversation, agent:, turn: nil, focus: nil, auto: false, overrides: {}, force: true)
126
+ policy = policy_for(agent, overrides)
127
+ raise CompactionError, "compaction is disabled" unless policy["enabled"]
128
+
129
+ messages = turn ? conversation.messages_for_turn(turn) : conversation.messages
130
+ projected = project(messages)
131
+ selected = select_messages(projected, policy)
132
+ return nil if selected.nil? && auto
133
+ raise CompactionError, "not enough messages to compact" unless selected
134
+
135
+ selected_tokens = estimate_messages_tokens(selected.fetch("middle"))
136
+ return nil if auto && !force && !over_threshold?(projected, policy)
137
+
138
+ summary = generate_summary(
139
+ agent: agent,
140
+ policy: policy,
141
+ messages: selected.fetch("middle"),
142
+ previous_summary: selected["previous_summary"]&.text,
143
+ focus: focus,
144
+ target_tokens: summary_budget(selected_tokens, policy),
145
+ fallback_model: turn&.model || conversation.model || agent.effective_model,
146
+ conversation_id: conversation.id,
147
+ turn_id: turn&.id
148
+ )
149
+
150
+ append_summary(conversation, turn: turn, summary: summary, selected: selected, policy: policy, focus: focus, auto: auto, input_tokens: selected_tokens)
151
+ rescue CompactionError
152
+ raise
153
+ rescue StandardError => error
154
+ raise CompactionError, "#{error.class}: #{error.message}"
155
+ end
156
+
157
+ def project(messages)
158
+ rows = Array(messages).sort_by { |message| [ message.sequence.to_i, message.id ] }
159
+ summaries = active_summaries(rows)
160
+ ranges = summaries.filter_map { |summary| range_for(summary) }
161
+ summaries_by_id = summaries.to_h { |summary| [ summary.id, summary ] }
162
+ inserted = {}
163
+ projected = []
164
+
165
+ rows.each do |message|
166
+ summaries.each do |summary|
167
+ range = range_for(summary)
168
+ next unless range
169
+ next if inserted[summary.id]
170
+ next unless range.begin <= message.sequence.to_i
171
+
172
+ projected << summary
173
+ inserted[summary.id] = true
174
+ end
175
+
176
+ if message.context_summary?
177
+ projected << message if summaries_by_id[message.id] && !inserted[message.id] && !range_for(message)
178
+ inserted[message.id] = true if summaries_by_id[message.id]
179
+ next
180
+ end
181
+
182
+ next if ranges.any? { |range| range.cover?(message.sequence.to_i) }
183
+
184
+ projected << message
185
+ end
186
+
187
+ summaries.each do |summary|
188
+ next if inserted[summary.id]
189
+
190
+ projected << summary
191
+ inserted[summary.id] = true
192
+ end
193
+
194
+ projected
195
+ end
196
+
197
+ def estimate_messages_tokens(messages)
198
+ Array(messages).sum { |message| estimate_text_tokens(message.text) + 8 }
199
+ end
200
+
201
+ def estimate_text_tokens(text)
202
+ (text.to_s.length / 4.0).ceil
203
+ end
204
+
205
+ def summary_budget(input_tokens, policy)
206
+ budget = (input_tokens.to_i * policy["summary_ratio"].to_f).ceil
207
+ budget = [ budget, policy["min_summary_tokens"].to_i ].max
208
+ [ budget, policy["max_summary_tokens"].to_i ].min
209
+ end
210
+
211
+ def over_threshold?(messages, policy)
212
+ usable = [ policy["context_limit"].to_i - policy["reserved_tokens"].to_i, 1 ].max
213
+ estimate_messages_tokens(messages) >= (usable * policy["threshold"].to_f)
214
+ end
215
+
216
+ def select_messages(messages, policy)
217
+ rows = Array(messages)
218
+ return nil if rows.length <= policy["head_messages"].to_i + 1
219
+
220
+ previous_summary = rows.reverse.find(&:context_summary?)
221
+ candidates = rows.reject(&:context_summary?)
222
+ return nil if candidates.length <= policy["head_messages"].to_i + 1
223
+
224
+ head_count = policy["head_messages"].to_i
225
+ tail_start = tail_start_index(candidates, policy)
226
+ tail_start = [ tail_start, head_count ].max
227
+ tail_start = expand_tail_start_for_tool_pairs(candidates, tail_start)
228
+ middle = candidates[head_count...tail_start]
229
+ return nil if middle.nil? || middle.empty?
230
+
231
+ from_sequence = middle.first.sequence.to_i
232
+ through_sequence = middle.last.sequence.to_i
233
+ if previous_summary
234
+ from_sequence = [ from_sequence, previous_summary.sequence.to_i ].min
235
+ through_sequence = [ through_sequence, previous_summary.sequence.to_i ].max
236
+ end
237
+
238
+ {
239
+ "middle" => middle,
240
+ "previous_summary" => previous_summary,
241
+ "replaces_from_sequence" => from_sequence,
242
+ "replaces_through_sequence" => through_sequence,
243
+ "tail_start_sequence" => candidates[tail_start]&.sequence
244
+ }
245
+ end
246
+
247
+ def build_prompt(previous_summary:, focus:, target_tokens:)
248
+ parts = []
249
+ if previous_summary && !previous_summary.empty?
250
+ parts << <<~TEXT.strip
251
+ Update the anchored summary below using the conversation history above.
252
+
253
+ Preserve still-true details, remove stale details, and merge in new facts. Remove stale details that are no longer relevant or have been superseded.
254
+
255
+ <previous-summary>
256
+ #{previous_summary}
257
+ </previous-summary>
258
+ TEXT
259
+ else
260
+ parts << <<~TEXT.strip
261
+ Create a structured context checkpoint for the conversation history above.
262
+
263
+ This summary will replace older TurnKit messages in future model prompts while the original messages remain stored durably.
264
+ TEXT
265
+ end
266
+
267
+ if focus && !focus.to_s.strip.empty?
268
+ parts << <<~TEXT.strip
269
+ Focus topic: "#{focus}"
270
+
271
+ Preserve extra detail related to this focus topic. Summarize unrelated context more aggressively, but do not omit constraints or active blockers that affect the current task.
272
+ TEXT
273
+ end
274
+
275
+ parts << "Target length: approximately #{target_tokens} tokens."
276
+ parts << SUMMARY_TEMPLATE
277
+ parts.join("\n\n")
278
+ end
279
+
280
+ def normalize_config(value)
281
+ case value
282
+ when nil, true
283
+ nil
284
+ when false
285
+ false
286
+ when Hash
287
+ attrs = value.transform_keys(&:to_s)
288
+ unknown = attrs.keys - KNOWN_KEYS
289
+ raise ConfigError, "unknown compaction options: #{unknown.join(", ")}" if unknown.any?
290
+
291
+ attrs
292
+ else
293
+ raise ConfigError, "compaction must be true, false, nil, or a Hash"
294
+ end
295
+ end
296
+
297
+ def range_for(summary)
298
+ metadata = summary.compaction_metadata
299
+ from = metadata["replaces_from_sequence"]
300
+ through = metadata["replaces_through_sequence"]
301
+ return nil unless from && through
302
+
303
+ (from.to_i..through.to_i)
304
+ end
305
+
306
+ def active_summaries(messages)
307
+ summaries = Array(messages).select(&:context_summary?).sort_by { |summary| summary.sequence.to_i }
308
+ active = []
309
+
310
+ summaries.reverse_each do |summary|
311
+ next if active.any? { |newer| (range_for(newer)&.cover?(summary.sequence.to_i)) }
312
+
313
+ active << summary
314
+ end
315
+
316
+ active.reverse
317
+ end
318
+
319
+ def tail_start_index(messages, policy)
320
+ max_messages = policy["tail_messages"].to_i
321
+ max_tokens = policy["tail_tokens"].to_i
322
+ count = 0
323
+ tokens = 0
324
+ index = messages.length
325
+
326
+ (messages.length - 1).downto(0) do |i|
327
+ message_tokens = estimate_text_tokens(messages[i].text) + 8
328
+ break if count >= max_messages
329
+ break if count.positive? && tokens + message_tokens > max_tokens
330
+
331
+ count += 1
332
+ tokens += message_tokens
333
+ index = i
334
+ end
335
+
336
+ index
337
+ end
338
+
339
+ def expand_tail_start_for_tool_pairs(messages, tail_start)
340
+ index = tail_start
341
+ while index.positive? && messages[index]&.tool_result?
342
+ call_id = messages[index].metadata["tool_call_id"]
343
+ call_index = (index - 1).downto(0).find do |i|
344
+ messages[i].tool_call? && Array(messages[i].metadata["tool_calls"]).any? { |call| call["id"] == call_id || call[:id] == call_id }
345
+ end
346
+ break unless call_index
347
+
348
+ index = call_index
349
+ end
350
+ index
351
+ end
352
+
353
+ def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:)
354
+ client = policy["client"] || agent.effective_client
355
+ model = policy["model"] || fallback_model
356
+ safe_messages = messages.map { |message| sanitize_message(message, policy) }
357
+ prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
358
+ result = client.chat(
359
+ model: model,
360
+ messages: MessageProjection.for(safe_messages) + [ { role: :user, content: prompt } ],
361
+ tools: [],
362
+ instructions: COMPACTION_SYSTEM_PROMPT,
363
+ metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
364
+ )
365
+ text = result.text.to_s.strip
366
+ raise CompactionError, "compaction model returned an empty summary" if text.empty?
367
+
368
+ text
369
+ end
370
+
371
+ def sanitize_message(message, policy)
372
+ return message unless message.tool_result?
373
+
374
+ max = policy["tool_output_max_chars"].to_i
375
+ return message if max <= 0 || message.text.length <= max
376
+
377
+ attrs = message.to_h
378
+ text = "#{message.text[0, max]}\n\n[Tool result truncated for compaction]"
379
+ Message.new(attrs.merge("text" => text, "content" => [ { "type" => "text", "text" => text } ]))
380
+ end
381
+
382
+ def append_summary(conversation, turn:, summary:, selected:, policy:, focus:, auto:, input_tokens:)
383
+ model = policy["model"] || turn&.model || conversation.model || conversation.agent.effective_model
384
+ conversation.append_message(
385
+ role: "assistant",
386
+ kind: "context_summary",
387
+ text: summary,
388
+ turn_id: turn&.id,
389
+ metadata: {
390
+ "compaction" => {
391
+ "auto" => auto,
392
+ "focus" => focus,
393
+ "replaces_from_sequence" => selected.fetch("replaces_from_sequence"),
394
+ "replaces_through_sequence" => selected.fetch("replaces_through_sequence"),
395
+ "tail_start_sequence" => selected["tail_start_sequence"],
396
+ "summary_model" => model,
397
+ "input_tokens" => input_tokens,
398
+ "summary_tokens" => estimate_text_tokens(summary),
399
+ "created_for_turn_id" => turn&.id,
400
+ "created_at" => Clock.now.iso8601
401
+ }.compact
402
+ }
403
+ )
404
+ end
405
+ end
406
+ end
@@ -26,15 +26,17 @@ 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, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
30
- build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking).run!
29
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: 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, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, 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, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
33
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: 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
+ options["compact"] = compact unless compact.nil?
39
+ options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
38
40
  record = store.create_turn(
39
41
  "conversation_id" => id,
40
42
  "agent_name" => agent.name,
@@ -46,7 +48,12 @@ module TurnKit
46
48
  "model" => model || self.model || agent.effective_model,
47
49
  "options" => options
48
50
  )
49
- Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
51
+ Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth, on_event: on_event)
52
+ end
53
+
54
+ def compact!(focus: nil, model: nil)
55
+ overrides = { "model" => model }.compact
56
+ TurnKit::Compaction.compact!(self, agent: agent, focus: focus, auto: false, overrides: overrides)
50
57
  end
51
58
 
52
59
  def messages
data/lib/turnkit/error.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module TurnKit
4
4
  class Error < StandardError; end
5
5
  class ConfigError < Error; end
6
+ class CompactionError < Error; end
7
+ class ModelAccessError < ConfigError; end
6
8
  class StoreError < Error; end
7
9
  class ToolError < Error; end
10
+ class ToolValidationError < ToolError; end
8
11
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Event
5
+ attr_reader :type, :turn_id, :conversation_id, :payload, :created_at
6
+
7
+ def initialize(type:, turn_id:, conversation_id:, payload: {}, created_at: Clock.now)
8
+ @type = type.to_s
9
+ @turn_id = turn_id
10
+ @conversation_id = conversation_id
11
+ @payload = payload || {}
12
+ @created_at = created_at
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ "type" => type,
18
+ "turn_id" => turn_id,
19
+ "conversation_id" => conversation_id,
20
+ "payload" => payload,
21
+ "created_at" => created_at
22
+ }
23
+ end
24
+ end
25
+ end
@@ -30,6 +30,7 @@ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
30
30
  t.decimal :cost, precision: 14, scale: 6
31
31
  t.json :error
32
32
  t.text :output_text
33
+ t.json :output_data
33
34
  t.datetime :started_at
34
35
  t.datetime :heartbeat_at
35
36
  t.datetime :completed_at
@@ -12,8 +12,14 @@ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
12
12
  # TurnKit.timeout = 300
13
13
  # TurnKit.max_depth = 3
14
14
  # TurnKit.max_tool_executions = 100
15
+ # TurnKit.on_event = ->(event) { Rails.logger.info("turnkit.#{event.type} #{event.payload.inspect}") }
15
16
 
16
17
  # TurnKit builds each system prompt from these sections by default.
17
18
  # TurnKit.prompt_sections = %i[agent instructions behavior loaded_skills available_skills tools subject environment]
18
19
  # TurnKit.prompt_behavior = "Custom behavior instructions."
19
20
  # TurnKit.available_skills = TurnKit::Skill.from_directory(Rails.root.join("app/ai/skills"))
21
+
22
+ # Suggested Rails convention:
23
+ # - app/ai/agents/* builds TurnKit::Agent objects for your workflows.
24
+ # - app/ai/tools/* defines TurnKit::Tool subclasses.
25
+ # - app/ai/skills/* stores reusable Markdown skill files.