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/turn.rb CHANGED
@@ -6,7 +6,7 @@ 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, :output_schema
9
+ attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact, :output_schema, :prompt_mode
10
10
  attr_reader :started_at
11
11
 
12
12
  def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0, on_event: nil)
@@ -25,6 +25,7 @@ module TurnKit
25
25
  @thinking = thinking_from_options
26
26
  @compact = compact_from_options
27
27
  @output_schema = output_schema_from_options
28
+ @prompt_mode = prompt_mode_from_options
28
29
  @started_at = @record["started_at"]
29
30
  @budget = budget || agent.build_budget
30
31
  @depth = depth
@@ -44,13 +45,13 @@ module TurnKit
44
45
  TurnKit::Compaction.maybe_compact!(self)
45
46
 
46
47
  request = model_request
47
- emit("model.requested", model: request.model, tool_names: request.tool_names)
48
+ emit_model_requested("model.requested", request)
48
49
  result = call_client(request)
49
- emit("model.completed", model: result.model || model, tool_call_count: result.tool_calls.length)
50
50
  result_cost = Cost.from_usage(result.usage, model: result.model || model)
51
51
 
52
- budget.add_cost!(result_cost.total)
53
52
  add_usage!(result.usage, cost: result_cost)
53
+ emit_model_completed("model.completed", result, result_cost, model: model)
54
+ budget.add_cost!(result_cost.total)
54
55
  persist_assistant_message(result)
55
56
 
56
57
  if result.tool_calls?
@@ -61,8 +62,7 @@ module TurnKit
61
62
  break
62
63
  end
63
64
  else
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)
65
+ complete_with_output(result.text, output_data: result.output_data)
66
66
  break
67
67
  end
68
68
  end
@@ -95,6 +95,10 @@ module TurnKit
95
95
  @record["output_data"]
96
96
  end
97
97
 
98
+ def output_audit
99
+ (@record["options"] || {})["output_audit"]
100
+ end
101
+
98
102
  def usage
99
103
  Usage.from_h(@record["usage"] || {})
100
104
  end
@@ -112,6 +116,7 @@ module TurnKit
112
116
  @thinking = thinking_from_options
113
117
  @compact = compact_from_options
114
118
  @output_schema = output_schema_from_options
119
+ @prompt_mode = prompt_mode_from_options
115
120
  self
116
121
  end
117
122
 
@@ -123,9 +128,31 @@ module TurnKit
123
128
  emit_event(Event.new(type: type, turn_id: id, conversation_id: conversation.id, payload: payload))
124
129
  end
125
130
 
131
+ def internal_model_call(model:, messages:, instructions:, tools: [], thinking: nil, output_schema: nil, metadata: {}, purpose:, client: nil)
132
+ request = ModelRequest.new(
133
+ model: model,
134
+ messages: messages,
135
+ tools: tools,
136
+ instructions: instructions,
137
+ thinking: thinking,
138
+ output_schema: output_schema,
139
+ metadata: { purpose: purpose.to_s, turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
140
+ )
141
+ model_client = client || agent.effective_client
142
+ model_client.validate!(model: request.model)
143
+
144
+ emit_model_requested("#{purpose}.model.requested", request)
145
+ result = call_client(request, client: model_client)
146
+ result_cost = Cost.from_usage(result.usage, model: result.model || request.model)
147
+ add_usage!(result.usage, cost: result_cost)
148
+ emit_model_completed("#{purpose}.model.completed", result, result_cost, model: request.model)
149
+ budget.add_cost!(result_cost.total)
150
+ result
151
+ end
152
+
126
153
  private
127
154
  def model_request
128
- prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: agent.effective_prompt_mode(turn: self))
155
+ prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
129
156
  instructions = case agent.system_prompt
130
157
  when nil
131
158
  prompt.to_s
@@ -146,7 +173,7 @@ module TurnKit
146
173
  )
147
174
  end
148
175
 
149
- def call_client(request)
176
+ def call_client(request, client: agent.effective_client)
150
177
  kwargs = {
151
178
  model: request.model,
152
179
  messages: request.messages,
@@ -157,9 +184,9 @@ module TurnKit
157
184
  metadata: request.metadata,
158
185
  on_event: ->(event) { emit_event(event) }
159
186
  }
160
- accepted = chat_keyword_names(agent.effective_client)
187
+ accepted = chat_keyword_names(client)
161
188
  kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
162
- agent.effective_client.chat(**kwargs)
189
+ client.chat(**kwargs)
163
190
  end
164
191
 
165
192
  def chat_keyword_names(client)
@@ -174,6 +201,26 @@ module TurnKit
174
201
  MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
