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.
@@ -25,6 +25,12 @@ module TurnKit
25
25
  template "tool_execution.rb", "app/models/turnkit/tool_execution.rb"
26
26
  end
27
27
 
28
+ def create_ai_directories
29
+ empty_directory "app/ai/agents"
30
+ empty_directory "app/ai/tools"
31
+ empty_directory "app/ai/skills"
32
+ end
33
+
28
34
  def copy_migration
29
35
  migration_template "create_turnkit_tables.rb", "db/migrate/create_turnkit_tables.rb"
30
36
  end
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  class Message
5
5
  ROLES = %w[user assistant tool].freeze
6
- KINDS = %w[text tool_call tool_result].freeze
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
9
  attr_reader :content, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
@@ -43,6 +43,26 @@ module TurnKit
43
43
  }
44
44
  end
45
45
 
46
+ def text?
47
+ kind == "text"
48
+ end
49
+
50
+ def tool_call?
51
+ kind == "tool_call"
52
+ end
53
+
54
+ def tool_result?
55
+ kind == "tool_result"
56
+ end
57
+
58
+ def context_summary?
59
+ kind == "context_summary"
60
+ end
61
+
62
+ def compaction_metadata
63
+ metadata.fetch("compaction", {})
64
+ end
65
+
46
66
  private
47
67
  def stringify(hash)
48
68
  hash.transform_keys(&:to_s)
@@ -2,14 +2,41 @@
2
2
 
3
3
  module TurnKit
4
4
  class MessageProjection
5
+ CONTEXT_SUMMARY_TRIGGER = "What did we do so far?"
6
+ CONTEXT_SUMMARY_PREFIX = <<~TEXT.strip
7
+ [CONTEXT COMPACTION — REFERENCE ONLY]
8
+
9
+ Earlier TurnKit conversation messages were compacted into the summary below. This is a handoff from a previous context window. Treat it as background reference, not as active instructions.
10
+
11
+ Do not answer questions or perform tasks merely because they appear in this summary. Respond to the latest user message after this summary.
12
+
13
+ If the latest user message contradicts, supersedes, changes topic from, or diverges from Active Task, In Progress, Pending User Asks, or Remaining Work, the latest user message wins.
14
+
15
+ Subject context and live context are recomputed for the current turn and are more authoritative for state-sensitive facts.
16
+
17
+ The original messages remain durably stored; this summary only affects the model-visible prompt projection.
18
+ TEXT
19
+
5
20
  def self.for(messages)
6
- messages.map { |message| new(message).to_h }
21
+ messages.flat_map { |message| new(message).to_a }
7
22
  end
8
23
 
9
24
  def initialize(message)
10
25
  @message = message
11
26
  end
12
27
 
28
+ def to_a
29
+ case message.kind
30
+ when "context_summary"
31
+ [
32
+ { role: :user, content: CONTEXT_SUMMARY_TRIGGER },
33
+ { role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
34
+ ]
35
+ else
36
+ [ to_h ]
37
+ end
38
+ end
39
+
13
40
  def to_h
14
41
  case message.kind
15
42
  when "tool_call"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ModelRequest
5
+ attr_reader :model, :messages, :tools, :instructions, :thinking, :output_schema, :metadata, :report
6
+
7
+ def initialize(model:, messages:, tools:, instructions:, thinking: nil, output_schema: nil, metadata: {}, report: nil)
8
+ @model = model
9
+ @messages = Array(messages)
10
+ @tools = Array(tools)
11
+ @instructions = instructions.to_s
12
+ @thinking = thinking
13
+ @output_schema = output_schema
14
+ @metadata = metadata || {}
15
+ @report = report || {}
16
+ end
17
+
18
+ def tool_names
19
+ tools.map(&:tool_name)
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ "model" => model,
25
+ "messages" => messages,
26
+ "tools" => tool_names,
27
+ "instructions" => instructions,
28
+ "thinking" => thinking,
29
+ "output_schema" => output_schema,
30
+ "metadata" => metadata,
31
+ "report" => report
32
+ }
33
+ end
34
+ end
35
+ end
@@ -5,7 +5,7 @@ module TurnKit
5
5
  TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
6
6
  TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
7
7
 
