turnkit 0.2.9 → 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/agent.rb CHANGED
@@ -3,13 +3,14 @@
3
3
  module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
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, :compaction, :output_schema, :on_event
6
+ attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
8
+ attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
8
9
 
9
10
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
11
  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, compaction: nil,
12
- output_schema: nil, on_event: nil)
12
+ max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
13
+ output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
13
14
  @name = name.to_s
14
15
  @description = description.to_s
15
16
  @model = model
@@ -25,12 +26,18 @@ module TurnKit
25
26
  @store = store
26
27
  @max_iterations = max_iterations
27
28
  @timeout = timeout
28
- @cost_limit = cost_limit
29
+ @max_spend = max_spend
29
30
  @max_depth = max_depth
30
31
  @max_tool_executions = max_tool_executions
32
+ @max_tool_executions_by_name = max_tool_executions_by_name
31
33
  @thinking = self.class.normalize_thinking(thinking)
32
34
  @compaction = compaction
33
35
  @output_schema = output_schema
36
+ @input_schema = input_schema
37
+ @output_policy_model = output_policy_model
38
+ @output_policy = normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
39
+ @output_policy_mode = normalize_output_policy_mode(output_policy_mode)
40
+ @output_retries = Integer(output_retries || 0)
34
41
  @on_event = on_event
35
42
  raise ArgumentError, "name is required" if @name.empty?
36
43
  validate_tools!
@@ -65,6 +72,7 @@ module TurnKit
65
72
  def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
66
73
  task = task || prompt
67
74
  raise ArgumentError, "task is required" if task.to_s.empty?
75
+ SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
68
76
 
69
77
  conversation = self.conversation(subject: subject, metadata: metadata)
70
78
  message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
@@ -94,6 +102,10 @@ module TurnKit
94
102
  thinking
95
103
  end
96
104
 
105
+ def effective_output_policy
106
+ Array(output_policy).compact
107
+ end
108
+
97
109
  def effective_client
98
110
  client || TurnKit.client
99
111
  end
@@ -103,7 +115,9 @@ module TurnKit
103
115
  end
104
116
 
105
117
  def effective_tools
106
- tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
118
+ configured = tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
119
+ skills = effective_available_skills
120
+ skills.empty? ? configured : configured + [ LoadSkillTool.for(skills) ]
107
121
  end
108
122
 
109
123
  def effective_on_event
@@ -143,7 +157,8 @@ module TurnKit
143
157
  timeout: timeout || TurnKit.timeout,
144
158
  max_depth: max_depth || TurnKit.max_depth,
145
159
  max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
146
- cost_limit: cost_limit || TurnKit.cost_limit,
160
+ max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
161
+ max_spend: max_spend || TurnKit.max_spend,
147
162
  root_started_at: root_started_at
148
163
  )
149
164
  end
@@ -170,6 +185,45 @@ module TurnKit
170
185
  effective_tools.each(&:validate_definition!)
171
186
  end
172
187
 
188
+ def normalize_output_policy(value, model: nil, thinking: nil)
189
+ case value
190
+ when nil
191
+ nil
192
+ when Array
193
+ value.map { |item| normalize_output_policy(item, model: model, thinking: thinking) }.compact
194
+ when String
195
+ output_policy_from_path(value, model: model, thinking: thinking)
196
+ when Pathname
197
+ output_policy_from_path(value.to_s, model: model, thinking: thinking)
198
+ when Skill
199
+ OutputPolicy.from_skill(value, model: model || TurnKit.output_policy_model, thinking: thinking || TurnKit.output_policy_thinking)
200
+ else
201
+ return value if value.respond_to?(:call) || value.respond_to?(:check)
202
+
203
+ raise ArgumentError, "output_policy must be a policy file path, a skill, a #call/#check object, or an array of those"
204
+ end
205
+ end
206
+
207
+ def output_policy_from_path(path, model: nil, thinking: nil)
208
+ unless path.match?(/\.(md|markdown|txt)\z/i)
209
+ raise ArgumentError, "output_policy string must be a .md, .markdown, or .txt file path"
210
+ end
211
+
212
+ TurnKit::OutputPolicy.from_file(
213
+ path,
214
+ model: model || TurnKit.output_policy_model,
215
+ thinking: thinking || TurnKit.output_policy_thinking
216
+ )
217
+ end
218
+
219
+ def normalize_output_policy_mode(value)
220
+ value ||= :fail
221
+ mode = value.to_sym
222
+ raise ArgumentError, "unknown output_policy_mode: #{value}" unless %i[report fail].include?(mode)
223
+
224
+ mode
225
+ end
226
+
173
227
  def task_message(task, input)
