turnkit 0.2.10 → 0.4.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 +16 -0
- data/README.md +89 -9
- data/UPGRADE.md +37 -299
- data/lib/turnkit/adapters/ruby_llm.rb +87 -0
- data/lib/turnkit/agent.rb +22 -33
- data/lib/turnkit/budget.rb +24 -5
- data/lib/turnkit/client.rb +4 -0
- 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/image_result.rb +51 -0
- data/lib/turnkit/image_tool.rb +30 -0
- data/lib/turnkit/load_skill_tool.rb +29 -0
- data/lib/turnkit/memory_store.rb +11 -0
- data/lib/turnkit/message.rb +19 -8
- data/lib/turnkit/message_projection.rb +28 -2
- data/lib/turnkit/output_policy.rb +15 -0
- data/lib/turnkit/result.rb +41 -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 +41 -16
- data/lib/turnkit/turn.rb +161 -23
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/workflow.rb +20 -94
- data/lib/turnkit.rb +12 -10
- metadata +6 -2
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,9 +39,19 @@ 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))
|
|
52
|
+
rescue BudgetError => error
|
|
53
|
+
finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
|
|
54
|
+
raise
|
|
48
55
|
rescue StandardError => error
|
|
49
56
|
return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
|
|
50
57
|
end
|
|
@@ -63,32 +70,50 @@ module TurnKit
|
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def finish_success(execution, tool_call, payload)
|
|
73
|
+
json = payload.to_json
|
|
66
74
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
|
|
67
|
-
append_result(execution, tool_call, payload)
|
|
68
|
-
|
|
75
|
+
append_result(execution, tool_call, payload, json: json, error: false)
|
|
76
|
+
heartbeat!
|
|
77
|
+
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
|
|
69
78
|
ToolExecution.new(attrs)
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
def finish_error(execution, tool_call, message, details: nil)
|
|
73
82
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
83
|
+
json = error.to_json
|
|
74
84
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
|
|
75
|
-
append_result(execution, tool_call, error)
|
|
76
|
-
|
|
85
|
+
append_result(execution, tool_call, error, json: json, error: true)
|
|
86
|
+
heartbeat!
|
|
87
|
+
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
|
|
77
88
|
ToolExecution.new(attrs)
|
|
78
89
|
end
|
|
79
90
|
|
|
80
|
-
def append_result(execution, tool_call, payload)
|
|
91
|
+
def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
|
|
81
92
|
message = turn.conversation.append_message(
|
|
82
93
|
role: "tool",
|
|
83
94
|
kind: "tool_result",
|
|
84
|
-
|
|
95
|
+
content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
|
|
85
96
|
turn_id: turn.id,
|
|
86
97
|
tool_execution_id: execution.id,
|
|
87
|
-
metadata: { "
|
|
98
|
+
metadata: { "tool_name" => tool_call.name }
|
|
88
99
|
)
|
|
89
100
|
turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
90
101
|
end
|
|
91
102
|
|
|
103
|
+
def skip_remaining(calls, terminal:)
|
|
104
|
+
calls.each do |call|
|
|
105
|
+
payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
|
|
106
|
+
execution = ToolExecution.new(create_execution(call))
|
|
107
|
+
attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
|
|
108
|
+
append_result(ToolExecution.new(attrs), call, payload)
|
|
109
|
+
turn.emit("tool_call.skipped", id: call.id, name: call.name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def heartbeat!
|
|
114
|
+
turn.send(:heartbeat!)
|
|
115
|
+
end
|
|
116
|
+
|
|
92
117
|
def tool_for(name)
|
|
93
118
|
turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
|
|
94
119
|
end
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -36,12 +36,18 @@ 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
|
|
@@ -58,13 +64,24 @@ module TurnKit
|
|
|
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
|
-
break
|
|
72
|
+
candidate = result.text
|
|
67
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
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
complete_with_output(candidate, output_data: result.output_data, audit: audit)
|
|
84
|
+
break
|
|
68
85
|
end
|
|
69
86
|
reload
|
|
70
87
|
self
|
|
@@ -95,8 +112,8 @@ module TurnKit
|
|
|
95
112
|
@record["output_data"]
|
|
96
113
|
end
|
|
97
114
|
|
|
98
|
-
def
|
|
99
|
-
(@record["options"] || {})["
|
|
115
|
+
def policy_audit
|
|
116
|
+
(@record["options"] || {})["policy_audit"]
|
|
100
117
|
end
|
|
101
118
|
|
|
102
119
|
def usage
|
|
@@ -150,6 +167,59 @@ module TurnKit
|
|
|
150
167
|
result
|
|
151
168
|
end
|
|
152
169
|
|
|
170
|
+
def paint(prompt, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: {}, client: nil)
|
|
171
|
+
claimed_standalone = false
|
|
172
|
+
case status
|
|
173
|
+
when "pending"
|
|
174
|
+
claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
|
|
175
|
+
raise Error, "turn is already running" unless claimed
|
|
176
|
+
|
|
177
|
+
@record = claimed
|
|
178
|
+
@started_at = @record["started_at"]
|
|
179
|
+
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
|
|
180
|
+
claimed_standalone = true
|
|
181
|
+
emit("turn.started", status: status, model: model)
|
|
182
|
+
when "running"
|
|
183
|
+
# Image tools call this while their parent turn is running.
|
|
184
|
+
else
|
|
185
|
+
raise Error, "cannot paint for #{status} turn"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
image_client = client || agent.effective_client
|
|
189
|
+
request = {
|
|
190
|
+
prompt: prompt,
|
|
191
|
+
model: model,
|
|
192
|
+
provider: provider,
|
|
193
|
+
size: size,
|
|
194
|
+
assume_model_exists: assume_model_exists,
|
|
195
|
+
input_images: input_images,
|
|
196
|
+
mask: mask,
|
|
197
|
+
params: params || {},
|
|
198
|
+
metadata: { turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
image_client.validate!(model: model)
|
|
202
|
+
emit("image.requested", request.except(:input_images, :mask))
|
|
203
|
+
result = call_image_client(image_client, request)
|
|
204
|
+
result_cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
205
|
+
add_usage!(result.usage, cost: result_cost)
|
|
206
|
+
budget.add_cost!(result_cost.total)
|
|
207
|
+
image = result.images.first
|
|
208
|
+
raise Error, "image client returned no image" unless image
|
|
209
|
+
raise Error, "image client returned image without url or data" if image.url.to_s.empty? && image.data.to_s.empty?
|
|
210
|
+
|
|
211
|
+
persist_image_message(image)
|
|
212
|
+
emit("image.completed", image: image.to_h, model: image.model || model, provider: image.provider || provider&.to_s, mime_type: image.mime_type, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
|
|
213
|
+
complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if claimed_standalone
|
|
214
|
+
image
|
|
215
|
+
rescue StandardError => error
|
|
216
|
+
if claimed_standalone
|
|
217
|
+
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
218
|
+
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
219
|
+
end
|
|
220
|
+
raise
|
|
221
|
+
end
|
|
222
|
+
|
|
153
223
|
private
|
|
154
224
|
def model_request
|
|
155
225
|
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
@@ -197,6 +267,16 @@ module TurnKit
|
|
|
197
267
|
end
|
|
198
268
|
end
|
|
199
269
|
|
|
270
|
+
def call_image_client(client, request)
|
|
271
|
+
kwargs = request.merge(on_event: ->(event) { emit_event(event) })
|
|
272
|
+
accepted = client.method(:paint).parameters.filter_map do |kind, name|
|
|
273
|
+
return client.paint(**kwargs) if kind == :keyrest
|
|
274
|
+
|
|
275
|
+
name if %i[key keyreq].include?(kind)
|
|
276
|
+
end
|
|
277
|
+
client.paint(**kwargs.slice(*accepted))
|
|
278
|
+
end
|
|
279
|
+
|
|
200
280
|
def llm_messages
|
|
201
281
|
MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
|
|
202
282
|
end
|
|
@@ -248,36 +328,43 @@ module TurnKit
|
|
|
248
328
|
message = conversation.append_message(
|
|
249
329
|
role: "assistant",
|
|
250
330
|
kind: "tool_call",
|
|
251
|
-
|
|
331
|
+
content: result.parts,
|
|
252
332
|
turn_id: id,
|
|
253
|
-
metadata: {
|
|
333
|
+
metadata: {}
|
|
254
334
|
)
|
|
255
335
|
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
256
336
|
result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
|
|
337
|
+
elsif result.image?
|
|
338
|
+
message = conversation.append_message(role: "assistant", kind: "image", content: result.images.map { |image| image.to_h.merge("type" => "image") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
|
|
339
|
+
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
257
340
|
else
|
|
258
341
|
message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
|
|
259
342
|
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
260
343
|
end
|
|
261
344
|
end
|
|
262
345
|
|
|
263
|
-
def
|
|
346
|
+
def persist_image_message(image)
|
|
347
|
+
message = conversation.append_message(role: "assistant", kind: "image", content: [ image.to_h.merge("type" => "image") ], turn_id: id, metadata: { "output_data" => { "type" => "image", "images" => [ image.to_h ] } })
|
|
348
|
+
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def append_terminal_completion(runner, execution)
|
|
264
352
|
message = runner.completion_message(execution)
|
|
265
353
|
assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
|
|
266
354
|
emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
|
|
267
|
-
|
|
355
|
+
message
|
|
268
356
|
end
|
|
269
357
|
|
|
270
|
-
def complete_with_output(text, output_data: nil)
|
|
271
|
-
audit = audit_output(text, output_data: output_data)
|
|
358
|
+
def complete_with_output(text, output_data: nil, audit: nil)
|
|
272
359
|
attrs = { output_text: text, output_data: output_data, completed_at: Clock.now }
|
|
273
|
-
if audit && !audit.clean? && agent.
|
|
360
|
+
if audit && !audit.clean? && agent.output_policy_mode == :fail
|
|
274
361
|
attrs[:status] = "failed"
|
|
275
|
-
attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "
|
|
362
|
+
attrs[:error] = { "class" => "TurnKit::OutputAudit", "message" => audit.messages.join("; "), "policy_audit" => audit.to_h }
|
|
276
363
|
else
|
|
277
364
|
attrs[:status] = "completed"
|
|
278
365
|
end
|
|
279
366
|
update!(attrs)
|
|
280
|
-
|
|
367
|
+
persist_policy_audit(audit) if audit
|
|
281
368
|
|
|
282
369
|
if failed?
|
|
283
370
|
emit("turn.failed", error: @record["error"])
|
|
@@ -286,18 +373,48 @@ module TurnKit
|
|
|
286
373
|
end
|
|
287
374
|
end
|
|
288
375
|
|
|
289
|
-
def
|
|
290
|
-
constraints = agent.
|
|
376
|
+
def check_policy(text, output_data: nil)
|
|
377
|
+
constraints = agent.effective_output_policy
|
|
291
378
|
return nil if constraints.empty?
|
|
292
379
|
|
|
293
380
|
output = output_data.nil? ? text : output_data
|
|
294
|
-
TurnKit.
|
|
381
|
+
TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
|
|
295
382
|
end
|
|
296
383
|
|
|
297
|
-
def
|
|
298
|
-
options = (@record["options"] || {}).merge("
|
|
384
|
+
def persist_policy_audit(audit)
|
|
385
|
+
options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
|
|
299
386
|
update!(options: options)
|
|
300
|
-
emit("
|
|
387
|
+
emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def should_revise?(audit, revisions_used)
|
|
391
|
+
audit && !audit.clean? && revisions_used < agent.output_retries
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def append_revision_message(audit, attempt:, terminal_tool_name: nil)
|
|
395
|
+
text = <<~TEXT.strip
|
|
396
|
+
The previous output failed policy checks.
|
|
397
|
+
|
|
398
|
+
Revise the previous output. Do not introduce new claims.
|
|
399
|
+
Do not deviate from the skill or policy below.
|
|
400
|
+
|
|
401
|
+
#{revision_policy_blocks}
|
|
402
|
+
|
|
403
|
+
Violations:
|
|
404
|
+
#{audit.violations.each_with_index.map { |violation, index| "#{index + 1}. #{violation.rule}: #{violation.message}" }.join("\n")}
|
|
405
|
+
#{terminal_tool_name ? "\nResubmit via #{terminal_tool_name}." : ""}
|
|
406
|
+
TEXT
|
|
407
|
+
message = conversation.append_message(role: "user", kind: "text", text: text, turn_id: id, metadata: { "source" => "output_policy", "attempt" => attempt })
|
|
408
|
+
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def revision_policy_blocks
|
|
412
|
+
agent.effective_output_policy.filter_map do |policy|
|
|
413
|
+
next unless policy.respond_to?(:content)
|
|
414
|
+
|
|
415
|
+
key = policy.respond_to?(:name) ? policy.name : "output_policy"
|
|
416
|
+
"<skill key=\"#{key}\">\n#{policy.content}\n</skill>"
|
|
417
|
+
end.join("\n\n")
|
|
301
418
|
end
|
|
302
419
|
|
|
303
420
|
def add_usage!(usage, cost: nil)
|
|
@@ -316,6 +433,27 @@ module TurnKit
|
|
|
316
433
|
update!(attributes)
|
|
317
434
|
end
|
|
318
435
|
|
|
436
|
+
def count_iteration!
|
|
437
|
+
budget.count_iteration!
|
|
438
|
+
options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
|
|
439
|
+
update!(options: options)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def heartbeat!
|
|
443
|
+
update!(heartbeat_at: Clock.now)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def budget_limits
|
|
447
|
+
{
|
|
448
|
+
max_iterations: agent.max_iterations || TurnKit.max_iterations,
|
|
449
|
+
timeout: agent.timeout || TurnKit.timeout,
|
|
450
|
+
max_depth: agent.max_depth || TurnKit.max_depth,
|
|
451
|
+
max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
|
|
452
|
+
max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
|
|
453
|
+
max_spend: agent.max_spend || TurnKit.max_spend
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
|
|
319
457
|
def aggregate_cost(current, cost)
|
|
320
458
|
return cost unless current
|
|
321
459
|
|
data/lib/turnkit/version.rb
CHANGED