turnkit 0.2.10 → 0.3.0

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
@@ -36,12 +36,18 @@ module TurnKit
36
36
  @on_event = block if block
37
37
  return self unless status == "pending"
38
38
 
39
- update!(status: "running", started_at: Clock.now, heartbeat_at: Clock.now)
39
+ claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
40
+ return self unless claimed
41
+
42
+ @record = claimed
43
+ @started_at = @record["started_at"]
40
44
  emit("turn.started", status: status, model: model)
41
45
  agent.effective_client.validate!(model: model)
46
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
47
+ revisions_used = 0
42
48
  loop do
43
49
  budget.check!(depth: depth)
44
- budget.count_iteration!
50
+ count_iteration!
45
51
  TurnKit::Compaction.maybe_compact!(self)
46
52
 
47
53
  request = model_request
@@ -58,13 +64,24 @@ module TurnKit
58
64
  runner = ToolRunner.new(self)
59
65
  terminal = runner.dispatch(result.tool_calls)
60
66
  if terminal
61
- complete_from_terminal_tool(runner, terminal)
62
- break
67
+ candidate = append_terminal_completion(runner, terminal)
68
+ else
69
+ next
63
70
  end
64
71
  else
65
- complete_with_output(result.text, output_data: result.output_data)
66
- break
72
+ candidate = result.text
67
73
  end
74
+
75
+ audit = check_policy(candidate, output_data: result.output_data)
76
+ if should_revise?(audit, revisions_used)
77
+ revisions_used += 1
78
+ append_revision_message(audit, attempt: revisions_used, terminal_tool_name: terminal&.tool_name)
79
+ emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used)
80
+ next
81
+ end
82
+
83
+ complete_with_output(candidate, output_data: result.output_data, audit: audit)
84
+ break
68
85
  end
69
86
  reload
70
87
  self
@@ -95,8 +112,8 @@ module TurnKit
95
112
  @record["output_data"]
96
113
  end
97
114
 
98
- def output_audit
99
- (@record["options"] || {})["output_audit"]
115
+ def policy_audit
116
+ (@record["options"] || {})["policy_audit"]
100
117
  end
101
118
 
102
119
  def usage
@@ -248,9 +265,9 @@ module TurnKit
248
265
  message = conversation.append_message(
249
266
  role: "assistant",
250
267
  kind: "tool_call",
251
- text: result.text,
268
+ content: result.parts,
252
269
  turn_id: id,
253
- metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
270
+ metadata: {}
254
271
  )
255
272
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
256
273
  result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
@@ -260,24 +277,23 @@ module TurnKit
260
277
  end
261
278
  end
262
279
 
263
- def complete_from_terminal_tool(runner, execution)
280
+ def append_terminal_completion(runner, execution)
264
281
  message = runner.completion_message(execution)
265
282
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
266
283
  emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
267
- complete_with_output(message)
284
+ message
268
285
  end
269
286
 
270
- def complete_with_output(text, output_data: nil)
271
- audit = audit_output(text, output_data: output_data)
287
+ def complete_with_output(text, output_data: nil, audit: nil)
272
288
  attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
273
- if audit && !audit.clean? && agent.output_audit_mode == :fail
289
+ if audit && !audit.clean? && agent.output_policy_mode == :fail
274
290
  attrs[:status] = "failed"
275
- attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "output_audit" => audit.to_h }
291
+ attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "policy_audit" => audit.to_h }
276
292
  else
277
293
  attrs[:status] = "completed"
278
294
  end
279
295
  update!(attrs)
280
- persist_output_audit(audit) if audit
296
+ persist_policy_audit(audit) if audit
281
297
 
282
298
  if failed?
283
299
  emit("turn.failed", error: @record["error"])
@@ -286,18 +302,48 @@ module TurnKit
286
302
  end
287
303
  end
288
304
 
289
- def audit_output(text, output_data: nil)
290
- constraints = agent.effective_output_audit
305
+ def check_policy(text, output_data: nil)
306
+ constraints = agent.effective_output_policy
291
307
  return nil if constraints.empty?
292
308
 
293
309
  output = output_data.nil? ? text : output_data