174
228
  text = task.to_s
175
229
  return text if input.nil?
@@ -2,32 +2,59 @@
2
2
 
3
3
  module TurnKit
4
4
  class Budget
5
- attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :cost_limit
5
+ attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :max_spend
6
6
 
7
- def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, cost_limit: nil, root_started_at: Clock.now)
7
+ def self.resume(store:, root_turn_id:, limits: {})
8
+ turns = store.list_turns(root_turn_id: root_turn_id)
9
+ root = turns.find { |turn| turn.fetch("id") == root_turn_id } || turns.first || {}
10
+ budget = new(**limits.merge(root_started_at: root["started_at"] || Clock.now))
11
+ budget.seed!(turns: turns, tool_executions: turns.flat_map { |turn| store.list_tool_executions(turn_id: turn.fetch("id")) })
12
+ budget
13
+ end
14
+
15
+ def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, max_spend: nil, root_started_at: Clock.now)
8
16
  @root_started_at = root_started_at
9
17
  @max_iterations = max_iterations
10
18
  @timeout = timeout
11
19
  @max_depth = max_depth
12
20
  @max_tool_executions = max_tool_executions
13
- @cost_limit = cost_limit
21
+ @max_tool_executions_by_name = normalize_tool_limits(max_tool_executions_by_name)
22
+ @max_spend = max_spend
14
23
  @iterations = 0
15
24
  @tool_executions = 0
25
+ @tool_executions_by_name = Hash.new(0)
16
26
  @cost = 0
17
27
  @mutex = Mutex.new
18
28
  end
19
29
 
30
+ def seed!(turns:, tool_executions:)
31
+ @mutex.synchronize do
32
+ @iterations = Array(turns).sum { |turn| (turn["options"] || {})["iterations"].to_i }
33
+ completed = Array(tool_executions).select { |execution| %w[completed failed].include?(execution["status"]) && !execution.dig("error", "details", "budget_denied") }
34
+ @tool_executions = completed.length
35
+ completed.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
36
+ @cost = Array(turns).sum { |turn| turn["cost"].to_f }
37
+ end
38
+ self
39
+ end
40
+
20
41
  def count_iteration!
21
42
  @mutex.synchronize do
43
+ raise BudgetError, "maximum iterations reached" if max_iterations && @iterations >= max_iterations
44
+
22
45
  @iterations += 1
23
- raise Error, "maximum iterations reached" if max_iterations && @iterations > max_iterations
24
46
  end
25
47
  end
26
48
 
27
- def count_tool_execution!
49
+ def count_tool_execution!(name = nil)
28
50
  @mutex.synchronize do
51
+ key = name.to_s if name
52
+ limit = max_tool_executions_by_name[key] if key
53
+ raise BudgetError, "maximum tool executions reached" if max_tool_executions && @tool_executions >= max_tool_executions
54
+ raise BudgetError, "maximum executions reached for tool #{key}" if limit && @tool_executions_by_name[key] >= limit
55
+
29
56
  @tool_executions += 1
30
- raise Error, "maximum tool executions reached" if max_tool_executions && @tool_executions > max_tool_executions
57
+ @tool_executions_by_name[key] += 1 if key
31
58
  end
32
59
  end
33
60
 
@@ -36,17 +63,24 @@ module TurnKit
36
63
  end
37
64
 
38
65
  def add_cost!(cost)
39
- return unless cost && cost_limit
66
+ return unless cost && max_spend
40
67
 
41
68
  @mutex.synchronize do
42
69
  @cost += cost.to_f
43
- raise Error, "cost limit reached" if @cost > cost_limit
70
+ raise BudgetError, "cost limit reached" if @cost > max_spend
44
71
  end
45
72
  end
46
73
 
47
74
  def check!(depth:)