8
- TURN_UPDATE_KEYS = %w[status options usage cost error output_text started_at heartbeat_at completed_at].freeze
8
+ TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
9
9
  TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
10
10
 
11
11
  module_function
@@ -49,6 +49,7 @@ module TurnKit
49
49
  "cost" => attrs["cost"],
50
50
  "error" => attrs["error"],
51
51
  "output_text" => attrs["output_text"],
52
+ "output_data" => attrs["output_data"],
52
53
  "started_at" => attrs["started_at"],
53
54
  "heartbeat_at" => attrs["heartbeat_at"],
54
55
  "completed_at" => attrs["completed_at"],
@@ -2,14 +2,15 @@
2
2
 
3
3
  module TurnKit
4
4
  class Result
5
- attr_reader :text, :tool_calls, :usage, :model, :finish_reason
5
+ attr_reader :text, :tool_calls, :usage, :model, :finish_reason, :output_data
6
6
 
7
- def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil)
7
+ def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
8
8
  @text = text.to_s
9
9
  @tool_calls = Array(tool_calls)
10
10
  @usage = usage || Usage.new
11
11
  @model = model
12
12
  @finish_reason = finish_reason
13
+ @output_data = output_data
13
14
  end
14
15
 
15
16
  def tool_calls?
@@ -57,7 +57,7 @@ module TurnKit
57
57
 
58
58
  def create_turn(attributes)
59
59
  attrs = Record.turn(attributes)
60
- record = turn_class.create!(
60
+ record_attrs = {
61
61
  uid: attrs.fetch("id"),
62
62
  conversation_uid: attrs.fetch("conversation_id"),
63
63
  agent_name: attrs["agent_name"],
@@ -75,7 +75,9 @@ module TurnKit
75
75
  started_at: attrs["started_at"],
76
76
  heartbeat_at: attrs["heartbeat_at"],
77
77
  completed_at: attrs["completed_at"]
78
- )
78
+ }
79
+ record_attrs[:output_data] = attrs["output_data"] if turn_has_attribute?("output_data")
80
+ record = turn_class.create!(record_attrs)
79
81
  turn_hash(record)
80
82
  end
81
83
 
@@ -85,7 +87,9 @@ module TurnKit
85
87
 
86
88
  def update_turn(id, attributes)
87
89
  record = turn_class.find_by!(uid: id)
88
- record.update!(Record.turn_update(attributes))
90
+ attrs = Record.turn_update(attributes)
91
+ attrs.delete("output_data") unless turn_has_attribute?("output_data")
92
+ record.update!(attrs)
89
93
  turn_hash(record)
90
94
  end
91
95
 
@@ -160,7 +164,7 @@ module TurnKit
160
164
  end
161
165
 
162
166
  def turn_hash(record)
163
- {
167
+ attrs = {
164
168
  "id" => record.uid, "conversation_id" => record.conversation_uid, "agent_name" => record.agent_name,
165
169
  "parent_turn_id" => record.parent_turn_uid, "parent_tool_execution_id" => record.parent_tool_execution_uid,
166
170
  "root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
@@ -169,6 +173,12 @@ module TurnKit
169
173
  "started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
170
174
  "created_at" => record.created_at, "updated_at" => record.updated_at
171
175
  }
176
+ attrs["output_data"] = record.output_data if record.respond_to?(:output_data)
177
+ attrs
178
+ end
179
+
180
+ def turn_has_attribute?(name)
181
+ turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
172
182
  end
173
183
 
174
184
  def message_hash(record)
@@ -21,9 +21,17 @@ module TurnKit
21
21
  def call(task:, context: nil, turnkit_context:)
22
22
  sub_agent = self.class.agent
23
23
  parent_turn = turnkit_context.turn
24
- conversation = parent_turn.conversation
25
24
  prompt = [ task, context ].compact.join("\n\n")
26
- trigger = conversation.append_message(role: "user", kind: "text", text: prompt, turn_id: parent_turn.id)
25
+ conversation = sub_agent.conversation(metadata: {
26
+ "parent_conversation_id" => parent_turn.conversation.id,
27
+ "parent_turn_id" => parent_turn.id,
28
+ "parent_tool_execution_id" => turnkit_context.execution.id
29
+ })
30
+ trigger = conversation.say(prompt, metadata: {
31
+ "parent_conversation_id" => parent_turn.conversation.id,
32
+ "parent_turn_id" => parent_turn.id,
33
+ "parent_tool_execution_id" => turnkit_context.execution.id
34
+ })
27
35
  child = conversation.run!(
28
36
  trigger_message_id: trigger.id,
29
37
  budget: parent_turn.budget,
@@ -31,9 +39,10 @@ module TurnKit
31
39
  parent_tool_execution: turnkit_context.execution,
32
40
  depth: parent_turn.depth + 1,
33
41
  model: sub_agent.effective_model,
34
- agent: sub_agent
42
+ agent: sub_agent,
43
+ on_event: parent_turn.agent.effective_on_event
35
44
  )
36
- { "turn_id" => child.id, "status" => child.status, "result" => child.output_text }
45
+ { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data }.compact
37
46
  end
38
47
  end
39
48
  end
data/lib/turnkit/tool.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module TurnKit
4
4
  class Tool
5
5
  TYPES = %i[string integer number boolean array object enum].freeze
6
+ NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
6
7
 
7
8
  class << self
8
9
  def tool_name(value = nil)
@@ -20,16 +21,22 @@ module TurnKit
20
21
  @usage_hint.to_s
21
22
  end
22
23
 
23
- def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil)
24
+ def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil, items: nil, properties: nil)
25
+ name = name.to_s
24
26
  raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
