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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -5
- data/README.md +165 -26
- data/UPGRADE.md +35 -68
- data/lib/turnkit/adapters/codex.rb +160 -0
- data/lib/turnkit/agent.rb +70 -4
- data/lib/turnkit/budget.rb +23 -8
- data/lib/turnkit/compaction.rb +15 -4
- data/lib/turnkit/conversation.rb +4 -3
- data/lib/turnkit/error.rb +1 -0
- data/lib/turnkit/output_audit.rb +92 -0
- data/lib/turnkit/output_policy.rb +121 -0
- data/lib/turnkit/run.rb +2 -0
- data/lib/turnkit/tool_runner.rb +11 -4
- data/lib/turnkit/turn.rb +96 -12
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/{fleet.rb → workflow.rb} +40 -13
- data/lib/turnkit.rb +14 -5
- metadata +10 -6
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
|
|
6
|
+
attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions, :max_tool_executions_by_name
|
|
7
7
|
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
|
|
8
|
+
attr_reader :output_audit, :output_audit_mode, :output_policy_model
|
|
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, cost_limit: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
|
|
13
|
+
output_schema: nil, output_audit: nil, output_audit_mode: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, on_event: nil)
|
|
13
14
|
@name = name.to_s
|
|
14
15
|
@description = description.to_s
|
|
15
16
|
@model = model
|
|
@@ -28,9 +29,13 @@ module TurnKit
|
|
|
28
29
|
@cost_limit = cost_limit
|
|
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
|
+
@output_policy_model = output_policy_model
|
|
37
|
+
@output_audit = normalize_output_policy_options(output_audit: output_audit, output_policy: output_policy, output_policy_model: output_policy_model, output_policy_thinking: output_policy_thinking)
|
|
38
|
+
@output_audit_mode = normalize_output_policy_mode(output_audit_mode: output_audit_mode, output_policy_mode: output_policy_mode)
|
|
34
39
|
@on_event = on_event
|
|
35
40
|
raise ArgumentError, "name is required" if @name.empty?
|
|
36
41
|
validate_tools!
|
|
@@ -62,7 +67,7 @@ module TurnKit
|
|
|
62
67
|
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, **options)
|
|
70
|
+
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
71
|
task = task || prompt
|
|
67
72
|
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
68
73
|
|
|
@@ -71,6 +76,7 @@ module TurnKit
|
|
|
71
76
|
turn = conversation.build_turn(
|
|
72
77
|
trigger_message_id: message.id,
|
|
73
78
|
root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
|
|
79
|
+
prompt_mode: prompt_mode,
|
|
74
80
|
**options
|
|
75
81
|
)
|
|
76
82
|
run = Run.new(turn)
|
|
@@ -93,6 +99,18 @@ module TurnKit
|
|
|
93
99
|
thinking
|
|
94
100
|
end
|
|
95
101
|
|
|
102
|
+
def effective_output_audit
|
|
103
|
+
Array(output_audit).compact
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def output_policy
|
|
107
|
+
output_audit
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def output_policy_mode
|
|
111
|
+
output_audit_mode
|
|
112
|
+
end
|
|
113
|
+
|
|
96
114
|
def effective_client
|
|
97
115
|
client || TurnKit.client
|
|
98
116
|
end
|
|
@@ -142,6 +160,7 @@ module TurnKit
|
|
|
142
160
|
timeout: timeout || TurnKit.timeout,
|
|
143
161
|
max_depth: max_depth || TurnKit.max_depth,
|
|
144
162
|
max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
|
|
163
|
+
max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
|
|
145
164
|
cost_limit: cost_limit || TurnKit.cost_limit,
|
|
146
165
|
root_started_at: root_started_at
|
|
147
166
|
)
|
|
@@ -169,6 +188,53 @@ module TurnKit
|
|
|
169
188
|
effective_tools.each(&:validate_definition!)
|
|
170
189
|
end
|
|
171
190
|
|
|
191
|
+
def normalize_output_policy_options(output_audit:, output_policy:, output_policy_model:, output_policy_thinking:)
|
|
192
|
+
raise ArgumentError, "use output_policy: or output_audit:, not both" if output_audit && output_policy
|
|
193
|
+
|
|
194
|
+
output_policy.nil? ? output_audit : normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalize_output_policy(value, model: nil, thinking: nil)
|
|
198
|
+
case value
|
|
199
|
+
when nil
|
|
200
|
+
nil
|
|
201
|
+
when Array
|
|
202
|
+
value.map { |item| normalize_output_policy(item, model: model, thinking: thinking) }.compact
|
|
203
|
+
when String
|
|
204
|
+
output_policy_from_path(value, model: model, thinking: thinking)
|
|
205
|
+
when Pathname
|
|
206
|
+
output_policy_from_path(value.to_s, model: model, thinking: thinking)
|
|
207
|
+
else
|
|
208
|
+
return value if value.respond_to?(:call) || value.respond_to?(:check)
|
|
209
|
+
|
|
210
|
+
raise ArgumentError, "output_policy must be a policy file path, a #call/#check object, or an array of those"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def output_policy_from_path(path, model: nil, thinking: nil)
|
|
215
|
+
unless path.match?(/\.(md|markdown|txt)\z/i)
|
|
216
|
+
raise ArgumentError, "output_policy string must be a .md, .markdown, or .txt file path"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
TurnKit::OutputPolicy.from_file(
|
|
220
|
+
path,
|
|
221
|
+
model: model || TurnKit.output_policy_model,
|
|
222
|
+
thinking: thinking || TurnKit.output_policy_thinking
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def normalize_output_policy_mode(output_audit_mode:, output_policy_mode:)
|
|
227
|
+
if output_audit_mode && output_policy_mode && output_audit_mode.to_sym != output_policy_mode.to_sym
|
|
228
|
+
raise ArgumentError, "use output_policy_mode: or output_audit_mode:, not both"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
value = output_policy_mode || output_audit_mode || :report
|
|
232
|
+
mode = value.to_sym
|
|
233
|
+
raise ArgumentError, "unknown output_policy_mode: #{value}" unless %i[report fail].include?(mode)
|
|
234
|
+
|
|
235
|
+
mode
|
|
236
|
+
end
|
|
237
|
+
|
|
172
238
|
def task_message(task, input)
|
|
173
239
|
text = task.to_s
|
|
174
240
|
return text if input.nil?
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -2,32 +2,40 @@
|
|
|
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, :cost_limit
|
|
6
6
|
|
|
7
|
-
def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, cost_limit: nil, root_started_at: Clock.now)
|
|
7
|
+
def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, cost_limit: nil, root_started_at: Clock.now)
|
|
8
8
|
@root_started_at = root_started_at
|
|
9
9
|
@max_iterations = max_iterations
|
|
10
10
|
@timeout = timeout
|
|
11
11
|
@max_depth = max_depth
|
|
12
12
|
@max_tool_executions = max_tool_executions
|
|
13
|
+
@max_tool_executions_by_name = normalize_tool_limits(max_tool_executions_by_name)
|
|
13
14
|
@cost_limit = cost_limit
|
|
14
15
|
@iterations = 0
|
|
15
16
|
@tool_executions = 0
|
|
17
|
+
@tool_executions_by_name = Hash.new(0)
|
|
16
18
|
@cost = 0
|
|
17
19
|
@mutex = Mutex.new
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def count_iteration!
|
|
21
23
|
@mutex.synchronize do
|
|
24
|
+
raise BudgetError, "maximum iterations reached" if max_iterations && @iterations >= max_iterations
|
|
25
|
+
|
|
22
26
|
@iterations += 1
|
|
23
|
-
raise Error, "maximum iterations reached" if max_iterations && @iterations > max_iterations
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
def count_tool_execution!
|
|
30
|
+
def count_tool_execution!(name = nil)
|
|
28
31
|
@mutex.synchronize do
|
|
32
|
+
key = name.to_s if name
|
|
33
|
+
limit = max_tool_executions_by_name[key] if key
|
|
34
|
+
raise BudgetError, "maximum tool executions reached" if max_tool_executions && @tool_executions >= max_tool_executions
|
|
35
|
+
raise BudgetError, "maximum executions reached for tool #{key}" if limit && @tool_executions_by_name[key] >= limit
|
|
36
|
+
|
|
29
37
|
@tool_executions += 1
|
|
30
|
-
|
|
38
|
+
@tool_executions_by_name[key] += 1 if key
|
|
31
39
|
end
|
|
32
40
|
end
|
|
33
41
|
|
|
@@ -40,13 +48,20 @@ module TurnKit
|
|
|
40
48
|
|
|
41
49
|
@mutex.synchronize do
|
|
42
50
|
@cost += cost.to_f
|
|
43
|
-
raise
|
|
51
|
+
raise BudgetError, "cost limit reached" if @cost > cost_limit
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
def check!(depth:)
|
|
48
|
-
raise
|
|
49
|
-
raise
|
|
56
|
+
raise BudgetError, "maximum sub-agent depth reached" if max_depth && depth > max_depth
|
|
57
|
+
raise BudgetError, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
|
|
50
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
def normalize_tool_limits(value)
|
|
62
|
+
value.to_h.transform_keys(&:to_s).transform_values do |limit|
|
|
63
|
+
limit.nil? ? nil : Integer(limit)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
51
66
|
end
|
|
52
67
|
end
|
data/lib/turnkit/compaction.rb
CHANGED
|
@@ -117,6 +117,8 @@ module TurnKit
|
|
|
117
117
|
return unless force || over_threshold?(messages, policy)
|
|
118
118
|
|
|
119
119
|
compact!(turn.conversation, agent: turn.agent, turn: turn, focus: focus, auto: true, overrides: policy, force: true)
|
|
120
|
+
rescue BudgetError
|
|
121
|
+
raise
|
|
120
122
|
rescue StandardError => error
|
|
121
123
|
TurnKit.logger&.warn("TurnKit compaction failed: #{error.class}: #{error.message}")
|
|
122
124
|
nil
|
|
@@ -144,12 +146,15 @@ module TurnKit
|
|
|
144
146
|
target_tokens: summary_budget(selected_tokens, policy),
|
|
145
147
|
fallback_model: turn&.model || conversation.model || agent.effective_model,
|
|
146
148
|
conversation_id: conversation.id,
|
|
147
|
-
turn_id: turn&.id
|
|
149
|
+
turn_id: turn&.id,
|
|
150
|
+
turn: turn
|
|
148
151
|
)
|
|
149
152
|
|
|
150
153
|
append_summary(conversation, turn: turn, summary: summary, selected: selected, policy: policy, focus: focus, auto: auto, input_tokens: selected_tokens)
|
|
151
154
|
rescue CompactionError
|
|
152
155
|
raise
|
|
156
|
+
rescue BudgetError
|
|
157
|
+
raise
|
|
153
158
|
rescue StandardError => error
|
|
154
159
|
raise CompactionError, "#{error.class}: #{error.message}"
|
|
155
160
|
end
|
|
@@ -350,18 +355,24 @@ module TurnKit
|
|
|
350
355
|
index
|
|
351
356
|
end
|
|
352
357
|
|
|
353
|
-
def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:)
|
|
358
|
+
def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:, turn: nil)
|
|
354
359
|
client = policy["client"] || agent.effective_client
|
|
355
360
|
model = policy["model"] || fallback_model
|
|
356
361
|
safe_messages = messages.map { |message| sanitize_message(message, policy) }
|
|
357
362
|
prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
|
|
358
|
-
|
|
363
|
+
attrs = {
|
|
359
364
|
model: model,
|
|
360
365
|
messages: MessageProjection.for(safe_messages) + [ { role: :user, content: prompt } ],
|
|
361
366
|
tools: [],
|
|
362
367
|
instructions: COMPACTION_SYSTEM_PROMPT,
|
|
363
368
|
metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
|
|
364
|
-
|
|
369
|
+
}
|
|
370
|
+
result = if turn
|
|
371
|
+
turn.internal_model_call(**attrs, purpose: "compaction", client: policy["client"])
|
|
372
|
+
else
|
|
373
|
+
client.validate!(model: model)
|
|
374
|
+
client.chat(**attrs)
|
|
375
|
+
end
|
|
365
376
|
text = result.text.to_s.strip
|
|
366
377
|
raise CompactionError, "compaction model returned an empty summary" if text.empty?
|
|
367
378
|
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -26,17 +26,18 @@ module TurnKit
|
|
|
26
26
|
async ? turn : turn.run!
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
|
|
30
|
-
build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, on_event: on_event).run!
|
|
29
|
+
def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil)
|
|
30
|
+
build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, prompt_mode: prompt_mode, on_event: on_event).run!
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
|
|
33
|
+
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil)
|
|
34
34
|
snapshot = latest_message_sequence
|
|
35
35
|
effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
|
|
36
36
|
options = { "trigger_message_id" => trigger_message_id }.compact
|
|
37
37
|
options["thinking"] = effective_thinking
|
|
38
38
|
options["compact"] = compact unless compact.nil?
|
|
39
39
|
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
40
|
+
options["prompt_mode"] = prompt_mode.to_sym if prompt_mode
|
|
40
41
|
record = store.create_turn(
|
|
41
42
|
"conversation_id" => id,
|
|
42
43
|
"agent_name" => agent.name,
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -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,121 @@
|
|
|
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 initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
|
|
31
|
+
@name = name.to_s
|
|
32
|
+
@content = content.to_s
|
|
33
|
+
@model = model
|
|
34
|
+
@thinking = Agent.normalize_thinking(thinking)
|
|
35
|
+
@client = client
|
|
36
|
+
raise ArgumentError, "content is required" if @content.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(output, run: nil, turn: nil)
|
|
40
|
+
model_name = model || turn&.model || run&.turn&.model || TurnKit.default_model
|
|
41
|
+
result = if turn
|
|
42
|
+
turn.internal_model_call(
|
|
43
|
+
model: model_name,
|
|
44
|
+
messages: audit_messages(output),
|
|
45
|
+
tools: [],
|
|
46
|
+
instructions: audit_instructions,
|
|
47
|
+
thinking: thinking,
|
|
48
|
+
output_schema: DEFAULT_SCHEMA,
|
|
49
|
+
metadata: { output_policy: name },
|
|
50
|
+
purpose: "output_policy",
|
|
51
|
+
client: client
|
|
52
|
+
)
|
|
53
|
+
else
|
|
54
|
+
audit_client = client || TurnKit.client
|
|
55
|
+
audit_client.validate!(model: model_name)
|
|
56
|
+
chat(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
|
|
57
|
+
end
|
|
58
|
+
data = result.output_data || parse_json(result.text)
|
|
59
|
+
return if data.fetch("approved", false)
|
|
60
|
+
|
|
61
|
+
Array(data["violations"]).map do |violation|
|
|
62
|
+
attrs = violation.transform_keys(&:to_s)
|
|
63
|
+
OutputAudit::Violation.new(
|
|
64
|
+
rule: attrs["rule"] || name,
|
|
65
|
+
message: attrs["message"] || "output policy failed",
|
|
66
|
+
metadata: attrs.reject { |key, _| %w[rule message].include?(key) }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
def audit_instructions
|
|
73
|
+
<<~TEXT
|
|
74
|
+
You audit model outputs against the policy below.
|
|
75
|
+
|
|
76
|
+
Return only a JSON object matching this shape:
|
|
77
|
+
{"approved":true,"violations":[]}
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
Policy:
|
|
82
|
+
#{content}
|
|
83
|
+
TEXT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def audit_messages(output)
|
|
87
|
+
[ { role: :user, content: JSON.generate(output: output) } ]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def chat(client, **kwargs)
|
|
91
|
+
accepted = chat_keyword_names(client)
|
|
92
|
+
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
93
|
+
client.chat(**kwargs)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def chat_keyword_names(client)
|
|
97
|
+
client.method(:chat).parameters.filter_map do |kind, name|
|
|
98
|
+
return [ :keyrest ] if kind == :keyrest
|
|
99
|
+
|
|
100
|
+
name if %i[key keyreq].include?(kind)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_json(value)
|
|
105
|
+
JSON.parse(extract_json(value.to_s))
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
{ "approved" => false, "violations" => [ { "rule" => name, "message" => "output policy returned invalid JSON" } ] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_json(value)
|
|
111
|
+
text = value.strip
|
|
112
|
+
return text if text.start_with?("{") && text.end_with?("}")
|
|
113
|
+
|
|
114
|
+
fenced = text[/```(?:json)?\s*(\{.*?\})\s*```/m, 1]
|
|
115
|
+
return fenced if fenced
|
|
116
|
+
|
|
117
|
+
object = text[/\{.*\}/m]
|
|
118
|
+
object || text
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/turnkit/run.rb
CHANGED
|
@@ -14,6 +14,8 @@ module TurnKit
|
|
|
14
14
|
def output = output_text
|
|
15
15
|
def output_text = turn.output_text
|
|
16
16
|
def output_data = turn.output_data
|
|
17
|
+
def output_audit = turn.output_audit
|
|
18
|
+
def output_audit_clean? = output_audit.nil? || output_audit.fetch("clean", false)
|
|
17
19
|
def usage = Usage.from_records(turn_records)
|
|
18
20
|
def cost = Cost.from_records(turn_records)
|
|
19
21
|
def steps = turn_records.length
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -23,10 +23,17 @@ module TurnKit
|
|
|
23
23
|
attr_reader :turn
|
|
24
24
|
|
|
25
25
|
def run(tool_call)
|
|
26
|
-
turn.budget.count_tool_execution!
|
|
27
|
-
tool = tool_for(tool_call.name)
|
|
28
26
|
execution = ToolExecution.new(create_execution(tool_call))
|
|
29
27
|
|
|
28
|
+
begin
|
|
29
|
+
turn.budget.count_tool_execution!(tool_call.name)
|
|
30
|
+
rescue BudgetError => error
|
|
31
|
+
finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
tool = tool_for(tool_call.name)
|
|
36
|
+
|
|
30
37
|
unless tool
|
|
31
38
|
return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
|
|
32
39
|
end
|
|
@@ -58,7 +65,7 @@ module TurnKit
|
|
|
58
65
|
def finish_success(execution, tool_call, payload)
|
|
59
66
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
|
|
60
67
|
append_result(execution, tool_call, payload)
|
|
61
|
-
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
|
|
68
|
+
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: payload.to_json.length)
|
|
62
69
|
ToolExecution.new(attrs)
|
|
63
70
|
end
|
|
64
71
|
|
|
@@ -66,7 +73,7 @@ module TurnKit
|
|
|
66
73
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
67
74
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
|
|
68
75
|
append_result(execution, tool_call, error)
|
|
69
|
-
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
|
|
76
|
+
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: error.to_json.length)
|
|
70
77
|
ToolExecution.new(attrs)
|
|
71
78
|
end
|
|
72
79
|
|