48
- raise Error, "maximum sub-agent depth reached" if max_depth && depth > max_depth
49
- raise Error, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
75
+ raise BudgetError, "maximum sub-agent depth reached" if max_depth && depth > max_depth
76
+ raise BudgetError, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
50
77
  end
78
+
79
+ private
80
+ def normalize_tool_limits(value)
81
+ value.to_h.transform_keys(&:to_s).transform_values do |limit|
82
+ limit.nil? ? nil : Integer(limit)
83
+ end
84
+ end
51
85
  end
52
86
  end
@@ -83,6 +83,7 @@ module TurnKit
83
83
  - Keep every section.
84
84
  - Use terse bullets.
85
85
  - Preserve exact file paths, commands, error strings, IDs, and important values.
86
+ - In Tool Results To Remember, record which skill keys were loaded.
86
87
  - Do not invent facts.
87
88
  - Do not include secrets.
88
89
  - Do not include a greeting or preamble.
@@ -117,6 +118,8 @@ module TurnKit
117
118
  return unless force || over_threshold?(messages, policy)
118
119
 
119
120
  compact!(turn.conversation, agent: turn.agent, turn: turn, focus: focus, auto: true, overrides: policy, force: true)
121
+ rescue BudgetError
122
+ raise
120
123
  rescue StandardError => error
121
124
  TurnKit.logger&.warn("TurnKit compaction failed: #{error.class}: #{error.message}")
122
125
  nil
@@ -144,12 +147,15 @@ module TurnKit
144
147
  target_tokens: summary_budget(selected_tokens, policy),
145
148
  fallback_model: turn&.model || conversation.model || agent.effective_model,
146
149
  conversation_id: conversation.id,
147
- turn_id: turn&.id
150
+ turn_id: turn&.id,
151
+ turn: turn
148
152
  )
149
153
 
150
154
  append_summary(conversation, turn: turn, summary: summary, selected: selected, policy: policy, focus: focus, auto: auto, input_tokens: selected_tokens)
151
155
  rescue CompactionError
152
156
  raise
157
+ rescue BudgetError
158
+ raise
153
159
  rescue StandardError => error
154
160
  raise CompactionError, "#{error.class}: #{error.message}"
155
161
  end
@@ -350,18 +356,24 @@ module TurnKit
350
356
  index
351
357
  end
352
358
 
353
- def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:)
359
+ def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:, turn: nil)
354
360
  client = policy["client"] || agent.effective_client
355
361
  model = policy["model"] || fallback_model
356
362
  safe_messages = messages.map { |message| sanitize_message(message, policy) }
357
363
  prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
358
- result = client.chat(
364
+ attrs = {
359
365
  model: model,
360
366
  messages: MessageProjection.for(safe_messages) + [ { role: :user, content: prompt } ],
361
367
  tools: [],
362
368
  instructions: COMPACTION_SYSTEM_PROMPT,
363
369
  metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
364
- )
370
+ }
371
+ result = if turn
372
+ turn.internal_model_call(**attrs, purpose: "compaction", client: policy["client"])
373
+ else
374
+ client.validate!(model: model)
375
+ client.chat(**attrs)
376
+ end
365
377
  text = result.text.to_s.strip
366
378
  raise CompactionError, "compaction model returned an empty summary" if text.empty?
367
379
 
data/lib/turnkit/error.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  module TurnKit
4
4
  class Error < StandardError; end
5
+ class BudgetError < Error; end
5
6
  class ConfigError < Error; end
6
7
  class CompactionError < Error; end
8
+ class InputError < Error; end
7
9
  class ModelAccessError < ConfigError; end
8
10
  class StoreError < Error; end
9
11
  class ToolError < Error; end
@@ -50,7 +50,6 @@ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
50
50
  t.string :kind, null: false
51
51
  t.integer :sequence, null: false
52
52
  t.json :content, null: false, default: []
53
- t.text :text
54
53
  t.string :tool_execution_uid
55
54
  t.string :provider_message_id