27
+ raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
28
+ raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
29
+ raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
25
30
 
26
31
  parameters << {
27
- name: name.to_s,
32
+ name: name,
28
33
  type: type,
29
34
  required: required ? true : false,
30
35
  description: description.to_s,
31
36
  default: default,
32
- enum: enum
37
+ enum: enum,
38
+ items: items,
39
+ properties: properties
33
40
  }.compact
34
41
  end
35
42
 
@@ -45,8 +52,56 @@ module TurnKit
45
52
  nil
46
53
  end
47
54
 
55
+ def validate_definition!
56
+ raise ArgumentError, "tool name is required" if tool_name.empty?
57
+ raise ArgumentError, "invalid tool name: #{tool_name}" unless NAME_PATTERN.match?(tool_name)
58
+
59
+ parameters.each do |param|
60
+ type = param.fetch(:type)
61
+ raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
62
+ raise ArgumentError, "enum values are required for enum parameter: #{param.fetch(:name)}" if type == :enum && Array(param[:enum]).empty?
63
+ validate_value!(param[:default], param) if param.key?(:default)
64
+ end
65
+ true
66
+ end
67
+
68
+ def input_schema
69
+ properties = parameters.to_h { |param| [ param.fetch(:name), schema_for(param) ] }
70
+ required = parameters.select { |param| param.fetch(:required) }.map { |param| param.fetch(:name) }
71
+ {
72
+ "type" => "object",
73
+ "properties" => properties,
74
+ "required" => required
75
+ }
76
+ end
77
+
78
+ def validate_arguments(arguments)
79
+ attrs = arguments.respond_to?(:to_h) ? arguments.to_h.transform_keys(&:to_s) : {}
80
+ allowed = parameters.map { |param| param.fetch(:name) }
81
+ unknown = attrs.keys - allowed
82
+ raise ToolValidationError, "unknown argument#{unknown.length == 1 ? "" : "s"}: #{unknown.join(", ")}" if unknown.any?
83
+
84
+ normalized = {}
85
+ parameters.each do |param|
86
+ name = param.fetch(:name)
87
+ if attrs.key?(name)
88
+ value = attrs[name]
89
+ elsif param.key?(:default)
90
+ value = param[:default]
91
+ elsif param.fetch(:required)
92
+ raise ToolValidationError, "missing required argument: #{name}"
93
+ else
94
+ next
95
+ end
96
+
97
+ validate_value!(value, param)
98
+ normalized[name] = value
99
+ end
100
+ normalized
101
+ end
102
+
48
103
  def call(arguments = {}, context:)
49
- keyword_arguments = symbolize(arguments)
104
+ keyword_arguments = symbolize(validate_arguments(arguments))
50
105
  instance = new
51
106
  if accepts_turnkit_context?(instance)
52
107
  instance.call(**keyword_arguments, turnkit_context: context)
