turnkit 0.4.1 → 0.5.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 +34 -0
- data/README.md +126 -51
- data/UPGRADE.md +29 -0
- data/UPGRADE_TO_0_4_2.md +425 -0
- data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
- data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +34 -10
- data/lib/turnkit/adapters/codex.rb +8 -11
- data/lib/turnkit/adapters/ruby_llm.rb +30 -46
- data/lib/turnkit/agent.rb +43 -10
- data/lib/turnkit/budget.rb +1 -1
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/compaction.rb +46 -46
- data/lib/turnkit/cost.rb +29 -31
- data/lib/turnkit/image_result.rb +1 -0
- data/lib/turnkit/image_tool.rb +2 -2
- data/lib/turnkit/media_analysis_result.rb +0 -2
- data/lib/turnkit/memory_store.rb +11 -6
- data/lib/turnkit/model_request.rb +4 -2
- data/lib/turnkit/output_policy.rb +1 -15
- data/lib/turnkit/reconciliation.rb +72 -0
- data/lib/turnkit/record.rb +1 -1
- data/lib/turnkit/run.rb +0 -4
- data/lib/turnkit/store.rb +17 -8
- data/lib/turnkit/sub_agent_tool.rb +9 -14
- data/lib/turnkit/system_prompt.rb +32 -96
- data/lib/turnkit/tool.rb +5 -16
- data/lib/turnkit/tool_runner.rb +27 -3
- data/lib/turnkit/turn.rb +73 -89
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/view_media_tool.rb +2 -2
- data/lib/turnkit.rb +3 -21
- metadata +16 -30
- data/lib/turnkit/prompt_contribution.rb +0 -13
- data/lib/turnkit/rails/railtie.rb +0 -9
- data/lib/turnkit/workflow.rb +0 -58
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install_generator.rb +0 -0
|
@@ -75,7 +75,7 @@ module TurnKit
|
|
|
75
75
|
else
|
|
76
76
|
audit_client = client || TurnKit.client
|
|
77
77
|
audit_client.validate!(model: model_name)
|
|
78
|
-
chat(
|
|
78
|
+
audit_client.chat(model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
|
|
79
79
|
end
|
|
80
80
|
data = result.output_data || parse_json(result.text)
|
|
81
81
|
return if data.fetch("approved", false)
|
|
@@ -111,20 +111,6 @@ module TurnKit
|
|
|
111
111
|
[ { role: :user, content: JSON.generate(output: output) } ]
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
def chat(client, **kwargs)
|
|
115
|
-
accepted = chat_keyword_names(client)
|
|
116
|
-
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
117
|
-
client.chat(**kwargs)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def chat_keyword_names(client)
|
|
121
|
-
client.method(:chat).parameters.filter_map do |kind, name|
|
|
122
|
-
return [ :keyrest ] if kind == :keyrest
|
|
123
|
-
|
|
124
|
-
name if %i[key keyreq].include?(kind)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
114
|
def parse_json(value)
|
|
129
115
|
JSON.parse(extract_json(value.to_s))
|
|
130
116
|
rescue JSON::ParserError
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Reconciles turns abandoned by a dead worker: atomically marks them stale,
|
|
5
|
+
# marks their unfinished tool executions interrupted, and appends synthetic
|
|
6
|
+
# error tool results so the persisted transcript stays structurally complete
|
|
7
|
+
# for continuation. TurnKit never reruns an interrupted tool; the synthetic
|
|
8
|
+
# result tells the continued model the outcome is unknown.
|
|
9
|
+
module Reconciliation
|
|
10
|
+
INTERRUPTED_MESSAGE = "Tool execution was interrupted before a result was recorded. " \
|
|
11
|
+
"It is unknown whether the operation ran; do not assume it did or did not."
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def reconcile!(before:)
|
|
16
|
+
TurnKit.store.reconcile_stale_turns(before: before).each do |turn|
|
|
17
|
+
emit("turn.stale", turn)
|
|
18
|
+
executions = interrupt_tool_executions(turn)
|
|
19
|
+
repair_transcript(turn, executions)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def interrupt_tool_executions(turn)
|
|
24
|
+
store = TurnKit.store
|
|
25
|
+
store.list_tool_executions(turn_id: turn.fetch("id")).map do |execution|
|
|
26
|
+
next execution unless %w[pending running].include?(execution.fetch("status"))
|
|
27
|
+
|
|
28
|
+
interrupted = store.claim_tool_execution(
|
|
29
|
+
execution.fetch("id"),
|
|
30
|
+
from: execution.fetch("status"),
|
|
31
|
+
to: "interrupted",
|
|
32
|
+
error: { "message" => "interrupted: worker terminated while the tool was executing" },
|
|
33
|
+
completed_at: Clock.now
|
|
34
|
+
)
|
|
35
|
+
next execution unless interrupted
|
|
36
|
+
|
|
37
|
+
emit("tool_call.interrupted", turn, id: interrupted.fetch("tool_call_id"), name: interrupted.fetch("tool_name"), tool_execution_id: interrupted.fetch("id"))
|
|
38
|
+
interrupted
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def repair_transcript(turn, executions)
|
|
43
|
+
store = TurnKit.store
|
|
44
|
+
messages = store.list_messages(turn.fetch("conversation_id"))
|
|
45
|
+
resolved = messages
|
|
46
|
+
.select { |message| message["kind"] == "tool_result" }
|
|
47
|
+
.flat_map { |message| message["content"].map { |part| part["tool_call_id"] } }
|
|
48
|
+
|
|
49
|
+
messages
|
|
50
|
+
.select { |message| message["turn_id"] == turn.fetch("id") && message["kind"] == "tool_call" }
|
|
51
|
+
.flat_map { |message| message["content"].select { |part| part["type"] == "tool_call" } }
|
|
52
|
+
.reject { |part| resolved.include?(part["id"]) }
|
|
53
|
+
.each do |part|
|
|
54
|
+
execution = executions.find { |candidate| candidate["tool_call_id"] == part["id"] }
|
|
55
|
+
message = store.append_message(
|
|
56
|
+
"conversation_id" => turn.fetch("conversation_id"),
|
|
57
|
+
"turn_id" => turn.fetch("id"),
|
|
58
|
+
"role" => "tool",
|
|
59
|
+
"kind" => "tool_result",
|
|
60
|
+
"content" => [ { "type" => "tool_result", "tool_call_id" => part["id"], "text" => { "error" => true, "message" => INTERRUPTED_MESSAGE }.to_json, "error" => true } ],
|
|
61
|
+
"tool_execution_id" => execution&.fetch("id"),
|
|
62
|
+
"metadata" => { "tool_name" => part["name"], "interrupted" => true }
|
|
63
|
+
)
|
|
64
|
+
emit("message.created", turn, message_id: message.fetch("id"), role: "tool", kind: "tool_result")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def emit(type, turn, payload = {})
|
|
69
|
+
TurnKit.on_event&.call(Event.new(type: type, turn_id: turn.fetch("id"), conversation_id: turn.fetch("conversation_id"), payload: payload))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Record
|
|
5
5
|
TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
|
|
6
|
-
TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
|
|
6
|
+
TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled interrupted].freeze
|
|
7
7
|
|
|
8
8
|
TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
|
|
9
9
|
TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
|
data/lib/turnkit/run.rb
CHANGED
|
@@ -11,16 +11,12 @@ module TurnKit
|
|
|
11
11
|
def id = turn.id
|
|
12
12
|
def root_turn_id = turn.root_turn_id
|
|
13
13
|
def status = turn.status
|
|
14
|
-
def output = output_text
|
|
15
14
|
def output_text = turn.output_text
|
|
16
15
|
def output_data = turn.output_data
|
|
17
16
|
def policy_audit = turn.policy_audit
|
|
18
17
|
def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
|
|
19
18
|
def usage = Usage.from_records(turn_records)
|
|
20
19
|
def cost = Cost.from_records(turn_records)
|
|
21
|
-
def steps = turn_records.length
|
|
22
|
-
def tool_calls = tool_executions
|
|
23
|
-
def persisted? = true
|
|
24
20
|
|
|
25
21
|
def error
|
|
26
22
|
turn.store.load_turn(id)["error"]
|
data/lib/turnkit/store.rb
CHANGED
|
@@ -12,19 +12,28 @@ 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
update_turn(id, attributes.merge(status: to))
|
|
20
|
-
end
|
|
15
|
+
# claim_turn is the concurrency-safety point: it must atomically
|
|
16
|
+
# compare-and-set status from `from` to `to` (returning nil when the turn
|
|
17
|
+
# is not in `from`), so concurrent workers cannot both claim a turn.
|
|
18
|
+
def claim_turn(_id, from: "pending", to: "running", **_attributes) = raise(NotImplementedError)
|
|
21
19
|
def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
|
|
22
20
|
|
|
23
21
|
def create_tool_execution(_attributes) = raise(NotImplementedError)
|
|
24
22
|
def load_tool_execution(_id) = raise(NotImplementedError)
|
|
25
|
-
|
|
23
|
+
# claim_tool_execution mirrors claim_turn: an atomic compare-and-set on
|
|
24
|
+
# status, so a tool result recorded by a live worker and a reconciler
|
|
25
|
+
# marking the execution interrupted cannot overwrite each other.
|
|
26
|
+
def claim_tool_execution(_id, from: "running", to: "completed", **_attributes) = raise(NotImplementedError)
|
|
26
27
|
def list_tool_executions(turn_id:) = raise(NotImplementedError)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
# reconcile_stale_turns is the other concurrency-safety point: it must
|
|
30
|
+
# atomically transition each pending/running turn whose stale anchor
|
|
31
|
+
# (heartbeat_at, else started_at, else created_at) is older than `before`
|
|
32
|
+
# to `stale`, rechecking both predicates at write time so a concurrently
|
|
33
|
+
# claimed, heartbeated, or completed turn is never overwritten. Returns
|
|
34
|
+
# the reconciled turn records. Descendant turns of a dead process stop
|
|
35
|
+
# heartbeating too and are reconciled by the same predicate, so no
|
|
36
|
+
# explicit subtree cascade is needed.
|
|
37
|
+
def reconcile_stale_turns(before:) = []
|
|
29
38
|
end
|
|
30
39
|
end
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class SubAgentTool < Tool
|
|
5
|
-
parameter :task, :string, required: true, description: "The task for the sub-agent
|
|
6
|
-
parameter :context, :string, required: false, description: "Relevant context for the sub-agent."
|
|
5
|
+
parameter :task, :string, required: true, description: "The complete task for the sub-agent, including all relevant context."
|
|
7
6
|
|
|
8
7
|
def self.for(agent)
|
|
9
8
|
Class.new(self) do
|
|
@@ -18,25 +17,21 @@ module TurnKit
|
|
|
18
17
|
end
|
|
19
18
|
end
|
|
20
19
|
|
|
21
|
-
def call(task:, context:
|
|
20
|
+
def call(task:, context:)
|
|
22
21
|
sub_agent = self.class.agent
|
|
23
|
-
parent_turn =
|
|
24
|
-
|
|
25
|
-
conversation = sub_agent.conversation(metadata: {
|
|
22
|
+
parent_turn = context.turn
|
|
23
|
+
lineage = {
|
|
26
24
|
"parent_conversation_id" => parent_turn.conversation.id,
|
|
27
25
|
"parent_turn_id" => parent_turn.id,
|
|
28
|
-
"parent_tool_execution_id" =>
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"parent_turn_id" => parent_turn.id,
|
|
33
|
-
"parent_tool_execution_id" => turnkit_context.execution.id
|
|
34
|
-
})
|
|
26
|
+
"parent_tool_execution_id" => context.execution.id
|
|
27
|
+
}
|
|
28
|
+
conversation = sub_agent.conversation(metadata: lineage)
|
|
29
|
+
trigger = conversation.say(task, metadata: lineage)
|
|
35
30
|
child = conversation.run!(
|
|
36
31
|
trigger_message_id: trigger.id,
|
|
37
32
|
budget: parent_turn.budget,
|
|
38
33
|
parent_turn: parent_turn,
|
|
39
|
-
parent_tool_execution:
|
|
34
|
+
parent_tool_execution: context.execution,
|
|
40
35
|
depth: parent_turn.depth + 1,
|
|
41
36
|
model: sub_agent.effective_model,
|
|
42
37
|
agent: sub_agent,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class SystemPrompt
|
|
5
5
|
DEFAULT_SECTIONS = %i[agent instructions behavior loaded_skills available_skills tools subject live_context environment].freeze
|
|
6
|
-
CACHE_BOUNDARY = "<!-- TURNKIT_DYNAMIC_PROMPT_BOUNDARY -->"
|
|
7
6
|
NONE_PROMPT = "You are an assistant running inside TurnKit."
|
|
8
7
|
PROMPT_MODES = %i[full minimal task none].freeze
|
|
9
8
|
MODE_SECTIONS = {
|
|
@@ -13,7 +12,6 @@ module TurnKit
|
|
|
13
12
|
none: []
|
|
14
13
|
}.freeze
|
|
15
14
|
DYNAMIC_SECTIONS = %i[subject live_context environment].freeze
|
|
16
|
-
OVERRIDABLE_SECTIONS = %i[behavior tools].freeze
|
|
17
15
|
|
|
18
16
|
SECTION_METHODS = {
|
|
19
17
|
agent: :agent_section,
|
|
@@ -92,44 +90,28 @@ module TurnKit
|
|
|
92
90
|
raise ArgumentError, "unknown prompt mode: #{@mode}" unless PROMPT_MODES.include?(@mode)
|
|
93
91
|
|
|
94
92
|
@sections = Array(sections || prompt_sections_for_mode)
|
|
95
|
-
@prompt_contribution = nil
|
|
96
93
|
end
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
boundary_inserted = false
|
|
106
|
-
sections.each do |section|
|
|
107
|
-
rendered = render(section)
|
|
108
|
-
next if rendered.nil? || rendered.strip.empty?
|
|
109
|
-
|
|
110
|
-
if dynamic_section?(section) && !boundary_inserted
|
|
111
|
-
values << CACHE_BOUNDARY
|
|
112
|
-
boundary_inserted = true
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
values << rendered
|
|
116
|
-
end
|
|
95
|
+
# The prompt splits into a stable part (identical across turns of the same
|
|
96
|
+
# agent, safe to cache) and a dynamic part (subject, live context,
|
|
97
|
+
# environment) recomputed each turn. Adapters that support prompt caching
|
|
98
|
+
# receive both via ModelRequest#instructions and #dynamic_instructions.
|
|
99
|
+
def stable
|
|
100
|
+
parts.fetch(0).join("\n\n")
|
|
101
|
+
end
|
|
117
102
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
103
|
+
def dynamic
|
|
104
|
+
parts.fetch(1).join("\n\n")
|
|
105
|
+
end
|
|
122
106
|
|
|
123
|
-
|
|
107
|
+
def to_s
|
|
108
|
+
[ stable, dynamic ].reject(&:empty?).join("\n\n")
|
|
124
109
|
end
|
|
125
110
|
|
|
126
111
|
def render(section)
|
|
127
112
|
method = SECTION_METHODS[section.to_sym]
|
|
128
113
|
raise ArgumentError, "unknown prompt section: #{section}" unless method
|
|
129
114
|
|
|
130
|
-
override = section_override(section)
|
|
131
|
-
return tagged(section, override) if override
|
|
132
|
-
|
|
133
115
|
public_send(method)
|
|
134
116
|
end
|
|
135
117
|
|
|
@@ -292,11 +274,9 @@ module TurnKit
|
|
|
292
274
|
|
|
293
275
|
def report
|
|
294
276
|
text = to_s
|
|
295
|
-
stable, dynamic = self.class.split_cache_boundary(text)
|
|
296
277
|
{
|
|
297
278
|
"chars" => text.length,
|
|
298
279
|
"hash" => Digest::SHA256.hexdigest(text),
|
|
299
|
-
"has_cache_boundary" => text.include?(CACHE_BOUNDARY),
|
|
300
280
|
"stable_chars" => stable.length,
|
|
301
281
|
"dynamic_chars" => dynamic.length,
|
|
302
282
|
"sections" => sections.map(&:to_s),
|
|
@@ -304,12 +284,27 @@ module TurnKit
|
|
|
304
284
|
}
|
|
305
285
|
end
|
|
306
286
|
|
|
307
|
-
def self.split_cache_boundary(text)
|
|
308
|
-
stable, dynamic = text.to_s.split(CACHE_BOUNDARY, 2)
|
|
309
|
-
[ stable.to_s, dynamic.to_s ]
|
|
310
|
-
end
|
|
311
|
-
|
|
312
287
|
private
|
|
288
|
+
def parts
|
|
289
|
+
@parts ||= build_parts
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_parts
|
|
293
|
+
return [ [ NONE_PROMPT ], [] ] if mode == :none
|
|
294
|
+
|
|
295
|
+
stable_parts = []
|
|
296
|
+
dynamic_parts = []
|
|
297
|
+
target = stable_parts
|
|
298
|
+
sections.each do |section|
|
|
299
|
+
rendered = render(section)
|
|
300
|
+
next if rendered.nil? || rendered.strip.empty?
|
|
301
|
+
|
|
302
|
+
target = dynamic_parts if dynamic_section?(section)
|
|
303
|
+
target << rendered
|
|
304
|
+
end
|
|
305
|
+
[ stable_parts, dynamic_parts ]
|
|
306
|
+
end
|
|
307
|
+
|
|
313
308
|
def tagged(name, content)
|
|
314
309
|
"<#{name}>\n#{content}\n</#{name}>"
|
|
315
310
|
end
|
|
@@ -383,64 +378,5 @@ module TurnKit
|
|
|
383
378
|
end
|
|
384
379
|
end
|
|
385
380
|
|
|
386
|
-
def prompt_contribution
|
|
387
|
-
@prompt_contribution ||= merge_prompt_contributions(resolve_prompt_contributions)
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
def resolve_prompt_contributions
|
|
391
|
-
contributors = Array(TurnKit.system_prompt_contributors)
|
|
392
|
-
contributors += matching_model_prompt_contributors
|
|
393
|
-
contributors.filter_map do |contributor|
|
|
394
|
-
value = contributor.respond_to?(:call) ? contributor.call(prompt_build_context) : contributor
|
|
395
|
-
normalize_prompt_contribution(value)
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def matching_model_prompt_contributors
|
|
400
|
-
model_name = (turn.model || agent.effective_model).to_s
|
|
401
|
-
TurnKit.model_prompt_contributors.flat_map do |matcher, contributor|
|
|
402
|
-
matches = case matcher
|
|
403
|
-
when Regexp
|
|
404
|
-
matcher.match?(model_name)
|
|
405
|
-
else
|
|
406
|
-
matcher.to_s == model_name
|
|
407
|
-
end
|
|
408
|
-
matches ? Array(contributor) : []
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
def normalize_prompt_contribution(value)
|
|
413
|
-
case value
|
|
414
|
-
when nil, false
|
|
415
|
-
nil
|
|
416
|
-
when PromptContribution
|
|
417
|
-
value
|
|
418
|
-
when Hash
|
|
419
|
-
PromptContribution.new(
|
|
420
|
-
stable_prefix: value[:stable_prefix] || value["stable_prefix"],
|
|
421
|
-
dynamic_suffix: value[:dynamic_suffix] || value["dynamic_suffix"],
|
|
422
|
-
section_overrides: value[:section_overrides] || value["section_overrides"]
|
|
423
|
-
)
|
|
424
|
-
else
|
|
425
|
-
PromptContribution.new(stable_prefix: value.to_s)
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def merge_prompt_contributions(contributions)
|
|
430
|
-
stable_prefix = contributions.map(&:stable_prefix).reject(&:empty?).join("\n\n")
|
|
431
|
-
dynamic_suffix = contributions.map(&:dynamic_suffix).reject(&:empty?).join("\n\n")
|
|
432
|
-
section_overrides = contributions.each_with_object({}) do |contribution, overrides|
|
|
433
|
-
overrides.merge!(contribution.section_overrides)
|
|
434
|
-
end
|
|
435
|
-
PromptContribution.new(stable_prefix: stable_prefix, dynamic_suffix: dynamic_suffix, section_overrides: section_overrides)
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def section_override(section)
|
|
439
|
-
key = section.to_sym
|
|
440
|
-
return nil unless OVERRIDABLE_SECTIONS.include?(key)
|
|
441
|
-
|
|
442
|
-
value = prompt_contribution.section_overrides[key]
|
|
443
|
-
value.to_s unless value.nil?
|
|
444
|
-
end
|
|
445
381
|
end
|
|
446
382
|
end
|
data/lib/turnkit/tool.rb
CHANGED
|
@@ -25,6 +25,7 @@ module TurnKit
|
|
|
25
25
|
name = name.to_s
|
|
26
26
|
raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
|
|
27
27
|
raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
|
|
28
|
+
raise ArgumentError, "context is a reserved parameter name" if name == "context"
|
|
28
29
|
raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
|
|
29
30
|
raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
|
|
30
31
|
|
|
@@ -113,23 +114,15 @@ module TurnKit
|
|
|
113
114
|
end
|
|
114
115
|
|
|
115
116
|
def call(arguments = {}, context:)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
rescue ArgumentError => error
|
|
119
|
-
raise if error.message !~ /wrong number of arguments|missing keyword/
|
|
117
|
+
required = instance_method(:initialize).parameters.any? { |kind, _| %i[req keyreq].include?(kind) }
|
|
118
|
+
raise ToolError, "#{tool_name} requires constructor arguments; register an instance instead" if required
|
|
120
119
|
|
|
121
|
-
|
|
122
|
-
end
|
|
123
|
-
invoke(instance, arguments, context: context)
|
|
120
|
+
invoke(new, arguments, context: context)
|
|
124
121
|
end
|
|
125
122
|
|
|
126
123
|
def invoke(instance, arguments = {}, context:)
|
|
127
124
|
keyword_arguments = symbolize(validate_arguments(arguments))
|
|
128
|
-
|
|
129
|
-
instance.call(**keyword_arguments, turnkit_context: context)
|
|
130
|
-
else
|
|
131
|
-
instance.call(**keyword_arguments, context: context)
|
|
132
|
-
end
|
|
125
|
+
instance.call(**keyword_arguments, context: context)
|
|
133
126
|
end
|
|
134
127
|
|
|
135
128
|
private
|
|
@@ -172,10 +165,6 @@ module TurnKit
|
|
|
172
165
|
SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: param.fetch(:name))
|
|
173
166
|
end
|
|
174
167
|
|
|
175
|
-
def accepts_turnkit_context?(instance)
|
|
176
|
-
instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
|
|
177
|
-
end
|
|
178
|
-
|
|
179
168
|
def symbolize(hash)
|
|
180
169
|
hash.transform_keys(&:to_sym)
|
|
181
170
|
end
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -71,7 +71,9 @@ module TurnKit
|
|
|
71
71
|
|
|
72
72
|
def finish_success(execution, tool_call, payload)
|
|
73
73
|
json = payload.to_json
|
|
74
|
-
attrs = turn.store.
|
|
74
|
+
attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "completed", result: payload, completed_at: Clock.now)
|
|
75
|
+
return superseded_execution(execution) unless attrs
|
|
76
|
+
|
|
75
77
|
append_result(execution, tool_call, payload, json: json, error: false)
|
|
76
78
|
heartbeat!
|
|
77
79
|
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
|
|
@@ -81,13 +83,21 @@ module TurnKit
|
|
|
81
83
|
def finish_error(execution, tool_call, message, details: nil)
|
|
82
84
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
83
85
|
json = error.to_json
|
|
84
|
-
attrs = turn.store.
|
|
86
|
+
attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "failed", error: error, completed_at: Clock.now)
|
|
87
|
+
return superseded_execution(execution) unless attrs
|
|
88
|
+
|
|
85
89
|
append_result(execution, tool_call, error, json: json, error: true)
|
|
86
90
|
heartbeat!
|
|
87
91
|
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
|
|
88
92
|
ToolExecution.new(attrs)
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
# The execution was reconciled (interrupted) while the tool ran; a
|
|
96
|
+
# synthetic result message already exists, so the late result is dropped.
|
|
97
|
+
def superseded_execution(execution)
|
|
98
|
+
ToolExecution.new(turn.store.load_tool_execution(execution.id))
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
|
|
92
102
|
message = turn.conversation.append_message(
|
|
93
103
|
role: "tool",
|
|
@@ -104,7 +114,9 @@ module TurnKit
|
|
|
104
114
|
calls.each do |call|
|
|
105
115
|
payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
|
|
106
116
|
execution = ToolExecution.new(create_execution(call))
|
|
107
|
-
attrs = turn.store.
|
|
117
|
+
attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "cancelled", result: payload, completed_at: Clock.now)
|
|
118
|
+
next unless attrs
|
|
119
|
+
|
|
108
120
|
append_result(ToolExecution.new(attrs), call, payload)
|
|
109
121
|
turn.emit("tool_call.skipped", id: call.id, name: call.name)
|
|
110
122
|
end
|
|
@@ -118,12 +130,24 @@ module TurnKit
|
|
|
118
130
|
turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
|
|
119
131
|
end
|
|
120
132
|
|
|
133
|
+
# Heartbeats while the tool runs so a tool slower than TurnKit.timeout
|
|
134
|
+
# keeps its turn's stale anchor fresh and is not falsely reconciled.
|
|
121
135
|
def call_tool(tool, arguments, context:)
|
|
136
|
+
interval = (TurnKit.timeout || 300) / 3.0
|
|
137
|
+
heartbeat = Thread.new do
|
|
138
|
+
loop do
|
|
139
|
+
sleep interval
|
|
140
|
+
heartbeat!
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
122
144
|
if tool.is_a?(Class)
|
|
123
145
|
tool.call(arguments, context: context)
|
|
124
146
|
else
|
|
125
147
|
tool.class.invoke(tool, arguments, context: context)
|
|
126
148
|
end
|
|
149
|
+
ensure
|
|
150
|
+
heartbeat.kill
|
|
127
151
|
end
|
|
128
152
|
|
|
129
153
|
def normalize_payload(value)
|