56
55
  t.json :metadata, null: false, default: {}
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class LoadSkillTool < Tool
5
+ tool_name "load_skill"
6
+ description "Load the full instructions for an available skill by key."
7
+ parameter :key, :string, required: true, description: "Skill key from <skills_available>."
8
+
9
+ def self.for(skills)
10
+ Class.new(self) do
11
+ tool_name "load_skill"
12
+ @skills = Array(skills).to_h { |skill| [ skill.key, skill ] }
13
+ class << self
14
+ attr_reader :skills
15
+ end
16
+ end
17
+ end
18
+
19
+ def call(key:, context:)
20
+ skill = self.class.skills[key]
21
+ unless skill
22
+ available = self.class.skills.keys.join(", ")
23
+ raise ToolError, "unknown skill: #{key}. Available: #{available}"
24
+ end
25
+
26
+ { "key" => skill.key, "name" => skill.name, "content" => skill.content }
27
+ end
28
+ end
29
+ end
@@ -68,6 +68,17 @@ module TurnKit
68
68
  end
69
69
  end
70
70
 
71
+ def claim_turn(id, from: "pending", to: "running", **attributes)
72
+ attrs = Record.turn_update(attributes.merge(status: to))
73
+ @mutex.synchronize do
74
+ record = @turns.fetch(id)
75
+ return nil unless record["status"] == from
76
+
77
+ record.merge!(attrs.merge("updated_at" => Clock.now))
78
+ duplicate(record)
79
+ end
80
+ end
81
+
71
82
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
72
83
  @mutex.synchronize do
73
84
  rows = @turns.values
@@ -6,7 +6,7 @@ module TurnKit
6
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
- attr_reader :content, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
9
+ attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
10
10
 
11
11
  def initialize(attributes = {})
12
12
  attrs = stringify(attributes)
@@ -16,8 +16,7 @@ module TurnKit
16
16
  @role = attrs.fetch("role").to_s
17
17
  @kind = attrs.fetch("kind", "text").to_s
18
18
  @sequence = attrs.fetch("sequence").to_i
19
- @content = normalize_content(attrs["content"] || attrs["text"])
20
- @text = attrs["text"] || extract_text(@content)
19
+ @content = normalize_content(attrs["content"].nil? ? attrs["text"] : attrs["content"])
21
20
  @tool_execution_id = attrs["tool_execution_id"]
22
21
  @provider_message_id = attrs["provider_message_id"]
23
22
  @metadata = attrs["metadata"] || {}
@@ -35,7 +34,6 @@ module TurnKit
35
34
  "kind" => kind,
36
35
  "sequence" => sequence,
37
36
  "content" => content,
38
- "text" => text,
39
37
  "tool_execution_id" => tool_execution_id,
40
38
  "provider_message_id" => provider_message_id,
41
39
  "metadata" => metadata,
@@ -59,6 +57,13 @@ module TurnKit
59
57
  kind == "context_summary"
60
58
  end
61
59
 
60
+ def text
61
+ content.filter_map do |part|
62
+ attrs = stringify(part)
63
+ attrs["text"] if attrs["type"] == "text"
64
+ end.join("\n")
65
+ end
66
+
62
67
  def compaction_metadata
63
68
  metadata.fetch("compaction", {})
64
69
  end
@@ -69,13 +74,15 @@ module TurnKit
69
74
  end
70
75
 
71
76
  def normalize_content(value)
72
- return value if value.is_a?(Array)
77
+ return Array(value).map { |part| normalize_part(part) } if value.is_a?(Array)
73
78
 
74
79
  [ { "type" => "text", "text" => value.to_s } ]
75
80
  end
76
81
 
77
- def extract_text(blocks)
78
- Array(blocks).filter_map { |block| block.is_a?(Hash) ? block["text"] || block[:text] : nil }.join("\n")
82
+ def normalize_part(part)
83
+ attrs = part.respond_to?(:to_h) ? part.to_h.transform_keys(&:to_s) : { "type" => "text", "text" => part.to_s }
84
+ attrs["type"] ||= "text"
85
+ attrs
79
86
  end
80
87
 
81
88
  def validate!
@@ -40,9 +40,10 @@ module TurnKit
40
40
  def to_h
41
41
  case message.kind
42
42
  when "tool_call"
43
- { role: :assistant, content: message.text, tool_calls: message.metadata.fetch("tool_calls", []) }
43
+ { role: :assistant, content: projected_content, tool_calls: tool_call_parts }
44
44
  when "tool_result"
