turnkit 0.2.6 → 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.
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, :compact
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
@@ -24,28 +24,29 @@ module TurnKit
24
24
  @model = @record["model"] || agent.effective_model
25
25
  @thinking = thinking_from_options
26
26
  @compact = compact_from_options
27
+ @output_schema = output_schema_from_options
27
28
  @started_at = @record["started_at"]
28
29
  @budget = budget || agent.build_budget
29
30
  @depth = depth
31
+ @on_event = on_event
30
32
  end
31
33
 
32
- def run!
34
+ def run!(&block)
35
+ @on_event = block if block
33
36
  return self unless status == "pending"
34
37
 
35
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)
36
41
  loop do
37
42
  budget.check!(depth: depth)
38
43
  budget.count_iteration!
39
44
  TurnKit::Compaction.maybe_compact!(self)
40
45
 
41
- result = agent.effective_client.chat(
42
- model: model,
43
- messages: llm_messages,
44
- tools: agent.effective_tools,
45
- instructions: agent.system_prompt_for(turn: self, conversation: conversation),
46
- thinking: thinking,
47
- metadata: { turn_id: id, conversation_id: conversation.id }
48
- )
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)
49
50
  result_cost = Cost.from_usage(result.usage, model: result.model || model)
50
51
 
51
52
  budget.add_cost!(result_cost.total)
@@ -60,7 +61,8 @@ module TurnKit
60
61
  break
61
62
  end
62
63
  else
63
- 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)
64
66
  break
65
67
  end
66
68
  end
@@ -68,10 +70,15 @@ module TurnKit
68
70
  self
69
71
  rescue StandardError => error
70
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 })
71
74
  reload
72
75
  self
73
76
  end
74
77
 
78
+ def preview
79
+ model_request
80
+ end
81
+
75
82
  def status
76
83
  @record.fetch("status")
77
84
  end
@@ -84,6 +91,10 @@ module TurnKit
84
91
  @record["output_text"].to_s
85
92
  end
86
93
 
94
+ def output_data
95
+ @record["output_data"]
96
+ end
97
+
87
98
  def usage
88
99
  Usage.from_h(@record["usage"] || {})
89
100
  end
@@ -100,6 +111,7 @@ module TurnKit
100
111
  @record = store.load_turn(id)
101
112
  @thinking = thinking_from_options
102
113
  @compact = compact_from_options
114
+ @output_schema = output_schema_from_options
103
115
  self
104
116
  end
105
117
 
@@ -107,7 +119,57 @@ module TurnKit
107
119
  update!(status: "stale", completed_at: Clock.now)
108
120
  end
109
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
+
110
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
+
111
173
  def llm_messages
112
174
  MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
113
175
  end
@@ -124,24 +186,34 @@ module TurnKit
124
186
  options["compact"] if options.key?("compact")
125
187
  end
126
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
+
127
194
  def persist_assistant_message(result)
128
195
  if result.tool_calls?
129
- conversation.append_message(
196
+ message = conversation.append_message(
130
197
  role: "assistant",
131
198
  kind: "tool_call",
132
199
  text: result.text,
133
200
  turn_id: id,
134
201
  metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
135
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) }
136
205
  else
137
- 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)
138
208
  end
139
209
  end
140
210
 
141
211
  def complete_from_terminal_tool(runner, execution)
142
212
  message = runner.completion_message(execution)
143
- 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)
144
215
  update!(status: "completed", output_text: message, completed_at: Clock.now)
216
+ emit("turn.completed", status: status, output_text: message)
145
217
  end
146
218
 
147
219
  def add_usage!(usage, cost: nil)
@@ -172,6 +244,11 @@ module TurnKit
172
244
  @model = @record["model"] || agent.effective_model
173
245
  @record
174
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
175
252
  end
176
253
 
177
254
  class ToolContext
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.6"
4
+ VERSION = "0.2.7"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "turnkit/id"
12
12
  require_relative "turnkit/clock"
13
13
  require_relative "turnkit/cost"
14
14
  require_relative "turnkit/budget"
15
+ require_relative "turnkit/event"
16
+ require_relative "turnkit/model_request"
15
17
  require_relative "turnkit/agent"
16
18
  require_relative "turnkit/client"
17
19
  require_relative "turnkit/conversation"
@@ -49,6 +51,7 @@ module TurnKit
49
51
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
50
52
  attr_accessor :prompt_data_max_chars, :context_contributors
51
53
  attr_accessor :system_prompt_contributors, :model_prompt_contributors
54
+ attr_accessor :on_event
52
55
  attr_accessor :conversation_record_class, :turn_record_class
53
56
  attr_accessor :message_record_class, :tool_execution_record_class
54
57
  end
@@ -69,6 +72,7 @@ module TurnKit
69
72
  self.context_contributors = []
70
73
  self.system_prompt_contributors = []
71
74
  self.model_prompt_contributors = {}
75
+ self.on_event = nil
72
76
 
73
77
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
74
78
  store.find_stale_turns(before: before).each do |turn|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
@@ -24,9 +24,8 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.14'
27
- description: TurnKit provides a small Ruby runtime for AI agents with conversations,
28
- turns, tool calls, terminal tools, file-based skills, sub-agents, and optional Rails
29
- persistence.
27
+ description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, tool
28
+ calling, skills, sub-agents, context compaction, and persistence.
30
29
  email:
31
30
  - sam@samcouch.com
32
31
  executables: []
@@ -46,6 +45,7 @@ files:
46
45
  - lib/turnkit/conversation.rb
47
46
  - lib/turnkit/cost.rb
48
47
  - lib/turnkit/error.rb
48
+ - lib/turnkit/event.rb
49
49
  - lib/turnkit/generators/turnkit/install/templates/conversation.rb
50
50
  - lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb
51
51
  - lib/turnkit/generators/turnkit/install/templates/initializer.rb
@@ -57,6 +57,7 @@ files:
57
57
  - lib/turnkit/memory_store.rb
58
58
  - lib/turnkit/message.rb
59
59
  - lib/turnkit/message_projection.rb
60
+ - lib/turnkit/model_request.rb
60
61
  - lib/turnkit/prompt_context.rb
61
62
  - lib/turnkit/prompt_contribution.rb
62
63
  - lib/turnkit/prompt_data.rb
@@ -102,5 +103,5 @@ requirements: []
102
103
  rubygems_version: 3.5.22
103
104
  signing_key:
104
105
  specification_version: 4
105
- summary: Durable Ruby AI agent turns, tools, skills, and conversations.
106
+ summary: Ruby/Rails agent runtime for durable AI conversations.
106
107
  test_files: []