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/result.rb
CHANGED
|
@@ -2,19 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Result
|
|
5
|
-
attr_reader :
|
|
5
|
+
attr_reader :parts, :usage, :model, :finish_reason, :output_data
|
|
6
6
|
|
|
7
|
-
def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
|
|
8
|
-
@
|
|
9
|
-
@tool_calls = Array(tool_calls)
|
|
7
|
+
def initialize(text: "", tool_calls: [], parts: nil, usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
|
|
8
|
+
@parts = parts ? normalize_parts(parts) : synthesize_parts(text: text, tool_calls: tool_calls)
|
|
10
9
|
@usage = usage || Usage.new
|
|
11
10
|
@model = model
|
|
12
11
|
@finish_reason = finish_reason
|
|
13
12
|
@output_data = output_data
|
|
14
13
|
end
|
|
15
14
|
|
|
15
|
+
def text
|
|
16
|
+
parts.filter_map { |part| part["text"] if part["type"] == "text" }.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tool_calls
|
|
20
|
+
parts.filter_map do |part|
|
|
21
|
+
next unless part["type"] == "tool_call"
|
|
22
|
+
|
|
23
|
+
ToolCall.new(id: part.fetch("id"), name: part.fetch("name"), arguments: part["arguments"] || {}, arguments_error: part["arguments_error"])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
def tool_calls?
|
|
17
28
|
tool_calls.any?
|
|
18
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def synthesize_parts(text:, tool_calls:)
|
|
33
|
+
parts = []
|
|
34
|
+
parts << { "type" => "text", "text" => text.to_s } unless text.to_s.empty?
|
|
35
|
+
Array(tool_calls).each do |call|
|
|
36
|
+
parts << { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments, "arguments_error" => call.arguments_error }.compact
|
|
37
|
+
end
|
|
38
|
+
parts
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_parts(value)
|
|
42
|
+
Array(value).map { |part| part.to_h.transform_keys(&:to_s) }
|
|
43
|
+
end
|
|
19
44
|
end
|
|
20
45
|
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 policy_audit = turn.policy_audit
|
|
18
|
+
def policy_clean? = policy_audit.nil? || policy_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
|
|
@@ -25,9 +27,8 @@ module TurnKit
|
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def messages
|
|
28
|
-
turn_records.flat_map do |
|
|
29
|
-
|
|
30
|
-
turn.store.list_messages(conversation.fetch("id"))
|
|
30
|
+
turn_records.map { |record| record.fetch("conversation_id") }.uniq.flat_map do |conversation_id|
|
|
31
|
+
turn.store.list_messages(conversation_id).map { |attrs| Message.new(attrs) }
|
|
31
32
|
end
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
module SchemaCheck
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate!(value, schema, error_class: ToolValidationError, label: "input")
|
|
8
|
+
schema = stringify_schema(schema || {})
|
|
9
|
+
type = schema["type"] || "object"
|
|
10
|
+
validate_type!(value, type, schema, error_class: error_class, label: label)
|
|
11
|
+
validate_enum!(value, schema, error_class: error_class, label: label)
|
|
12
|
+
|
|
13
|
+
if type == "object" && schema["properties"]
|
|
14
|
+
attrs = value.respond_to?(:to_h) ? value.to_h.transform_keys(&:to_s) : {}
|
|
15
|
+
required = Array(schema["required"]).map(&:to_s)
|
|
16
|
+
missing = required.reject { |name| attrs.key?(name) }
|
|
17
|
+
raise error_class, "#{label} missing required field#{missing.length == 1 ? "" : "s"}: #{missing.join(", ")}" if missing.any?
|
|
18
|
+
|
|
19
|
+
schema.fetch("properties", {}).each do |name, child_schema|
|
|
20
|
+
next unless attrs.key?(name)
|
|
21
|
+
|
|
22
|
+
validate!(attrs[name], child_schema, error_class: error_class, label: "#{label}.#{name}")
|
|
23
|
+
end
|
|
24
|
+
elsif type == "array" && schema["items"] && value.is_a?(Array)
|
|
25
|
+
value.each_with_index do |item, index|
|
|
26
|
+
validate!(item, schema["items"], error_class: error_class, label: "#{label}[#{index}]")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stringify_schema(value)
|
|
34
|
+
case value
|
|
35
|
+
when Hash
|
|
36
|
+
value.transform_keys(&:to_s).transform_values { |nested| stringify_schema(nested) }
|
|
37
|
+
when Array
|
|
38
|
+
value.map { |nested| stringify_schema(nested) }
|
|
39
|
+
when Symbol
|
|
40
|
+
value.to_s
|
|
41
|
+
else
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_type!(value, type, schema, error_class:, label:)
|
|
47
|
+
return if value.nil? && !Array(schema["required"]).include?(label.to_s)
|
|
48
|
+
|
|
49
|
+
valid = case type.to_s
|
|
50
|
+
when "string" then value.is_a?(String)
|
|
51
|
+
when "integer" then value.is_a?(Integer)
|
|
52
|
+
when "number" then value.is_a?(Numeric)
|
|
53
|
+
when "boolean" then value == true || value == false
|
|
54
|
+
when "array" then value.is_a?(Array)
|
|
55
|
+
when "object" then value.is_a?(Hash)
|
|
56
|
+
else true
|
|
57
|
+
end
|
|
58
|
+
raise error_class, "#{label} must be a #{type}" unless valid
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_enum!(value, schema, error_class:, label:)
|
|
62
|
+
enum = schema["enum"]
|
|
63
|
+
return unless enum && !Array(enum).include?(value)
|
|
64
|
+
|
|
65
|
+
raise error_class, "#{label} must be one of: #{Array(enum).join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/turnkit/skill.rb
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
3
5
|
module TurnKit
|
|
4
6
|
class Skill
|
|
5
7
|
attr_reader :key, :name, :description, :content
|
|
6
8
|
|
|
7
9
|
def self.from_file(path, key: nil, name: nil, description: "")
|
|
8
|
-
content = File.read(path)
|
|
10
|
+
content, metadata = parse_file(File.read(path))
|
|
9
11
|
base = File.basename(path, File.extname(path))
|
|
10
|
-
new(key: key || base, name: name || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description, content: content)
|
|
12
|
+
new(key: key || base, name: name || metadata["name"] || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description.to_s.empty? ? metadata["description"].to_s : description, content: content)
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def self.from_directory(path, pattern: "*.md")
|
|
@@ -23,5 +25,17 @@ module TurnKit
|
|
|
23
25
|
raise ArgumentError, "name is required" if @name.empty?
|
|
24
26
|
raise ArgumentError, "content is required" if @content.empty?
|
|
25
27
|
end
|
|
28
|
+
|
|
29
|
+
def self.parse_file(content)
|
|
30
|
+
text = content.to_s
|
|
31
|
+
return [ text, {} ] unless text.start_with?("---\n")
|
|
32
|
+
|
|
33
|
+
_, frontmatter, body = text.split(/^---\s*$/, 3)
|
|
34
|
+
return [ text, {} ] unless body
|
|
35
|
+
|
|
36
|
+
[ body.sub(/\A\n/, ""), YAML.safe_load(frontmatter, permitted_classes: [ Symbol ], aliases: false) || {} ]
|
|
37
|
+
rescue Psych::SyntaxError
|
|
38
|
+
[ text, {} ]
|
|
39
|
+
end
|
|
26
40
|
end
|
|
27
41
|
end
|
data/lib/turnkit/store.rb
CHANGED
|
@@ -12,6 +12,12 @@ module TurnKit
|
|
|
12
12
|
def create_turn(_attributes) = raise(NotImplementedError)
|
|
13
13
|
def load_turn(_id) = raise(NotImplementedError)
|
|
14
14
|
def update_turn(_id, _attributes) = raise(NotImplementedError)
|
|
15
|
+
def claim_turn(id, from: "pending", to: "running", **attributes)
|
|
16
|
+
turn = load_turn(id)
|
|
17
|
+
return nil unless turn["status"] == from
|
|
18
|
+
|
|
19
|
+
update_turn(id, attributes.merge(status: to))
|
|
20
|
+
end
|
|
15
21
|
def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
|
|
16
22
|
|
|
17
23
|
def create_tool_execution(_attributes) = raise(NotImplementedError)
|
|
@@ -38,7 +38,6 @@ module TurnKit
|
|
|
38
38
|
kind: message.fetch("kind"),
|
|
39
39
|
sequence: message.fetch("sequence"),
|
|
40
40
|
content: message.fetch("content"),
|
|
41
|
-
text: message.fetch("text"),
|
|
42
41
|
tool_execution_uid: message["tool_execution_id"],
|
|
43
42
|
provider_message_id: message["provider_message_id"],
|
|
44
43
|
metadata: message.fetch("metadata")
|
|
@@ -93,6 +92,15 @@ module TurnKit
|
|
|
93
92
|
turn_hash(record)
|
|
94
93
|
end
|
|
95
94
|
|
|
95
|
+
def claim_turn(id, from: "pending", to: "running", **attributes)
|
|
96
|
+
attrs = Record.turn_update(attributes.merge(status: to))
|
|
97
|
+
attrs.delete("output_data") unless turn_has_attribute?("output_data")
|
|
98
|
+
affected = turn_class.where(uid: id, status: from).update_all(attrs.merge(updated_at: Clock.now))
|
|
99
|
+
return nil if affected.zero?
|
|
100
|
+
|
|
101
|
+
load_turn(id)
|
|
102
|
+
end
|
|
103
|
+
|
|
96
104
|
def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
|
|
97
105
|
scope = turn_class.all
|
|
98
106
|
scope = scope.where(root_turn_uid: root_turn_id) if root_turn_id
|
|
@@ -185,7 +193,7 @@ module TurnKit
|
|
|
185
193
|
{
|
|
186
194
|
"id" => record.uid, "conversation_id" => record.conversation_uid, "turn_id" => record.turn_uid,
|
|
187
195
|
"role" => record.role, "kind" => record.kind, "sequence" => record.sequence, "content" => record.content,
|
|
188
|
-
"
|
|
196
|
+
"tool_execution_id" => record.tool_execution_uid,
|
|
189
197
|
"provider_message_id" => record.provider_message_id, "metadata" => record.metadata || {}, "created_at" => record.created_at
|
|
190
198
|
}
|
|
191
199
|
end
|
|
@@ -42,7 +42,8 @@ module TurnKit
|
|
|
42
42
|
agent: sub_agent,
|
|
43
43
|
on_event: parent_turn.agent.effective_on_event
|
|
44
44
|
)
|
|
45
|
-
|
|
45
|
+
error = child.store.load_turn(child.id)["error"] if child.failed?
|
|
46
|
+
{ "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data, "error" => error }.compact
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
end
|
|
@@ -194,7 +194,7 @@ module TurnKit
|
|
|
194
194
|
|
|
195
195
|
tagged(
|
|
196
196
|
"skills_available",
|
|
197
|
-
"
|
|
197
|
+
"These skills are listed but not loaded. When a task matches a skill description, call load_skill with the skill key before relying on it.\n\n#{entries.join("\n")}"
|
|
198
198
|
)
|
|
199
199
|
end
|
|
200
200
|
|
data/lib/turnkit/tool.rb
CHANGED
|
@@ -106,7 +106,7 @@ module TurnKit
|
|
|
106
106
|
next
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: name)
|
|
110
110
|
normalized[name] = value
|
|
111
111
|
end
|
|
112
112
|
normalized
|
|
@@ -169,26 +169,7 @@ module TurnKit
|
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
def validate_value!(value, param)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
case param.fetch(:type)
|
|
175
|
-
when :string, :enum
|
|
176
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be a string" unless value.is_a?(String)
|
|
177
|
-
when :integer
|
|
178
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be an integer" unless value.is_a?(Integer)
|
|
179
|
-
when :number
|
|
180
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be a number" unless value.is_a?(Numeric)
|
|
181
|
-
when :boolean
|
|
182
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be a boolean" unless value == true || value == false
|
|
183
|
-
when :array
|
|
184
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be an array" unless value.is_a?(Array)
|
|
185
|
-
when :object
|
|
186
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be an object" unless value.is_a?(Hash)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
if param[:enum] && !Array(param[:enum]).include?(value)
|
|
190
|
-
raise ToolValidationError, "#{param.fetch(:name)} must be one of: #{Array(param[:enum]).join(", ")}"
|
|
191
|
-
end
|
|
172
|
+
SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: param.fetch(:name))
|
|
192
173
|
end
|
|
193
174
|
|
|
194
175
|
def accepts_turnkit_context?(instance)
|
data/lib/turnkit/tool_call.rb
CHANGED
|
@@ -4,10 +4,10 @@ module TurnKit
|
|
|
4
4
|
class ToolCall
|
|
5
5
|
attr_reader :id, :name, :arguments, :arguments_error
|
|
6
6
|
|
|
7
|
-
def initialize(id:, name:, arguments: {})
|
|
7
|
+
def initialize(id:, name:, arguments: {}, arguments_error: nil)
|
|
8
8
|
@id = id.to_s
|
|
9
9
|
@name = name.to_s
|
|
10
|
-
@arguments_error =
|
|
10
|
+
@arguments_error = arguments_error
|
|
11
11
|
@arguments = normalize_arguments(arguments)
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ module TurnKit
|
|
|
23
23
|
{}
|
|
24
24
|
end
|
|
25
25
|
rescue JSON::ParserError
|
|
26
|
-
@arguments_error
|
|
26
|
+
@arguments_error ||= "invalid JSON arguments"
|
|
27
27
|
{}
|
|
28
28
|
end
|
|
29
29
|
end
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -7,9 +7,12 @@ module TurnKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def dispatch(tool_calls)
|
|
10
|
-
tool_calls.
|
|
10
|
+
tool_calls.each_with_index do |tool_call, index|
|
|
11
11
|
execution = run(tool_call)
|
|
12
|
-
|
|
12
|
+
if execution.completed? && tool_for(tool_call.name)&.ends_turn?
|
|
13
|
+
skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
|
|
14
|
+
return execution
|
|
15
|
+
end
|
|
13
16
|
end
|
|
14
17
|
nil
|
|
15
18
|
end
|
|
@@ -23,9 +26,10 @@ module TurnKit
|
|
|
23
26
|
attr_reader :turn
|
|
24
27
|
|
|
25
28
|
def run(tool_call)
|
|
26
|
-
turn.budget.count_tool_execution!
|
|
27
|
-
tool = tool_for(tool_call.name)
|
|
28
29
|
execution = ToolExecution.new(create_execution(tool_call))
|
|
30
|
+
heartbeat!
|
|
31
|
+
|
|
32
|
+
tool = tool_for(tool_call.name)
|
|
29
33
|
|
|
30
34
|
unless tool
|
|
31
35
|
return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
|
|
@@ -35,6 +39,13 @@ module TurnKit
|
|
|
35
39
|
return finish_error(execution, tool_call, tool_call.arguments_error)
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
begin
|
|
43
|
+
turn.budget.count_tool_execution!(tool_call.name)
|
|
44
|
+
rescue BudgetError => error
|
|
45
|
+
finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
context = ToolContext.new(turn: turn, execution: execution)
|
|
39
50
|
payload = begin
|
|
40
51
|
normalize_payload(call_tool(tool, tool_call.arguments, context: context))
|
|
@@ -56,32 +67,50 @@ module TurnKit
|
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
def finish_success(execution, tool_call, payload)
|
|
70
|
+
json = payload.to_json
|
|
59
71
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
|
|
60
|
-
append_result(execution, tool_call, payload)
|
|
61
|
-
|
|
72
|
+
append_result(execution, tool_call, payload, json: json, error: false)
|
|
73
|
+
heartbeat!
|
|
74
|
+
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
|
|
62
75
|
ToolExecution.new(attrs)
|
|
63
76
|
end
|
|
64
77
|
|
|
65
78
|
def finish_error(execution, tool_call, message, details: nil)
|
|
66
79
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
80
|
+
json = error.to_json
|
|
67
81
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
|
|
68
|
-
append_result(execution, tool_call, error)
|
|
69
|
-
|
|
82
|
+
append_result(execution, tool_call, error, json: json, error: true)
|
|
83
|
+
heartbeat!
|
|
84
|
+
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
|
|
70
85
|
ToolExecution.new(attrs)
|
|
71
86
|
end
|
|
72
87
|
|
|
73
|
-
def append_result(execution, tool_call, payload)
|
|
88
|
+
def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
|
|
74
89
|
message = turn.conversation.append_message(
|
|
75
90
|
role: "tool",
|
|
76
91
|
kind: "tool_result",
|
|
77
|
-
|
|
92
|
+
content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
|
|
78
93
|
turn_id: turn.id,
|
|
79
94
|
tool_execution_id: execution.id,
|
|
80
|
-
metadata: { "
|
|
95
|
+
metadata: { "tool_name" => tool_call.name }
|
|
81
96
|
)
|
|
82
97
|
turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
83
98
|
end
|
|
84
99
|
|
|
100
|
+
def skip_remaining(calls, terminal:)
|
|
101
|
+
calls.each do |call|
|
|
102
|
+
payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
|
|
103
|
+
execution = ToolExecution.new(create_execution(call))
|
|
104
|
+
attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
|
|
105
|
+
append_result(ToolExecution.new(attrs), call, payload)
|
|
106
|
+
turn.emit("tool_call.skipped", id: call.id, name: call.name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def heartbeat!
|
|
111
|
+
turn.send(:heartbeat!)
|
|
112
|
+
end
|
|
113
|
+
|
|
85
114
|
def tool_for(name)
|
|
86
115
|
turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
|
|
87
116
|
end
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -36,36 +36,52 @@ module TurnKit
|
|
|
36
36
|
@on_event = block if block
|
|
37
37
|
return self unless status == "pending"
|
|
38
38
|
|
|
39
|
-
|
|
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
|
-
|
|
50
|
+
count_iteration!
|
|
45
51
|
TurnKit::Compaction.maybe_compact!(self)
|
|
46
52
|
|
|
47
53
|
request = model_request
|
|
48
|
-
|
|
54
|
+
emit_model_requested("model.requested", request)
|
|
49
55
|
result = call_client(request)
|
|
50
|
-
emit("model.completed", model: result.model || model, tool_call_count: result.tool_calls.length)
|
|
51
56
|
result_cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
52
57
|
|
|
53
|
-
budget.add_cost!(result_cost.total)
|
|
54
58
|
add_usage!(result.usage, cost: result_cost)
|
|
59
|
+
emit_model_completed("model.completed", result, result_cost, model: model)
|
|
60
|
+
budget.add_cost!(result_cost.total)
|
|
55
61
|
persist_assistant_message(result)
|
|
56
62
|
|
|
57
63
|
if result.tool_calls?
|
|
58
64
|
runner = ToolRunner.new(self)
|
|
59
65
|
terminal = runner.dispatch(result.tool_calls)
|
|
60
66
|
if terminal
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
candidate = append_terminal_completion(runner, terminal)
|
|
68
|
+
else
|
|
69
|
+
next
|
|
63
70
|
end
|
|
64
71
|
else
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
candidate = result.text
|
|
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
|
|
68
81
|
end
|
|
82
|
+
|
|
83
|
+
complete_with_output(candidate, output_data: result.output_data, audit: audit)
|
|
84
|
+
break
|
|
69
85
|
end
|
|
70
86
|
reload
|
|
71
87
|
self
|
|
@@ -96,6 +112,10 @@ module TurnKit
|
|
|
96
112
|
@record["output_data"]
|
|
97
113
|
end
|
|
98
114
|
|
|
115
|
+
def policy_audit
|
|
116
|
+
(@record["options"] || {})["policy_audit"]
|
|
117
|
+
end
|
|
118
|
+
|
|
99
119
|
def usage
|
|
100
120
|
Usage.from_h(@record["usage"] || {})
|
|
101
121
|
end
|
|
@@ -125,6 +145,28 @@ module TurnKit
|
|
|
125
145
|
emit_event(Event.new(type: type, turn_id: id, conversation_id: conversation.id, payload: payload))
|
|
126
146
|
end
|
|
127
147
|
|
|
148
|
+
def internal_model_call(model:, messages:, instructions:, tools: [], thinking: nil, output_schema: nil, metadata: {}, purpose:, client: nil)
|
|
149
|
+
request = ModelRequest.new(
|
|
150
|
+
model: model,
|
|
151
|
+
messages: messages,
|
|
152
|
+
tools: tools,
|
|
153
|
+
instructions: instructions,
|
|
154
|
+
thinking: thinking,
|
|
155
|
+
output_schema: output_schema,
|
|
156
|
+
metadata: { purpose: purpose.to_s, turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
|
|
157
|
+
)
|
|
158
|
+
model_client = client || agent.effective_client
|
|
159
|
+
model_client.validate!(model: request.model)
|
|
160
|
+
|
|
161
|
+
emit_model_requested("#{purpose}.model.requested", request)
|
|
162
|
+
result = call_client(request, client: model_client)
|
|
163
|
+
result_cost = Cost.from_usage(result.usage, model: result.model || request.model)
|
|
164
|
+
add_usage!(result.usage, cost: result_cost)
|
|
165
|
+
emit_model_completed("#{purpose}.model.completed", result, result_cost, model: request.model)
|
|
166
|
+
budget.add_cost!(result_cost.total)
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
128
170
|
private
|
|
129
171
|
def model_request
|
|
130
172
|
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
@@ -148,7 +190,7 @@ module TurnKit
|
|
|
148
190
|
)
|
|
149
191
|
end
|
|
150
192
|
|
|
151
|
-
def call_client(request)
|
|
193
|
+
def call_client(request, client: agent.effective_client)
|
|
152
194
|
kwargs = {
|
|
153
195
|
model: request.model,
|
|
154
196
|
messages: request.messages,
|
|
@@ -159,9 +201,9 @@ module TurnKit
|
|
|
159
201
|
metadata: request.metadata,
|
|
160
202
|
on_event: ->(event) { emit_event(event) }
|
|
161
203
|
}
|
|
162
|
-
accepted = chat_keyword_names(
|
|
204
|
+
accepted = chat_keyword_names(client)
|
|
163
205
|
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
164
|
-
|
|
206
|
+
client.chat(**kwargs)
|
|
165
207
|
end
|
|
166
208
|
|
|
167
209
|
def chat_keyword_names(client)
|
|
@@ -176,6 +218,26 @@ module TurnKit
|
|
|
176
218
|
MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
|
|
177
219
|
end
|
|
178
220
|
|
|
221
|
+
def emit_model_requested(type, request)
|
|
222
|
+
emit(
|
|
223
|
+
type,
|
|
224
|
+
model: request.model,
|
|
225
|
+
tool_names: request.tool_names,
|
|
226
|
+
message_count: request.messages.length,
|
|
227
|
+
prompt: request.report
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def emit_model_completed(type, result, cost, model: self.model)
|
|
232
|
+
emit(
|
|
233
|
+
type,
|
|
234
|
+
model: result.model || model,
|
|
235
|
+
tool_call_count: result.tool_calls.length,
|
|
236
|
+
usage: result.usage.to_h,
|
|
237
|
+
cost: cost.to_h
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
179
241
|
def thinking_from_options
|
|
180
242
|
options = (@record["options"] || {}).transform_keys(&:to_s)
|
|
181
243
|
return Agent.normalize_thinking(options["thinking"]) if options.key?("thinking")
|
|
@@ -203,9 +265,9 @@ module TurnKit
|
|
|
203
265
|
message = conversation.append_message(
|
|
204
266
|
role: "assistant",
|
|
205
267
|
kind: "tool_call",
|
|
206
|
-
|
|
268
|
+
content: result.parts,
|
|
207
269
|
turn_id: id,
|
|
208
|
-
metadata: {
|
|
270
|
+
metadata: {}
|
|
209
271
|
)
|
|
210
272
|
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
211
273
|
result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
|
|
@@ -215,12 +277,73 @@ module TurnKit
|
|
|
215
277
|
end
|
|
216
278
|
end
|
|
217
279
|
|
|
218
|
-
def
|
|
280
|
+
def append_terminal_completion(runner, execution)
|
|
219
281
|
message = runner.completion_message(execution)
|
|
220
282
|
assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
|
|
221
283
|
emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
|
|
222
|
-
|
|
223
|
-
|
|
284
|
+
message
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def complete_with_output(text, output_data: nil, audit: nil)
|
|
288
|
+
attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
|
|
289
|
+
if audit && !audit.clean? && agent.output_policy_mode == :fail
|
|
290
|
+
attrs[:status] = "failed"
|
|
291
|
+
attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "policy_audit" => audit.to_h }
|
|
292
|
+
else
|
|
293
|
+
attrs[:status] = "completed"
|
|
294
|
+
end
|
|
295
|
+
update!(attrs)
|
|
296
|
+
persist_policy_audit(audit) if audit
|
|
297
|
+
|
|
298
|
+
if failed?
|
|
299
|
+
emit("turn.failed", error: @record["error"])
|
|
300
|
+
else
|
|
301
|
+
emit("turn.completed", status: status, output_text: text)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def check_policy(text, output_data: nil)
|
|
306
|
+
constraints = agent.effective_output_policy
|
|
307
|
+
return nil if constraints.empty?
|
|
308
|
+
|
|
309
|
+
output = output_data.nil? ? text : output_data
|
|
310
|
+
TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def persist_policy_audit(audit)
|
|
314
|
+
options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
|
|
315
|
+
update!(options: options)
|
|
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")
|
|
224
347
|
end
|
|
225
348
|
|
|
226
349
|
def add_usage!(usage, cost: nil)
|
|
@@ -239,6 +362,27 @@ module TurnKit
|
|
|
239
362
|
update!(attributes)
|
|
240
363
|
end
|
|
241
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
|
+
|
|
242
386
|
def aggregate_cost(current, cost)
|
|
243
387
|
return cost unless current
|
|
244
388
|
|