@@ -56,6 +111,64 @@ module TurnKit
56
111
  end
57
112
 
58
113
  private
114
+ def schema_for(param)
115
+ schema = {
116
+ "type" => schema_type(param.fetch(:type)),
117
+ "description" => param[:description].to_s
118
+ }.reject { |_key, value| value.nil? || value == "" }
119
+ schema["enum"] = Array(param[:enum]) if param[:enum]
120
+ schema["default"] = param[:default] if param.key?(:default)
121
+ schema["items"] = normalize_items(param[:items]) if param[:items]
122
+ schema["properties"] = normalize_properties(param[:properties]) if param[:properties]
123
+ schema
124
+ end
125
+
126
+ def schema_type(type)
127
+ type == :enum ? "string" : type.to_s
128
+ end
129
+
130
+ def normalize_items(value)
131
+ return { "type" => value.to_s } if value.is_a?(Symbol)
132
+
133
+ stringify_schema(value)
134
+ end
135
+
136
+ def normalize_properties(value)
137
+ value.to_h.transform_keys(&:to_s).transform_values { |schema| stringify_schema(schema) }
138
+ end
139
+
140
+ def stringify_schema(value)
141
+ case value
142
+ when Hash
143
+ value.transform_keys(&:to_s).transform_values { |nested| nested.is_a?(Hash) ? stringify_schema(nested) : nested }
144
+ else
145
+ { "type" => value.to_s }
146
+ end
147
+ end
148
+
149
+ def validate_value!(value, param)
150
+ return if value.nil? && !param.fetch(:required)
151
+
152
+ case param.fetch(:type)
153
+ when :string, :enum
154
+ raise ToolValidationError, "#{param.fetch(:name)} must be a string" unless value.is_a?(String)
155
+ when :integer
156
+ raise ToolValidationError, "#{param.fetch(:name)} must be an integer" unless value.is_a?(Integer)
157
+ when :number
158
+ raise ToolValidationError, "#{param.fetch(:name)} must be a number" unless value.is_a?(Numeric)
159
+ when :boolean
160
+ raise ToolValidationError, "#{param.fetch(:name)} must be a boolean" unless value == true || value == false
161
+ when :array
162
+ raise ToolValidationError, "#{param.fetch(:name)} must be an array" unless value.is_a?(Array)
163
+ when :object
164
+ raise ToolValidationError, "#{param.fetch(:name)} must be an object" unless value.is_a?(Hash)
165
+ end
166
+
167
+ if param[:enum] && !Array(param[:enum]).include?(value)
168
+ raise ToolValidationError, "#{param.fetch(:name)} must be one of: #{Array(param[:enum]).join(", ")}"
169
+ end
170
+ end
171
+
59
172
  def accepts_turnkit_context?(instance)
60
173
  instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
61
174
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module TurnKit
4
4
  class ToolCall
5
- attr_reader :id, :name, :arguments
5
+ attr_reader :id, :name, :arguments, :arguments_error
6
6
 
7
7
  def initialize(id:, name:, arguments: {})
8
8
  @id = id.to_s
9
9
  @name = name.to_s
10
+ @arguments_error = nil
10
11
  @arguments = normalize_arguments(arguments)
11
12
  end
12
13
 
@@ -22,6 +23,7 @@ module TurnKit
22
23
  {}
23
24
  end
24
25
  rescue JSON::ParserError
26
+ @arguments_error = "invalid JSON arguments"
25
27
  {}
26
28
  end
27
29
  end
@@ -31,6 +31,10 @@ module TurnKit
31
31
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
32
32
  end
33
33
 
34
+ if tool_call.arguments_error
35
+ return finish_error(execution, tool_call, tool_call.arguments_error)
36
+ end
37
+
34
38
  context = ToolContext.new(turn: turn, execution: execution)
35
39
  payload = begin
36
40
  normalize_payload(tool.call(tool_call.arguments, context: context))
@@ -54,6 +58,7 @@ module TurnKit
54
58
  def finish_success(execution, tool_call, payload)
55
59
  attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
56
60
  append_result(execution, tool_call, payload)
61
+ turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
57
62
  ToolExecution.new(attrs)
58
63
  end
59
64
 
@@ -61,11 +66,12 @@ module TurnKit
61
66
  error = { "message" => message.to_s, "details" => details }.compact
