turnkit 0.2.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +34 -9
- data/UPGRADE.md +37 -299
- data/lib/turnkit/adapters/ruby_llm.rb +29 -0
- data/lib/turnkit/agent.rb +22 -33
- data/lib/turnkit/budget.rb +24 -5
- data/lib/turnkit/compaction.rb +1 -0
- data/lib/turnkit/error.rb +1 -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_policy.rb +6 -0
- data/lib/turnkit/result.rb +29 -4
- data/lib/turnkit/run.rb +4 -5
- 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 +38 -16
- data/lib/turnkit/turn.rb +90 -23
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/workflow.rb +20 -94
- data/lib/turnkit.rb +5 -10
- metadata +3 -1
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.
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -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
|
|
@@ -27,6 +27,10 @@ module TurnKit
|
|
|
27
27
|
new(name: name || File.basename(path, File.extname(path)), content: File.read(path), **options)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def self.from_skill(skill, **options)
|
|
31
|
+
new(name: skill.key, content: skill.content, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
|
|
31
35
|
@name = name.to_s
|
|
32
36
|
@content = content.to_s
|
|
@@ -78,6 +82,8 @@ module TurnKit
|
|
|
78
82
|
|
|
79
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.
|
|
80
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
|
+
|
|
81
87
|
Policy:
|
|
82
88
|
#{content}
|
|
83
89
|
TEXT
|
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,8 +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
|
|
18
|
-
def
|
|
17
|
+
def policy_audit = turn.policy_audit
|
|
18
|
+
def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
|
|
19
19
|
def usage = Usage.from_records(turn_records)
|
|
20
20
|
def cost = Cost.from_records(turn_records)
|
|
21
21
|
def steps = turn_records.length
|
|
@@ -27,9 +27,8 @@ module TurnKit
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def messages
|
|
30
|
-
turn_records.flat_map do |
|
|
31
|
-
|
|
32
|
-
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) }
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
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
|
|
@@ -24,13 +27,7 @@ module TurnKit
|
|
|
24
27
|
|
|
25
28
|
def run(tool_call)
|
|
26
29
|
execution = ToolExecution.new(create_execution(tool_call))
|
|
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
|
|
30
|
+
heartbeat!
|
|
34
31
|
|
|
35
32
|
tool = tool_for(tool_call.name)
|
|
36
33
|
|
|
@@ -42,6 +39,13 @@ module TurnKit
|
|
|
42
39
|
return finish_error(execution, tool_call, tool_call.arguments_error)
|
|
43
40
|
end
|
|
44
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
|
+
|
|
45
49
|
context = ToolContext.new(turn: turn, execution: execution)
|
|
46
50
|
payload = begin
|
|
47
51
|
normalize_payload(call_tool(tool, tool_call.arguments, context: context))
|
|
@@ -63,32 +67,50 @@ module TurnKit
|
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def finish_success(execution, tool_call, payload)
|
|
70
|
+
json = payload.to_json
|
|
66
71
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
|
|
67
|
-
append_result(execution, tool_call, payload)
|
|
68
|
-
|
|
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)
|
|
69
75
|
ToolExecution.new(attrs)
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
def finish_error(execution, tool_call, message, details: nil)
|
|
73
79
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
80
|
+
json = error.to_json
|
|
74
81
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
|
|
75
|
-
append_result(execution, tool_call, error)
|
|
76
|
-
|
|
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)
|
|
77
85
|
ToolExecution.new(attrs)
|
|
78
86
|
end
|
|
79
87
|
|
|
80
|
-
def append_result(execution, tool_call, payload)
|
|
88
|
+
def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
|
|
81
89
|
message = turn.conversation.append_message(
|
|
82
90
|
role: "tool",
|
|
83
91
|
kind: "tool_result",
|
|
84
|
-
|
|
92
|
+
content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
|
|
85
93
|
turn_id: turn.id,
|
|
86
94
|
tool_execution_id: execution.id,
|
|
87
|
-
metadata: { "
|
|
95
|
+
metadata: { "tool_name" => tool_call.name }
|
|
88
96
|
)
|
|
89
97
|
turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
90
98
|
end
|
|
91
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
|
+
|
|
92
114
|
def tool_for(name)
|
|
93
115
|
turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
|
|
94
116
|
end
|