294
- TurnKit.audit_output(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
310
+ TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
295
311
  end
296
312
 
297
- def persist_output_audit(audit)
298
- options = (@record["options"] || {}).merge("output_audit" => audit.to_h)
313
+ def persist_policy_audit(audit)
314
+ options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
299
315
  update!(options: options)
300
- emit("output_audit.completed", clean: audit.clean?, violation_count: audit.violations.length)
316
+ emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
317
+ end
318
+
319
+ def should_revise?(audit, revisions_used)
320
+ audit && !audit.clean? && revisions_used < agent.output_retries
321
+ end
322
+
323
+ def append_revision_message(audit, attempt:, terminal_tool_name: nil)
324
+ text = <<~TEXT.strip
325
+ The previous output failed policy checks.
326
+
327
+ Revise the previous output. Do not introduce new claims.
328
+ Do not deviate from the skill or policy below.
329
+
330
+ #{revision_policy_blocks}
331
+
332
+ Violations:
333
+ #{audit.violations.each_with_index.map { |violation, index| "#{index + 1}. #{violation.rule}: #{violation.message}" }.join("\n")}
334
+ #{terminal_tool_name ? "\nResubmit via #{terminal_tool_name}." : ""}
335
+ TEXT
336
+ message = conversation.append_message(role: "user", kind: "text", text: text, turn_id: id, metadata: { "source" => "output_policy", "attempt" => attempt })
337
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
338
+ end
339
+
340
+ def revision_policy_blocks
341
+ agent.effective_output_policy.filter_map do |policy|
342
+ next unless policy.respond_to?(:content)
343
+
344
+ key = policy.respond_to?(:name) ? policy.name : "output_policy"
345
+ "<skill key=\"#{key}\">\n#{policy.content}\n</skill>"
346
+ end.join("\n\n")
301
347
  end
302
348
 
303
349
  def add_usage!(usage, cost: nil)
@@ -316,6 +362,27 @@ module TurnKit
316
362
  update!(attributes)
317
363
  end
318
364
 
365
+ def count_iteration!
366
+ budget.count_iteration!
367
+ options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
368
+ update!(options: options)
369
+ end
370
+
371
+ def heartbeat!
372
+ update!(heartbeat_at: Clock.now)
373
+ end
374
+
375
+ def budget_limits
376
+ {
377
+ max_iterations: agent.max_iterations || TurnKit.max_iterations,
378
+ timeout: agent.timeout || TurnKit.timeout,
379
+ max_depth: agent.max_depth || TurnKit.max_depth,
380
+ max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
381
+ max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
382
+ max_spend: agent.max_spend || TurnKit.max_spend
383
+ }
384
+ end
385
+
319
386
  def aggregate_cost(current, cost)
320
387
  return cost unless current
321
388
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.10"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -4,13 +4,7 @@ require_relative "agent"
4
4
 
5
5
  module TurnKit
6
6
  class Workflow
7
- attr_reader :name, :description, :instructions, :tools, :skills, :available_skills
8
- attr_reader :model, :client, :store, :prompt_mode, :thinking, :compaction, :output_schema
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
12
-
13
- DEFAULT_INSTRUCTIONS = <<~TEXT.strip
7
+ ORCHESTRATOR_PREAMBLE = <<~TEXT.strip
14
8
  You are an autonomous task orchestrator. Navigate from the application
15
9
  request to a final output without asking the user follow-up questions.
16
10
 
@@ -29,104 +23,36 @@ module TurnKit
29
23
  limits.
30
24
  TEXT
31
25
 
32
- def initialize(name: "workflow", description: "", instructions: nil,
33
- tools: [], skills: [], available_skills: [], model: nil, client: nil,
34
- store: nil, prompt_mode: :task, thinking: nil, compaction: nil, on_event: nil,
35
- output_schema: nil, max_iterations: nil, timeout: nil, max_spend: 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)
26
+ DEFAULT_INSTRUCTIONS = ORCHESTRATOR_PREAMBLE
27
+
28
+ attr_reader :name, :options
38
29
 
30
+ def initialize(name: "workflow", instructions: nil, preamble: true, **options)
39
31
  @name = name.to_s
40
- @description = description.to_s
41
- @instructions = instructions || DEFAULT_INSTRUCTIONS
42
- @tools = Array(tools)
43
- @skills = Array(skills)
44
- @available_skills = Array(available_skills)
45
- @model = model
46
- @client = client
47
- @store = store
48
- @prompt_mode = prompt_mode
49
- @thinking = thinking
50
- @compaction = compaction
51
- @on_event = on_event
52
- @output_schema = output_schema
53
- @max_iterations = max_iterations
54
- @timeout = timeout
55
- @cost_limit = cost_limit || max_spend
56
- @max_depth = max_depth
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
65
32
  raise ArgumentError, "name is required" if @name.empty?
66
- @agent = build_agent
67
- end
68
-
69
- def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {},
70
- max_spend: nil, cost_limit: nil, **options)
71
-
72
- task = task || prompt
73
- raise ArgumentError, "task is required" if task.to_s.empty?
74
-
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
33
 
81
- runtime_agent.run(
82
- task,
83
- input: input,
84
- async: async,
85
- subject: subject,
86
- metadata: metadata
87
- )
34
+ @options = options.merge(
35
+ name: @name,
36
+ prompt_mode: options.fetch(:prompt_mode, :task),
37
+ instructions: compose_instructions(instructions, preamble: preamble)
38
+ ).freeze
39
+ @agent = Agent.new(**@options)
88
40
  end