175
202
  end
176
203
 
204
+ def emit_model_requested(type, request)
205
+ emit(
206
+ type,
207
+ model: request.model,
208
+ tool_names: request.tool_names,
209
+ message_count: request.messages.length,
210
+ prompt: request.report
211
+ )
212
+ end
213
+
214
+ def emit_model_completed(type, result, cost, model: self.model)
215
+ emit(
216
+ type,
217
+ model: result.model || model,
218
+ tool_call_count: result.tool_calls.length,
219
+ usage: result.usage.to_h,
220
+ cost: cost.to_h
221
+ )
222
+ end
223
+
177
224
  def thinking_from_options
178
225
  options = (@record["options"] || {}).transform_keys(&:to_s)
179
226
  return Agent.normalize_thinking(options["thinking"]) if options.key?("thinking")
@@ -191,6 +238,11 @@ module TurnKit
191
238
  options["output_schema"] if options.key?("output_schema")
192
239
  end
193
240
 
241
+ def prompt_mode_from_options
242
+ options = (@record["options"] || {}).transform_keys(&:to_s)
243
+ options["prompt_mode"]&.to_sym if options.key?("prompt_mode")
244
+ end
245
+
194
246
  def persist_assistant_message(result)
195
247
  if result.tool_calls?
196
248
  message = conversation.append_message(
@@ -212,8 +264,40 @@ module TurnKit
212
264
  message = runner.completion_message(execution)
213
265
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
214
266
  emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
215
- update!(status: "completed", output_text: message, completed_at: Clock.now)
216
- emit("turn.completed", status: status, output_text: message)
267
+ complete_with_output(message)
268
+ end
269
+
270
+ def complete_with_output(text, output_data: nil)
271
+ audit = audit_output(text, output_data: output_data)
272
+ attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
273
+ if audit && !audit.clean? && agent.output_audit_mode == :fail
274
+ attrs[:status] = "failed"
275
+ attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "output_audit" => audit.to_h }
276
+ else
277
+ attrs[:status] = "completed"
278
+ end
279
+ update!(attrs)
280
+ persist_output_audit(audit) if audit
281
+
282
+ if failed?
283
+ emit("turn.failed", error: @record["error"])
284
+ else
285
+ emit("turn.completed", status: status, output_text: text)
286
+ end
287
+ end
288
+
289
+ def audit_output(text, output_data: nil)
290
+ constraints = agent.effective_output_audit
291
+ return nil if constraints.empty?
292
+
293
+ output = output_data.nil? ? text : output_data
294
+ TurnKit.audit_output(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
295
+ end
296
+
297
+ def persist_output_audit(audit)
298
+ options = (@record["options"] || {}).merge("output_audit" => audit.to_h)
299
+ update!(options: options)
300
+ emit("output_audit.completed", clean: audit.clean?, violation_count: audit.violations.length)
217
301
  end
218
302
 
219
303
  def add_usage!(usage, cost: nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.10"
5
5
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent"
4
+
3
5
  module TurnKit
4
- class Fleet
6
+ class Workflow
5
7
  attr_reader :name, :description, :instructions, :tools, :skills, :available_skills
6
8
  attr_reader :model, :client, :store, :prompt_mode, :thinking, :compaction, :output_schema
7
- attr_reader :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
9
+ attr_reader :on_event
10
+ attr_reader :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions, :max_tool_executions_by_name
11
+ attr_reader :output_audit, :output_audit_mode, :output_policy, :output_policy_mode, :output_policy_model, :output_policy_thinking
8
12
 
9
13
  DEFAULT_INSTRUCTIONS = <<~TEXT.strip
10
14
  You are an autonomous task orchestrator. Navigate from the application
@@ -15,17 +19,22 @@ module TurnKit
15
19
  patterns. Iterate when work needs missing context, critique, revision, or
16
20
  verification.
17
21
 
22
+ When multiple independent items need the same kind of fetch or read, and
23
+ an available batch tool can handle them in one call, prefer the batch tool
24
+ over repeated one-item tool calls.
25
+
18
26
  Stop when the task is complete, when the available context and tools are
19
27
  sufficient for the best possible answer, or when further iteration would
20
28
  not materially improve the result. Respect runtime, cost, and iteration
21
29
  limits.
22
30
  TEXT
23
31
 
24
- def initialize(name: "orchestrator", description: "", instructions: nil,
32
+ def initialize(name: "workflow", description: "", instructions: nil,
25
33
  tools: [], skills: [], available_skills: [], model: nil, client: nil,
26
- store: nil, prompt_mode: :task, thinking: nil, compaction: nil,
34
+ store: nil, prompt_mode: :task, thinking: nil, compaction: nil, on_event: nil,
27
35
  output_schema: nil, max_iterations: nil, timeout: nil, max_spend: nil,
28
- cost_limit: nil, max_depth: nil, max_tool_executions: nil)
36
+ cost_limit: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil,
37
+ output_audit: nil, output_audit_mode: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil)
29
38
 
30
39
  @name = name.to_s
31
40
  @description = description.to_s
@@ -39,14 +48,22 @@ module TurnKit
39
48
  @prompt_mode = prompt_mode
40
49
  @thinking = thinking
41
50
  @compaction = compaction
51
+ @on_event = on_event
42
52
  @output_schema = output_schema
43
53
  @max_iterations = max_iterations
44
54
  @timeout = timeout
45
55
  @cost_limit = cost_limit || max_spend
46
56
  @max_depth = max_depth
47
57
  @max_tool_executions = max_tool_executions
58
+ @max_tool_executions_by_name = max_tool_executions_by_name
59
+ @output_audit = output_audit
60
+ @output_audit_mode = output_audit_mode
61
+ @output_policy = output_policy
62
+ @output_policy_mode = output_policy_mode
63
+ @output_policy_model = output_policy_model
64
+ @output_policy_thinking = output_policy_thinking
48
65
  raise ArgumentError, "name is required" if @name.empty?
49
- build_agent
66
+ @agent = build_agent
50
67
  end
51
68
 
52
69
  def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {},
@@ -55,7 +72,13 @@ module TurnKit
55
72
  task = task || prompt
56
73
  raise ArgumentError, "task is required" if task.to_s.empty?
57
74
 
58
- build_agent(cost_limit: cost_limit || max_spend, **options).run(
75
+ runtime_agent = if options.empty? && cost_limit.nil? && max_spend.nil?
76
+ @agent
77
+ else
78
+ build_agent(cost_limit: cost_limit || max_spend, **options)
79
+ end
80
+
81
+ runtime_agent.run(
59
82
  task,
60
83
  input: input,
61
84
  async: async,
@@ -64,11 +87,8 @@ module TurnKit
64
87
  )
65
88
  end
66
89
 
67
- alias_method :auto_run, :run
68
- alias_method :autorun, :run
69
-
70
90
  def agent(**options)
71
- build_agent(**options)
91
+ options.empty? ? @agent : build_agent(**options)
72
92
  end
73
93
 
74
94
  def max_spend
@@ -90,16 +110,23 @@ module TurnKit
90
110
  prompt_mode: prompt_mode,
91
111
  thinking: thinking,
92
112
  compaction: compaction,
113
+ on_event: on_event,
93
114
  output_schema: output_schema,
94
115
  max_iterations: max_iterations,
95
116
  timeout: timeout,
96
117
  cost_limit: cost_limit,
97
118
  max_depth: max_depth,
98
- max_tool_executions: max_tool_executions
119
+ max_tool_executions: max_tool_executions,
120
+ max_tool_executions_by_name: max_tool_executions_by_name,
121
+ output_audit: output_audit,
122
+ output_audit_mode: output_audit_mode,
123
+ output_policy: output_policy,
124
+ output_policy_mode: output_policy_mode,
125
+ output_policy_model: output_policy_model,
126
+ output_policy_thinking: output_policy_thinking
99
127
  }
100
128
  attrs.merge!(overrides.compact)
101
129
  Agent.new(**attrs)
102
130
  end
103
131
  end
104
-
105
132
  end
data/lib/turnkit.rb CHANGED
@@ -5,6 +5,7 @@ require "digest"
5
5
  require "securerandom"
6
6
  require "time"
7
7
  require "date"
8
+ require "pathname"
8
9
 
9
10
  require_relative "turnkit/version"
10
11
  require_relative "turnkit/error"
@@ -15,13 +16,15 @@ require_relative "turnkit/budget"
15
16
  require_relative "turnkit/event"
16
17
  require_relative "turnkit/model_request"
17
18
  require_relative "turnkit/agent"
18
- require_relative "turnkit/fleet"
19
+ require_relative "turnkit/workflow"
19
20
  require_relative "turnkit/client"
20
21
  require_relative "turnkit/conversation"
21
22
  require_relative "turnkit/message"
22
23
  require_relative "turnkit/record"
23
24
  require_relative "turnkit/result"
24
25
  require_relative "turnkit/skill"
26
+ require_relative "turnkit/output_audit"
27
+ require_relative "turnkit/output_policy"
25
28
  require_relative "turnkit/prompt_data"
26
29
  require_relative "turnkit/prompt_context"
27
30
  require_relative "turnkit/prompt_contribution"
@@ -38,6 +41,7 @@ require_relative "turnkit/tool_runner"
38
41
  require_relative "turnkit/turn"
39
42
  require_relative "turnkit/usage"
40
43
  require_relative "turnkit/run"
44
+ require_relative "turnkit/adapters/codex"
41
45
  require_relative "turnkit/adapters/ruby_llm"
42
46
  require_relative "turnkit/stores/active_record_store"
43
47
 
@@ -47,8 +51,10 @@ module TurnKit
47
51
  class << self
48
52
  attr_accessor :default_model, :client, :store, :logger
49
53
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
54
+ attr_accessor :max_tool_executions_by_name
50
55
  attr_accessor :cost_limit, :prompt_cache
51
56
  attr_accessor :compaction
57
+ attr_accessor :output_policy_model, :output_policy_thinking
52
58
  attr_accessor :cost_rates, :cost_calculator
53
59
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
54
60
  attr_accessor :prompt_data_max_chars, :context_contributors
@@ -65,6 +71,7 @@ module TurnKit
65
71
  self.timeout = 300
66
72
  self.max_depth = 3
67
73
  self.max_tool_executions = 100
74
+ self.max_tool_executions_by_name = {}
68
75
  self.prompt_cache = :auto
69
76
  self.compaction = true
70
77
  self.cost_rates = {}
@@ -75,6 +82,8 @@ module TurnKit
75
82
  self.system_prompt_contributors = []
76
83
  self.model_prompt_contributors = {}
77
84
  self.on_event = nil
85
+ self.output_policy_model = nil
86
+ self.output_policy_thinking = { effort: :low }
78
87
 
79
88
  def self.configure
80
89
  yield self
@@ -96,13 +105,13 @@ module TurnKit
96
105
  self.cost_limit = value
97
106
  end
98
107
 
99
- def self.fleet(name = "orchestrator", **options)
100
- Fleet.new(name: name, **options)
101
- end
102
-
103
108
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
104
109
  store.find_stale_turns(before: before).each do |turn|
105
110
  store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
106
111
  end
107
112
  end
113
+
114
+ def self.audit_output(output, constraints: [], context: {})
115
+ OutputAudit.check(output, constraints: constraints, context: context)
116
+ end
108
117
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.2.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -24,8 +24,9 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.14'
27
- description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, tool
28
- calling, skills, sub-agents, context compaction, and persistence.
27
+ description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
28
+ runs, reusable workflows, tool calling, skills, sub-agents, context compaction,
29
+ and persistence.
29
30
  email:
30
31
  - sam@samcouch.com
31
32
  executables: []
@@ -37,6 +38,7 @@ files:
37
38
  - README.md
38
39
  - UPGRADE.md
39
40
  - lib/turnkit.rb
41
+ - lib/turnkit/adapters/codex.rb
40
42
  - lib/turnkit/adapters/ruby_llm.rb
41
43
  - lib/turnkit/agent.rb
42
44
  - lib/turnkit/budget.rb
@@ -47,7 +49,6 @@ files:
47
49
  - lib/turnkit/cost.rb
48
50
  - lib/turnkit/error.rb
49
51
  - lib/turnkit/event.rb
50
- - lib/turnkit/fleet.rb
51
52
  - lib/turnkit/generators/turnkit/install/templates/conversation.rb
52
53
  - lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb
53
54
  - lib/turnkit/generators/turnkit/install/templates/initializer.rb
@@ -60,6 +61,8 @@ files:
60
61
  - lib/turnkit/message.rb
61
62
  - lib/turnkit/message_projection.rb
62
63
  - lib/turnkit/model_request.rb
64
+ - lib/turnkit/output_audit.rb
65
+ - lib/turnkit/output_policy.rb
63
66
  - lib/turnkit/prompt_context.rb
64
67
  - lib/turnkit/prompt_contribution.rb
65
68
  - lib/turnkit/prompt_data.rb
@@ -79,6 +82,7 @@ files:
79
82
  - lib/turnkit/turn.rb
80
83
  - lib/turnkit/usage.rb
81
84
  - lib/turnkit/version.rb
85
+ - lib/turnkit/workflow.rb
82
86
  homepage: https://github.com/samuelcouch/turnkit
83
87
  licenses:
84
88
  - MIT
@@ -106,5 +110,5 @@ requirements: []
106
110
  rubygems_version: 3.5.22
107
111
  signing_key:
108
112
  specification_version: 4
109
- summary: Ruby/Rails agent runtime for durable AI conversations.
113
+ summary: Ruby/Rails agent runtime for durable AI conversations, runs, and workflows.
110
114
  test_files: []