62
67
  attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
63
68
  append_result(execution, tool_call, error)
69
+ turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
64
70
  ToolExecution.new(attrs)
65
71
  end
66
72
 
67
73
  def append_result(execution, tool_call, payload)
68
- turn.conversation.append_message(
74
+ message = turn.conversation.append_message(
69
75
  role: "tool",
70
76
  kind: "tool_result",
71
77
  text: payload.to_json,
@@ -73,6 +79,7 @@ module TurnKit
73
79
  tool_execution_id: execution.id,
74
80
  metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
75
81
  )
82
+ turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
76
83
  end
77
84
 
78
85
  def tool_class(name)
data/lib/turnkit/turn.rb CHANGED
@@ -6,10 +6,10 @@ module TurnKit
6
6
 
7
7
  attr_reader :agent, :conversation, :store, :budget, :depth
8
8
  attr_reader :id, :conversation_id, :agent_name, :parent_turn_id, :parent_tool_execution_id
9
- attr_reader :root_turn_id, :context_message_sequence, :model, :thinking
9
+ attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact, :output_schema
10
10
  attr_reader :started_at
11
11
 
12
- def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
12
+ def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0, on_event: nil)
13
13
  @agent = agent
14
14
  @conversation = conversation
15
15
  @store = store
@@ -23,27 +23,30 @@ module TurnKit
23
23
  @context_message_sequence = @record["context_message_sequence"].to_i
24
24
  @model = @record["model"] || agent.effective_model
25
25
  @thinking = thinking_from_options
26
+ @compact = compact_from_options
27
+ @output_schema = output_schema_from_options
26
28
  @started_at = @record["started_at"]
27
29
  @budget = budget || agent.build_budget
28
30
  @depth = depth
31
+ @on_event = on_event
29
32
  end
30
33
 
31
- def run!
34
+ def run!(&block)
35
+ @on_event = block if block
32
36
  return self unless status == "pending"
33
37
 
34
38
  update!(status: "running", started_at: Clock.now, heartbeat_at: Clock.now)
39
+ emit("turn.started", status: status, model: model)
40
+ agent.effective_client.validate!(model: model)
35
41
  loop do
36
42
  budget.check!(depth: depth)
37
43
  budget.count_iteration!
44
+ TurnKit::Compaction.maybe_compact!(self)
38
45
 
39
- result = agent.effective_client.chat(
40
- model: model,
41
- messages: llm_messages,
42
- tools: agent.effective_tools,
43
- instructions: agent.system_prompt_for(turn: self, conversation: conversation),
44
- thinking: thinking,
45
- metadata: { turn_id: id, conversation_id: conversation.id }
46
- )
46
+ request = model_request
47
+ emit("model.requested", model: request.model, tool_names: request.tool_names)
48
+ result = call_client(request)
49
+ emit("model.completed", model: result.model || model, tool_call_count: result.tool_calls.length)
47
50
  result_cost = Cost.from_usage(result.usage, model: result.model || model)
48
51
 
49
52
  budget.add_cost!(result_cost.total)
@@ -58,7 +61,8 @@ module TurnKit
58
61
  break
59
62
  end
60
63
  else
61
- update!(status: "completed", output_text: result.text, completed_at: Clock.now)
64
+ update!(status: "completed", output_text: result.text, output_data: result.output_data, completed_at: Clock.now)
65
+ emit("turn.completed", status: status, output_text: result.text)
62
66
  break
63
67
  end
64
68
  end
@@ -66,10 +70,15 @@ module TurnKit
66
70
  self
67
71
  rescue StandardError => error
68
72
  update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
73
+ emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
69
74
  reload
70
75
  self
71
76
  end
72
77
 
78
+ def preview
79
+ model_request
80
+ end
81
+
73
82
  def status
74
83
  @record.fetch("status")
75
84
  end
@@ -82,6 +91,10 @@ module TurnKit
82
91
  @record["output_text"].to_s
83
92
  end
84
93
 
94
+ def output_data
95
+ @record["output_data"]
96
+ end
97
+
85
98
  def usage
86
99
  Usage.from_h(@record["usage"] || {})
87
100
  end
@@ -97,6 +110,8 @@ module TurnKit
97
110
  def reload
