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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +112 -6
- data/UPGRADE.md +37 -299
- data/lib/turnkit/adapters/ruby_llm.rb +29 -0
- data/lib/turnkit/agent.rb +61 -7
- data/lib/turnkit/budget.rb +44 -10
- data/lib/turnkit/compaction.rb +16 -4
- data/lib/turnkit/error.rb +2 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +0 -1
- data/lib/turnkit/load_skill_tool.rb +29 -0
- data/lib/turnkit/memory_store.rb +11 -0
- data/lib/turnkit/message.rb +14 -7
- data/lib/turnkit/message_projection.rb +17 -2
- data/lib/turnkit/output_audit.rb +92 -0
- data/lib/turnkit/output_policy.rb +127 -0
- data/lib/turnkit/result.rb +29 -4
- data/lib/turnkit/run.rb +4 -3
- data/lib/turnkit/schema_check.rb +68 -0
- data/lib/turnkit/skill.rb +16 -2
- data/lib/turnkit/store.rb +6 -0
- data/lib/turnkit/stores/active_record_store.rb +10 -2
- data/lib/turnkit/sub_agent_tool.rb +2 -1
- data/lib/turnkit/system_prompt.rb +1 -1
- data/lib/turnkit/tool.rb +2 -21
- data/lib/turnkit/tool_call.rb +3 -3
- data/lib/turnkit/tool_runner.rb +40 -11
- data/lib/turnkit/turn.rb +162 -18
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/workflow.rb +24 -69
- data/lib/turnkit.rb +16 -9
- metadata +6 -2
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, :
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
|
|
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?
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
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 &&
|
|
66
|
+
return unless cost && max_spend
|
|
40
67
|
|
|
41
68
|
@mutex.synchronize do
|
|
42
69
|
@cost += cost.to_f
|
|
43
|
-
raise
|
|
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
|
|
49
|
-
raise
|
|
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
|
data/lib/turnkit/compaction.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -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
|
data/lib/turnkit/message.rb
CHANGED
|
@@ -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, :
|
|
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"]
|
|
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
|
|
78
|
-
|
|
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:
|
|
43
|
+
{ role: :assistant, content: projected_content, tool_calls: tool_call_parts }
|
|
44
44
|
when "tool_result"
|
|
45
|
-
|
|
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
|