89
41
 
90
- def agent(**options)
91
- options.empty? ? @agent : build_agent(**options)
42
+ def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, **overrides)
43
+ agent(**overrides).run(task || prompt, input: input, async: async, subject: subject, metadata: metadata)
92
44
  end
93
45
 
94
- def max_spend
95
- cost_limit
46
+ def agent(**overrides)
47
+ overrides.empty? ? @agent : Agent.new(**@options.merge(overrides.compact))
96
48
  end
97
49
 
98
50
  private
99
- def build_agent(**overrides)
100
- attrs = {
101
- name: name,
102
- description: description,
103
- instructions: instructions,
104
- tools: tools,
105
- skills: skills,
106
- available_skills: available_skills,
107
- model: model,
108
- client: client,
109
- store: store,
110
- prompt_mode: prompt_mode,
111
- thinking: thinking,
112
- compaction: compaction,
113
- on_event: on_event,
114
- output_schema: output_schema,
115
- max_iterations: max_iterations,
116
- timeout: timeout,
117
- cost_limit: cost_limit,
118
- max_depth: max_depth,
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
127
- }
128
- attrs.merge!(overrides.compact)
129
- Agent.new(**attrs)
51
+ def compose_instructions(instructions, preamble:)
52
+ parts = []
53
+ parts << ORCHESTRATOR_PREAMBLE if preamble
54
+ parts << instructions.to_s.strip unless instructions.to_s.strip.empty?
55
+ parts.join("\n\n")
130
56
  end
131
57
  end
132
58
  end
data/lib/turnkit.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "turnkit/cost"
15
15
  require_relative "turnkit/budget"
16
16
  require_relative "turnkit/event"
17
17
  require_relative "turnkit/model_request"
18
+ require_relative "turnkit/schema_check"
18
19
  require_relative "turnkit/agent"
19
20
  require_relative "turnkit/workflow"
20
21
  require_relative "turnkit/client"
@@ -36,6 +37,7 @@ require_relative "turnkit/tool"
36
37
  require_relative "turnkit/tool_call"
37
38
  require_relative "turnkit/tool_execution"
38
39
  require_relative "turnkit/sub_agent_tool"
40
+ require_relative "turnkit/load_skill_tool"
39
41
  require_relative "turnkit/message_projection"
40
42
  require_relative "turnkit/tool_runner"
41
43
  require_relative "turnkit/turn"
@@ -52,7 +54,7 @@ module TurnKit
52
54
  attr_accessor :default_model, :client, :store, :logger
53
55
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
54
56
  attr_accessor :max_tool_executions_by_name
55
- attr_accessor :cost_limit, :prompt_cache
57
+ attr_accessor :max_spend, :prompt_cache
56
58
  attr_accessor :compaction
57
59
  attr_accessor :output_policy_model, :output_policy_thinking
58
60
  attr_accessor :cost_rates, :cost_calculator
@@ -72,6 +74,7 @@ module TurnKit
72
74
  self.max_depth = 3
73
75
  self.max_tool_executions = 100
74
76
  self.max_tool_executions_by_name = {}
77
+ self.max_spend = nil
75
78
  self.prompt_cache = :auto
76
79
  self.compaction = true
77
80
  self.cost_rates = {}
@@ -97,21 +100,13 @@ module TurnKit
97
100
  self.default_model = value
98
101
  end
99
102
 
100
- def self.max_spend
101
- cost_limit
102
- end
103
-
104
- def self.max_spend=(value)
105
- self.cost_limit = value
106
- end
107
-
108
103
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
109
104
  store.find_stale_turns(before: before).each do |turn|
110
105
  store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
111
106
  end
112
107
  end
113
108
 
114
- def self.audit_output(output, constraints: [], context: {})
109
+ def self.check_output_policy(output, constraints: [], context: {})
115
110
  OutputAudit.check(output, constraints: constraints, context: context)
116
111
  end
117
112
  end
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.10
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
@@ -57,6 +57,7 @@ files:
57
57
  - lib/turnkit/generators/turnkit/install/templates/turn.rb
58
58
  - lib/turnkit/generators/turnkit/install_generator.rb
59
59
  - lib/turnkit/id.rb
60
+ - lib/turnkit/load_skill_tool.rb
60
61
  - lib/turnkit/memory_store.rb
61
62
  - lib/turnkit/message.rb
62
63
  - lib/turnkit/message_projection.rb
@@ -70,6 +71,7 @@ files:
70
71
  - lib/turnkit/record.rb
71
72
  - lib/turnkit/result.rb
72
73
  - lib/turnkit/run.rb
74
+ - lib/turnkit/schema_check.rb
73
75
  - lib/turnkit/skill.rb
74
76
  - lib/turnkit/store.rb
75
77
  - lib/turnkit/stores/active_record_store.rb