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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +126 -51
  4. data/UPGRADE.md +29 -0
  5. data/UPGRADE_TO_0_4_2.md +425 -0
  6. data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
  7. data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +34 -10
  8. data/lib/turnkit/adapters/codex.rb +8 -11
  9. data/lib/turnkit/adapters/ruby_llm.rb +30 -46
  10. data/lib/turnkit/agent.rb +43 -10
  11. data/lib/turnkit/budget.rb +1 -1
  12. data/lib/turnkit/client.rb +5 -1
  13. data/lib/turnkit/compaction.rb +46 -46
  14. data/lib/turnkit/cost.rb +29 -31
  15. data/lib/turnkit/image_result.rb +1 -0
  16. data/lib/turnkit/image_tool.rb +2 -2
  17. data/lib/turnkit/media_analysis_result.rb +0 -2
  18. data/lib/turnkit/memory_store.rb +11 -6
  19. data/lib/turnkit/model_request.rb +4 -2
  20. data/lib/turnkit/output_policy.rb +1 -15
  21. data/lib/turnkit/reconciliation.rb +72 -0
  22. data/lib/turnkit/record.rb +1 -1
  23. data/lib/turnkit/run.rb +0 -4
  24. data/lib/turnkit/store.rb +17 -8
  25. data/lib/turnkit/sub_agent_tool.rb +9 -14
  26. data/lib/turnkit/system_prompt.rb +32 -96
  27. data/lib/turnkit/tool.rb +5 -16
  28. data/lib/turnkit/tool_runner.rb +27 -3
  29. data/lib/turnkit/turn.rb +73 -89
  30. data/lib/turnkit/version.rb +1 -1
  31. data/lib/turnkit/view_media_tool.rb +2 -2
  32. data/lib/turnkit.rb +3 -21
  33. metadata +16 -30
  34. data/lib/turnkit/prompt_contribution.rb +0 -13
  35. data/lib/turnkit/rails/railtie.rb +0 -9
  36. data/lib/turnkit/workflow.rb +0 -58
  37. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
  38. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
  39. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
  40. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
  41. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
  42. /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(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
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
@@ -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
- 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
+ # 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
- def update_tool_execution(_id, _attributes) = raise(NotImplementedError)
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
- def find_stale_turns(before:) = []
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 to complete."
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: nil, turnkit_context:)
20
+ def call(task:, context:)
22
21
  sub_agent = self.class.agent
23
- parent_turn = turnkit_context.turn
24
- prompt = [ task, context ].compact.join("\n\n")
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" => turnkit_context.execution.id
29
- })
30
- trigger = conversation.say(prompt, metadata: {
31
- "parent_conversation_id" => parent_turn.conversation.id,
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: turnkit_context.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
- def to_s
99
- return NONE_PROMPT if mode == :none
100
-
101
- values = []
102
- contribution = prompt_contribution
103
- values << contribution.stable_prefix unless contribution.stable_prefix.empty?
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
- unless contribution.dynamic_suffix.empty?
119
- values << CACHE_BOUNDARY unless boundary_inserted
120
- values << contribution.dynamic_suffix
121
- end
103
+ def dynamic
104
+ parts.fetch(1).join("\n\n")
105
+ end
122
106
 
123
- values.compact.reject { |value| value.strip.empty? }.join("\n\n")
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
- instance = begin
117
- new
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
- raise ToolError, "#{tool_name} requires constructor arguments; register an instance instead"
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
- if accepts_turnkit_context?(instance)
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
@@ -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.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
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.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
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.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
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)