45
- { role: :tool, content: message.text, tool_call_id: message.metadata["tool_call_id"] }
45
+ part = message.content.find { |candidate| candidate.fetch("type") == "tool_result" }
46
+ { role: :tool, content: part&.fetch("text", message.text) || message.text, tool_call_id: part&.fetch("tool_call_id", nil) }
46
47
  else
47
48
  { role: message.role.to_sym, content: message.text }
48
49
  end
@@ -50,5 +51,19 @@ module TurnKit
50
51
 
51
52
  private
52
53
  attr_reader :message
54
+
55
+ def projected_content
56
+ parts = message.content.reject { |part| %w[tool_call provider].include?(part.fetch("type")) }
57
+ ordered = parts.select { |part| part.fetch("type") == "thinking" } + parts.select { |part| part.fetch("type") == "text" }
58
+ ordered.filter_map { |part| part.fetch("text", nil) }.join("\n")
59
+ end
60
+
61
+ def tool_call_parts
62
+ message.content.filter_map do |part|
63
+ next unless part.fetch("type") == "tool_call"
64
+
65
+ { "id" => part.fetch("id"), "name" => part.fetch("name"), "arguments" => part["arguments"] || {} }
66
+ end
67
+ end
53
68
  end
54
69
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class OutputAudit
5
+ Violation = Struct.new(:rule, :message, :metadata, keyword_init: true) do
6
+ def to_h
7
+ { "rule" => rule.to_s, "message" => message.to_s, "metadata" => metadata || {} }
8
+ end
9
+ end
10
+
11
+ Result = Struct.new(:violations, keyword_init: true) do
12
+ def clean?
13
+ violations.empty?
14
+ end
15
+
16
+ def messages
17
+ violations.map(&:message)
18
+ end
19
+
20
+ def to_h
21
+ { "clean" => clean?, "violations" => violations.map(&:to_h) }
22
+ end
23
+ end
24
+
25
+ def self.check(output, constraints: [], context: {})
26
+ new(output, constraints: constraints, context: context).check
27
+ end
28
+
29
+ def initialize(output, constraints: [], context: {})
30
+ @output = output
31
+ @constraints = Array(constraints)
32
+ @context = context || {}
33
+ end
34
+
35
+ def check
36
+ Result.new(violations: constraints.flat_map { |constraint| normalize(check_constraint(constraint)) })
37
+ end
38
+
39
+ private
40
+ attr_reader :output, :constraints, :context
41
+
42
+ def check_constraint(constraint)
43
+ if constraint.respond_to?(:check)
44
+ call_with_optional_context(constraint.method(:check))
45
+ elsif constraint.respond_to?(:call)
46
+ callable = constraint.is_a?(Proc) ? constraint : constraint.method(:call)
47
+ call_with_optional_context(callable)
48
+ else
49
+ raise ArgumentError, "output constraints must respond to #call or #check"
50
+ end
51
+ end
52
+
53
+ def call_with_optional_context(method)
54
+ parameters = method.parameters
55
+ return method.call(output) unless parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
56
+ return method.call(output, **context) if parameters.any? { |kind, _| kind == :keyrest }
57
+
58
+ accepted = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
59
+ method.call(output, **context.slice(*accepted))
60
+ end
61
+
62
+ def normalize(value)
63
+ case value
64
+ when nil, false, true
65
+ []
66
+ when Violation
67
+ [ value ]
68
+ when Result
69
+ value.violations
70
+ when String
71
+ [ Violation.new(rule: "output_constraint", message: value, metadata: {}) ]
72
+ when Hash
73
+ [ violation_from_hash(value) ]
74
+ else
75
+ if value.respond_to?(:to_ary)
76
+ value.to_ary.flat_map { |item| normalize(item) }
77
+ else
78
+ raise ArgumentError, "output constraint returned unsupported value: #{value.class}"
79
+ end
80
+ end
81
+ end
82
+
83
+ def violation_from_hash(value)
84
+ attrs = value.transform_keys(&:to_s)
85
+ Violation.new(
86
+ rule: attrs["rule"] || "output_constraint",
87
+ message: attrs["message"] || attrs["error"] || "output constraint failed",
88
+ metadata: attrs["metadata"] || attrs.reject { |key, _| %w[rule message error].include?(key) }
89
+ )
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class OutputPolicy
5
+ DEFAULT_SCHEMA = {
6
+ type: "object",
7
+ properties: {
8
+ approved: { type: "boolean" },
9
+ violations: {
10
+ type: "array",
11
+ items: {
12
+ type: "object",
13
+ properties: {
14
+ rule: { type: "string" },
15
+ message: { type: "string" }
16
+ },
17
+ required: [ "rule", "message" ]
18
+ }
19
+ }
20
+ },
21
+ required: [ "approved", "violations" ]
22
+ }.freeze
23
+
24
+ attr_reader :name, :content, :model, :thinking, :client
25
+
26
+ def self.from_file(path, name: nil, **options)
27
+ new(name: name || File.basename(path, File.extname(path)), content: File.read(path), **options)
28
+ end
29
+
30
+ def self.from_skill(skill, **options)
31
+ new(name: skill.key, content: skill.content, **options)
32
+ end
33
+
34
+ def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
35
+ @name = name.to_s
36
+ @content = content.to_s
37
+ @model = model
38
+ @thinking = Agent.normalize_thinking(thinking)
39
+ @client = client
40
+ raise ArgumentError, "content is required" if @content.empty?
41
+ end
42
+
43
+ def call(output, run: nil, turn: nil)
44
+ model_name = model || turn&.model || run&.turn&.model || TurnKit.default_model
45
+ result = if turn
46
+ turn.internal_model_call(
47
+ model: model_name,
48
+ messages: audit_messages(output),
49
+ tools: [],
50
+ instructions: audit_instructions,
51
+ thinking: thinking,
52
+ output_schema: DEFAULT_SCHEMA,
53
+ metadata: { output_policy: name },
54
+ purpose: "output_policy",
55
+ client: client
56
+ )
57
+ else
58
+ audit_client = client || TurnKit.client
59
+ audit_client.validate!(model: model_name)
60
+ chat(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
61
+ end
62
+ data = result.output_data || parse_json(result.text)
63
+ return if data.fetch("approved", false)
64
+
65
+ Array(data["violations"]).map do |violation|
66
+ attrs = violation.transform_keys(&:to_s)
67
+ OutputAudit::Violation.new(
68
+ rule: attrs["rule"] || name,
69
+ message: attrs["message"] || "output policy failed",
70
+ metadata: attrs.reject { |key, _| %w[rule message].include?(key) }
71
+ )
72
+ end
73
+ end
74
+
75
+ private
76
+ def audit_instructions
77
+ <<~TEXT
78
+ You audit model outputs against the policy below.
79
+
80
+ Return only a JSON object matching this shape:
81
+ {"approved":true,"violations":[]}
82
+
83
+ Set approved to true only when the output satisfies the policy. For each violation, include a concise rule and message. Do not repair the output. Do not wrap the JSON in Markdown. Do not include commentary before or after the JSON.
84
+
85
+ The policy may be a skill; treat its output-facing rules as normative and ignore process steps that are not observable in the output.
86
+
87
+ Policy:
88
+ #{content}
89
+ TEXT
90
+ end
91
+
92
+ def audit_messages(output)
93
+ [ { role: :user, content: JSON.generate(output: output) } ]
94
+ end
95
+
96
+ def chat(client, **kwargs)
97
+ accepted = chat_keyword_names(client)
98
+ kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
99
+ client.chat(**kwargs)
100
+ end
101
+
102
+ def chat_keyword_names(client)
103
+ client.method(:chat).parameters.filter_map do |kind, name|
104
+ return [ :keyrest ] if kind == :keyrest
105
+
106
+ name if %i[key keyreq].include?(kind)
107
+ end
108
+ end
109
+
110
+ def parse_json(value)
111
+ JSON.parse(extract_json(value.to_s))
112
+ rescue JSON::ParserError
113
+ { "approved" => false, "violations" => [ { "rule" => name, "message" => "output policy returned invalid JSON" } ] }
114
+ end
115
+
116
+ def extract_json(value)
117
+ text = value.strip
118
+ return text if text.start_with?("{") && text.end_with?("}")
119
+
120
+ fenced = text[/```(?:json)?\s*(\{.*?\})\s*```/m, 1]
121
+ return fenced if fenced
122
+
123
+ object = text[/\{.*\}/m]
124
+ object || text
125
+ end
126
+ end
127
+ end