98
111
  @record = store.load_turn(id)
99
112
  @thinking = thinking_from_options
113
+ @compact = compact_from_options
114
+ @output_schema = output_schema_from_options
100
115
  self
101
116
  end
102
117
 
@@ -104,9 +119,59 @@ module TurnKit
104
119
  update!(status: "stale", completed_at: Clock.now)
105
120
  end
106
121
 
122
+ def emit(type, payload = {})
123
+ emit_event(Event.new(type: type, turn_id: id, conversation_id: conversation.id, payload: payload))
124
+ end
125
+
107
126
  private
127
+ def model_request
128
+ prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: agent.effective_prompt_mode(turn: self))
129
+ instructions = case agent.system_prompt
130
+ when nil
131
+ prompt.to_s
132
+ when String
133
+ agent.system_prompt
134
+ else
135
+ agent.system_prompt.call(prompt).to_s
136
+ end
137
+ ModelRequest.new(
138
+ model: model,
139
+ messages: llm_messages,
140
+ tools: agent.effective_tools,
141
+ instructions: instructions,
142
+ thinking: thinking,
143
+ output_schema: output_schema,
144
+ metadata: { turn_id: id, conversation_id: conversation.id },
145
+ report: prompt.report
146
+ )
147
+ end
148
+
149
+ def call_client(request)
150
+ kwargs = {
151
+ model: request.model,
152
+ messages: request.messages,
153
+ tools: request.tools,
154
+ instructions: request.instructions,
155
+ thinking: request.thinking,
156
+ output_schema: request.output_schema,
157
+ metadata: request.metadata,
158
+ on_event: ->(event) { emit_event(event) }
159
+ }
160
+ accepted = chat_keyword_names(agent.effective_client)
161
+ kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
162
+ agent.effective_client.chat(**kwargs)
163
+ end
164
+
165
+ def chat_keyword_names(client)
166
+ client.method(:chat).parameters.filter_map do |kind, name|
167
+ return [ :keyrest ] if kind == :keyrest
168
+
169
+ name if %i[key keyreq].include?(kind)
170
+ end
171
+ end
172
+
108
173
  def llm_messages
109
- MessageProjection.for(conversation.messages_for_turn(self))
174
+ MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
110
175
  end
111
176
 
112
177
  def thinking_from_options
@@ -116,24 +181,39 @@ module TurnKit
116
181
  agent.effective_thinking
117
182
  end
118
183
 
184
+ def compact_from_options
185
+ options = (@record["options"] || {}).transform_keys(&:to_s)
186
+ options["compact"] if options.key?("compact")
187
+ end
188
+
189
+ def output_schema_from_options
190
+ options = (@record["options"] || {}).transform_keys(&:to_s)
191
+ options["output_schema"] if options.key?("output_schema")
192
+ end
193
+
119
194
  def persist_assistant_message(result)
120
195
  if result.tool_calls?
121
- conversation.append_message(
196
+ message = conversation.append_message(
122
197
  role: "assistant",
123
198
  kind: "tool_call",
124
199
  text: result.text,
125
200
  turn_id: id,
126
201
  metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
127
202
  )
203
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
204
+ result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
128
205
  else
129
- conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id)
206
+ message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
207
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
130
208
  end
131
209
  end
132
210
 
133
211
  def complete_from_terminal_tool(runner, execution)
134
212
  message = runner.completion_message(execution)
135
- conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
213
+ assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
214
+ emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
136
215
  update!(status: "completed", output_text: message, completed_at: Clock.now)
216
+ emit("turn.completed", status: status, output_text: message)
137
217
  end
138
218
 
139
219
  def add_usage!(usage, cost: nil)
@@ -164,6 +244,11 @@ module TurnKit
164
244
  @model = @record["model"] || agent.effective_model
165
245
  @record
166
246
  end
247
+
248
+ def emit_event(event)
249
+ event = Event.new(type: event[:type] || event["type"], turn_id: id, conversation_id: conversation.id, payload: event[:payload] || event["payload"] || {}) if event.is_a?(Hash)
250
+ Array(@on_event || agent.effective_on_event).each { |callback| callback.call(event) }
251
+ end
167
252
  end
168
253
 
169
254
  class ToolContext