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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 268561a36c656098e1d23ea6de4c17616358ff931e05e1389e707a9e28fe458b
4
- data.tar.gz: 8f6731d78fed5b3e3cc94d781c4f4e26accc4f8d05842b5c56eb58a6e7448907
3
+ metadata.gz: b5694bc97b2f735e5076574e2863ee5addc41926bd85edf02e1835263ffb3516
4
+ data.tar.gz: 65286330a1d0b4bbd0e3e6c11ba73abd836fb22a44ae4b3ab48a58ecf9d19425
5
5
  SHA512:
6
- metadata.gz: ae0a246b5937e586c808a25d28f051bafc54c2a922a52d89160eb3f5ef3bf7360b1d637cbb0c170d41eb74cd536638b6f9a1880275bd0ccd2fc8dcb4ac44db5c
7
- data.tar.gz: 7ffebcfeadf51f193c7f2277a0842c2f56e00d9ff95d502915924f2a6d7e10744a0a710d1d2f5b1865182a9de21b2cce30edc3e94c16f49626912b93b1fc7063
6
+ metadata.gz: 2b3674abf0cae37286a04431f0ceb02a30e282c715e4d6d96e51c0a08d600c94a9fee6c82bf178c0b97ff080ee221b00a18b5409e72003d92c7a5430b34d5733
7
+ data.tar.gz: 7141f5cc00df42bfaf0e9b035d75f54e0b7c9b14ff71a8c95805242b32835fb410358f7f42ff2161f89896425b62fd840c05dcc2504f555430450517dc61bf9b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-06-10
4
+
5
+ - Make the task-runtime API skills-first and intentionally breaking: `max_spend` is the only spend-limit name and output validation is exposed as `output_policy` / `policy_audit`.
6
+ - Store message content as ordered typed parts, with text derived from content and tool calls/results persisted in the transcript instead of metadata.
7
+ - Add `load_skill` for progressively disclosed available skills.
8
+ - Add output-policy revision loops with `output_retries`, including skill/policy rehydration in revision prompts.
9
+ - Add deterministic `input_schema` validation before turns are created.
10
+ - Ensure terminal tools never orphan sibling tool calls; skipped siblings receive cancelled executions and tool-result messages.
11
+ - Add turn claiming, tool-runner heartbeats, persisted budget resume, and sub-agent failure details.
12
+
3
13
  ## 0.2.10 - 2026-06-10
4
14
 
5
15
  - Add output audits and file-backed output policies for validating final run output.
data/README.md CHANGED
@@ -331,8 +331,8 @@ end
331
331
 
332
332
  workflow = TurnKit::Workflow.new(
333
333
  name: "memo_writer",
334
- output_audit: [no_em_dash, numbered_lists_only],
335
- output_audit_mode: :fail
334
+ output_policy: [no_em_dash, numbered_lists_only],
335
+ output_policy_mode: :fail
336
336
  )
337
337
  ```
338
338
 
@@ -340,7 +340,7 @@ Run checks directly when you want to test a renderer or policy without calling a
340
340
  model:
341
341
 
342
342
  ```ruby
343
- audit = TurnKit.audit_output(
343
+ audit = TurnKit.check_output_policy(
344
344
  "1. Recommendation\n- unordered item — fix this\n",
345
345
  constraints: [no_em_dash, numbered_lists_only]
346
346
  )
@@ -350,8 +350,8 @@ puts audit.messages
350
350
  ```
351
351
 
352
352
  Use `output_policy` when a semantic judge is worth the extra model call. The
353
- policy can be a `.md`, `.markdown`, or `.txt` file path, a `TurnKit::OutputPolicy`,
354
- or any object that responds to `#call` or `#check`.
353
+ policy can be a `.md`, `.markdown`, or `.txt` file path, a `TurnKit::Skill`, a
354
+ `TurnKit::OutputPolicy`, or any object that responds to `#call` or `#check`.
355
355
 
356
356
  ```ruby
357
357
  workflow = TurnKit::Workflow.new(
@@ -364,8 +364,34 @@ workflow = TurnKit::Workflow.new(
364
364
  ```
365
365
 
366
366
  `output_policy_mode: :report` records violations while allowing the run to
367
- complete. `:fail` marks the run failed after recording the output and audit.
368
- Policy model usage and cost are counted on the parent run.
367
+ complete. `:fail` marks the run failed after recording the output and audit;
368
+ `:fail` is the default for contract-driven workflows. Policy model usage and
369
+ cost are counted on the parent run.
370
+
371
+ Add `output_retries:` to turn policy failures into bounded revision loops instead
372
+ of dead ends:
373
+
374
+ ```ruby
375
+ voice = TurnKit::Skill.from_file("app/ai/skills/memo_voice.md")
376
+
377
+ workflow = TurnKit::Workflow.new(
378
+ name: "memo_writer",
379
+ skills: [voice],
380
+ output_policy: [voice, no_em_dash],
381
+ output_retries: 2,
382
+ input_schema: {
383
+ "type" => "object",
384
+ "required" => ["project_id"],
385
+ "properties" => { "project_id" => { "type" => "string" } }
386
+ }
387
+ )
388
+ ```
389
+
390
+ `skills:` are always loaded into the prompt. `available_skills:` are listed in
391
+ `<skills_available>` and exposed through the `load_skill` tool, so the model can
392
+ load full instructions on demand. Every advertised tool call receives exactly one
393
+ tool result, including validation errors, budget denials, and calls skipped after
394
+ a terminal tool ends the turn.
369
395
 
370
396
  ### Prompt Preview
371
397
 
@@ -639,8 +665,7 @@ TurnKit.output_policy_model = "gpt-4.1-mini"
639
665
  TurnKit.timeout = 300
640
666
  ```
641
667
 
642
- `TurnKit.cost_limit` remains supported as the internal/legacy name for
643
- `max_spend`.
668
+ `max_spend` is the only spend-limit name in the public API.
644
669
 
645
670
  Set options per agent:
646
671
 
data/UPGRADE.md CHANGED
@@ -1,313 +1,51 @@
1
1
  # Upgrade Guide
2
2
 
3
- This guide covers migrating to the workflow-based task-runtime API. The
4
- recommended migration is about making the three work shapes easier to read:
3
+ ## 0.3.0 is a clean break
5
4
 
6
- - conversations for durable multi-turn threads;
7
- - runs for one non-interactive application task;
8
- - workflows for reusable task runners with tools, skills, limits, and policy.
5
+ TurnKit 0.3.0 intentionally removes the short-lived legacy names from the 0.2
6
+ series. The gem is pre-1.0 and the durable transcript schema changed, so migrate
7
+ by updating call sites and reinstalling the generated tables for new projects.
9
8
 
10
- ## Quick summary
9
+ ### Renames
11
10
 
12
- Before changing call sites, bump TurnKit to the latest version and run your
13
- test suite against the new release.
11
+ - `TurnKit.cost_limit` `TurnKit.max_spend`
12
+ - `Agent.new(cost_limit:)` `Agent.new(max_spend:)`
13
+ - `Workflow.new(cost_limit:)` / `workflow.run(cost_limit:)` → `max_spend:`
14
+ - `output_audit:` → `output_policy:`
15
+ - `output_audit_mode:` → `output_policy_mode:`
16
+ - `run.output_audit` → `run.policy_audit`
17
+ - `run.output_audit_clean?` → `run.policy_clean?`
18
+ - `TurnKit.audit_output(...)` → `TurnKit.check_output_policy(...)`
14
19
 
15
- ```ruby
16
- # Gemfile
17
- gem "turnkit", "~> 0.2.9"
18
- ```
20
+ The audit result class remains `TurnKit::OutputAudit`; only the public option and
21
+ run-accessor names changed.
19
22
 
20
- ```sh
21
- bundle update turnkit
22
- ```
23
+ ### Message schema
23
24
 
24
- Use workflows for reusable autonomous task runners.
25
+ `turnkit_messages.text` was removed. Message `content` is now the canonical
26
+ ordered array of parts:
25
27
 
26
- Recommended new forms:
28
+ - `text`
29
+ - `thinking`
30
+ - `tool_call`
31
+ - `tool_result`
32
+ - opaque provider parts
27
33
 
28
- ```ruby
29
- TurnKit.configure do |config|
30
- config.model = "gpt-5.2"
31
- config.max_spend = 0.25
32
- end
34
+ `Message#text` is derived from text parts. New Rails installs should regenerate
35
+ the install migration; there is no compatibility shim for older schemas.
33
36
 
34
- workflow = TurnKit::Workflow.new(name: "brief_writer", tools: [WebSearch, SaveBrief])
35
- run = workflow.run("Create a source-grounded brief.", input: { topic: "Rails 8" })
37
+ ### Workflows
36
38
 
37
- puts run.output
38
- ```
39
+ `TurnKit::Workflow` now forwards options directly to `Agent`. Use
40
+ `workflow.options[:name]` or `workflow.agent` for inspection instead of per-option
41
+ workflow attr readers. Workflow `instructions:` compose with the orchestrator
42
+ preamble by default; pass `preamble: false` to opt out.
39
43
 
40
- ## Configuration
44
+ ### Skills and policy loops
41
45
 
42
- ### Model name
43
-
44
- Before:
45
-
46
- ```ruby
47
- TurnKit.default_model = "gpt-5.2"
48
- ```
49
-
50
- After:
51
-
52
- ```ruby
53
- TurnKit.model = "gpt-5.2"
54
- ```
55
-
56
- `TurnKit.default_model` remains supported. `TurnKit.model` is the shorter public
57
- alias for app code and initializers.
58
-
59
- ### Global setup
60
-
61
- Before:
62
-
63
- ```ruby
64
- TurnKit.default_model = "gpt-5.2"
65
- TurnKit.cost_limit = 0.25
66
- TurnKit.max_iterations = 12
67
- ```
68
-
69
- After:
70
-
71
- ```ruby
72
- TurnKit.configure do |config|
73
- config.model = "gpt-5.2"
74
- config.max_spend = 0.25
75
- config.max_iterations = 12
76
- end
77
- ```
78
-
79
- `TurnKit.configure` simply yields the `TurnKit` module. There is no separate
80
- configuration object or DSL.
81
-
82
- ### Spend limit naming
83
-
84
- Before:
85
-
86
- ```ruby
87
- TurnKit.cost_limit = 0.25
88
- ```
89
-
90
- After:
91
-
92
- ```ruby
93
- TurnKit.max_spend = 0.25
94
- ```
95
-
96
- `cost_limit` remains supported. Prefer `max_spend` in application-facing code
97
- because it matches how developers think about autonomous runs.
98
-
99
- ## Running application tasks
100
-
101
- ### Agent tasks
102
-
103
- Before:
104
-
105
- ```ruby
106
- run = agent.run(task: "Classify this lead.", input: lead.attributes)
107
- puts run.output_text
108
- ```
109
-
110
- After:
111
-
112
- ```ruby
113
- run = agent.run("Classify this lead.", input: lead.attributes)
114
- puts run.output
115
- ```
116
-
117
- The keyword form still works. The positional string is the recommended form for
118
- the common case. `Agent#run` uses task prompt behavior by default; pass
119
- `prompt_mode: :full` if you need conversation-style prompt behavior for a run.
120
-
121
- ### Pending runs
122
-
123
- No behavior change.
124
-
125
- ```ruby
126
- run = agent.run("Classify later.", async: true)
127
- request = run.preview
128
- run.run!
129
- ```
130
-
131
- The existing keyword form remains valid:
132
-
133
- ```ruby
134
- run = agent.run(task: "Classify later.", async: true)
135
- ```
136
-
137
- ## Workflows
138
-
139
- The preferred name for reusable autonomous task runtimes is now workflow. A
140
- workflow packages:
141
-
142
- - one task-mode orchestrator
143
- - workflow skills
144
- - tools
145
- - guardrails
146
- - compaction
147
- - optional persistence/action tools
148
-
149
- ### Construction
150
-
151
- ```ruby
152
- workflow = TurnKit::Workflow.new(
153
- name: "sales_enrichment",
154
- tools: [AccountLookup, WebSearch, SaveEnrichment],
155
- skills: [sales_research_skill],
156
- max_spend: 0.25
157
- )
158
- ```
159
-
160
- ### Running
161
-
162
- ```ruby
163
- run = workflow.run(
164
- "Enrich this account for responsible outreach.",
165
- input: account.attributes
166
- )
167
- ```
168
-
169
- `task:` remains supported.
170
-
171
- ## Run inspection
172
-
173
- New convenience methods were added to `TurnKit::Run`.
174
-
175
- Before:
176
-
177
- ```ruby
178
- run.output_text
179
- run.tool_executions
180
- run.turn_records.length
181
- TurnKit.store.load_turn(run.id)["error"]
182
- ```
183
-
184
- After:
185
-
186
- ```ruby
187
- run.output
188
- run.tool_calls
189
- run.steps
190
- run.error
191
- ```
192
-
193
- Old methods remain available. Prefer the shorter methods in application code,
194
- examples, and docs.
195
-
196
- ## Save/action tools
197
-
198
- Use `terminal!` for tools that complete the run by saving an artifact or taking
199
- the final action.
200
-
201
- Before:
202
-
203
- ```ruby
204
- class SaveBrief < TurnKit::Tool
205
- def self.ends_turn? = true
206
- def self.completion_message(result) = "Saved #{result.fetch("id")}."
207
-
208
- def call(title:, body:, context:)
209
- { "id" => Brief.create!(title: title, body: body).id }
210
- end
211
- end
212
- ```
213
-
214
- After:
215
-
216
- ```ruby
217
- class SaveBrief < TurnKit::Tool
218
- terminal! { |result| "Saved #{result.fetch("id")}." }
219
-
220
- def call(title:, body:, context:)
221
- { "id" => Brief.create!(title: title, body: body).id }
222
- end
223
- end
224
- ```
225
-
226
- The old `ends_turn?` and `completion_message` methods remain supported. Prefer
227
- `terminal!` for readability.
228
-
229
- ## Tool instances
230
-
231
- If a tool needs constructor arguments, register an instance instead of a class.
232
-
233
- Before, this may have failed at runtime:
234
-
235
- ```ruby
236
- class WebSearch < TurnKit::Tool
237
- def initialize(client:)
238
- @client = client
239
- end
240
- end
241
-
242
- agent = TurnKit::Agent.new(tools: [WebSearch])
243
- ```
244
-
245
- After:
246
-
247
- ```ruby
248
- client = SearchClient.new(api_key: ENV.fetch("SEARCH_API_KEY"))
249
- agent = TurnKit::Agent.new(tools: [WebSearch.new(client: client)])
250
- ```
251
-
252
- This is the recommended pattern for API clients, test doubles, and per-tenant
253
- dependencies.
254
-
255
- ## Multi-agent workflows
256
-
257
- If you previously modeled every role as a separate agent, consider migrating the
258
- default path to one workflow with a workflow skill.
259
-
260
- Before:
261
-
262
- ```ruby
263
- researcher = TurnKit::Agent.new(name: "researcher", tools: [WebSearch])
264
- writer = TurnKit::Agent.new(name: "writer")
265
- verifier = TurnKit::Agent.new(name: "verifier")
266
-
267
- orchestrator = TurnKit::Agent.new(
268
- name: "orchestrator",
269
- sub_agents: [researcher, writer, verifier]
270
- )
271
- ```
272
-
273
- After:
274
-
275
- ```ruby
276
- workflow = TurnKit::Skill.new(
277
- key: "source_grounded_brief",
278
- name: "Source Grounded Brief",
279
- content: <<~TEXT
280
- Research first. Build an evidence pack. Draft only from evidence. Verify
281
- important claims. Revise unsupported claims before final output.
282
- TEXT
283
- )
284
-
285
- source_brief = TurnKit::Workflow.new(
286
- name: "source_brief",
287
- skills: [workflow],
288
- tools: [WebSearch, ReadWebPage, SaveBrief],
289
- max_spend: 0.25,
290
- max_tool_executions: 20
291
- )
292
- ```
293
-
294
- Keep separate agents when the isolation is worth the extra model calls:
295
-
296
- - different models
297
- - different tool permissions
298
- - adversarial review
299
- - parallel specialist research
300
- - separate durable child conversations
301
-
302
- ## Suggested migration order
303
-
304
- 1. Replace `TurnKit.default_model =` with `TurnKit.model =` in app-level config.
305
- 2. Wrap global settings in `TurnKit.configure` if you have more than one.
306
- 3. Use `TurnKit::Workflow.new(name: "...")` for reusable autonomous task runners.
307
- 4. Replace `run(task: "...")` with `run("...")` where it improves readability.
308
- 5. Replace `run.output_text` with `run.output` in application code.
309
- 6. Replace save/action tool overrides with `terminal!` when convenient.
310
- 7. Consider collapsing role-agent workflows into one workflow plus workflow skills if
311
- cost or complexity is a concern.
312
-
313
- Run your test suite after migrating call sites.
46
+ - `available_skills:` now exposes a real `load_skill` tool.
47
+ - `output_policy:` accepts `TurnKit::Skill` instances.
48
+ - `output_retries:` controls bounded revision loops. The default policy mode is
49
+ now `:fail`; use `output_policy_mode: :report` if dirty output should complete.
50
+ - `input_schema:` validates application input before any conversation or turn is
51
+ created.
@@ -182,6 +182,7 @@ module TurnKit
182
182
  )
183
183
  Result.new(
184
184
  text: response_text(response),
185
+ parts: response_parts(response, tool_calls: tool_calls),
185
186
  output_data: response_data(response),
186
187
  tool_calls: tool_calls,
187
188
  usage: usage,
@@ -189,6 +190,34 @@ module TurnKit
189
190
  )
190
191
  end
191
192
 
193
+ def response_parts(response, tool_calls:)
194
+ content = response.respond_to?(:content) ? response.content : response
195
+ parts = case content
196
+ when Array
197
+ content.map { |part| normalize_provider_part(part) }
198
+ when Hash
199
+ [ { "type" => "text", "text" => content.to_json } ]
200
+ else
201
+ text = content.to_s
202
+ text.empty? ? [] : [ { "type" => "text", "text" => text } ]
203
+ end.compact
204
+ parts + Array(tool_calls).map { |call| { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments } }
205
+ end
206
+
207
+ def normalize_provider_part(part)
208
+ attrs = part.respond_to?(:to_h) ? part.to_h.transform_keys(&:to_s) : nil
209
+ return { "type" => "text", "text" => part.to_s } unless attrs
210
+
211
+ case attrs["type"].to_s
212
+ when "text", "output_text"
213
+ { "type" => "text", "text" => attrs["text"] || attrs["content"].to_s }
214
+ when "thinking", "reasoning"
215
+ { "type" => "thinking", "text" => attrs["text"] || attrs["content"].to_s, "signature" => attrs["signature"], "redacted" => attrs["redacted"] || false }.compact
216
+ else
217
+ { "type" => "provider", "kind" => attrs["type"].to_s, "data" => attrs }
218
+ end
219
+ end
220
+
192
221
  def response_text(response)
193
222
  content = response.respond_to?(:content) ? response.content : response
194
223
  content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
data/lib/turnkit/agent.rb CHANGED
@@ -3,14 +3,14 @@
3
3
  module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
- attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions, :max_tool_executions_by_name
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
8
- attr_reader :output_audit, :output_audit_mode, :output_policy_model
6
+ attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
8
+ attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
9
9
 
10
10
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
11
11
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
12
- max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
13
- output_schema: nil, output_audit: nil, output_audit_mode: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, on_event: nil)
12
+ max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
13
+ output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
14
14
  @name = name.to_s
15
15
  @description = description.to_s
16
16
  @model = model
@@ -26,16 +26,18 @@ module TurnKit
26
26
  @store = store
27
27
  @max_iterations = max_iterations
28
28
  @timeout = timeout
29
- @cost_limit = cost_limit
29
+ @max_spend = max_spend
30
30
  @max_depth = max_depth
31
31
  @max_tool_executions = max_tool_executions
32
32
  @max_tool_executions_by_name = max_tool_executions_by_name
33
33
  @thinking = self.class.normalize_thinking(thinking)
34
34
  @compaction = compaction
35
35
  @output_schema = output_schema
36
+ @input_schema = input_schema
36
37
  @output_policy_model = output_policy_model
37
- @output_audit = normalize_output_policy_options(output_audit: output_audit, output_policy: output_policy, output_policy_model: output_policy_model, output_policy_thinking: output_policy_thinking)
38
- @output_audit_mode = normalize_output_policy_mode(output_audit_mode: output_audit_mode, output_policy_mode: output_policy_mode)
38
+ @output_policy = normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
39
+ @output_policy_mode = normalize_output_policy_mode(output_policy_mode)
40
+ @output_retries = Integer(output_retries || 0)
39
41
  @on_event = on_event
40
42
  raise ArgumentError, "name is required" if @name.empty?
41
43
  validate_tools!
@@ -70,6 +72,7 @@ module TurnKit
70
72
  def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
71
73
  task = task || prompt
72
74
  raise ArgumentError, "task is required" if task.to_s.empty?
75
+ SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
73
76
 
74
77
  conversation = self.conversation(subject: subject, metadata: metadata)
75
78
  message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
@@ -99,16 +102,8 @@ module TurnKit
99
102
  thinking
100
103
  end
101
104
 
102
- def effective_output_audit
103
- Array(output_audit).compact
104
- end
105
-
106
- def output_policy
107
- output_audit
108
- end
109
-
110
- def output_policy_mode
111
- output_audit_mode
105
+ def effective_output_policy
106
+ Array(output_policy).compact
112
107
  end
113
108
 
114
109
  def effective_client
@@ -120,7 +115,9 @@ module TurnKit
120
115
  end
121
116
 
122
117
  def effective_tools
123
- tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
118
+ configured = tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
119
+ skills = effective_available_skills
120
+ skills.empty? ? configured : configured + [ LoadSkillTool.for(skills) ]
124
121
  end
125
122
 
126
123
  def effective_on_event
@@ -161,7 +158,7 @@ module TurnKit
161
158
  max_depth: max_depth || TurnKit.max_depth,
162
159
  max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
163
160
  max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
164
- cost_limit: cost_limit || TurnKit.cost_limit,
161
+ max_spend: max_spend || TurnKit.max_spend,
165
162
  root_started_at: root_started_at
166
163
  )
167
164
  end
@@ -188,12 +185,6 @@ module TurnKit
188
185
  effective_tools.each(&:validate_definition!)
189
186
  end
190
187
 
191
- def normalize_output_policy_options(output_audit:, output_policy:, output_policy_model:, output_policy_thinking:)
192
- raise ArgumentError, "use output_policy: or output_audit:, not both" if output_audit && output_policy
193
-
194
- output_policy.nil? ? output_audit : normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
195
- end
196
-
197
188
  def normalize_output_policy(value, model: nil, thinking: nil)
198
189
  case value
199
190
  when nil
@@ -204,10 +195,12 @@ module TurnKit
204
195
  output_policy_from_path(value, model: model, thinking: thinking)
205
196
  when Pathname
206
197
  output_policy_from_path(value.to_s, model: model, thinking: thinking)
198
+ when Skill
199
+ OutputPolicy.from_skill(value, model: model || TurnKit.output_policy_model, thinking: thinking || TurnKit.output_policy_thinking)
207
200
  else
208
201
  return value if value.respond_to?(:call) || value.respond_to?(:check)
209
202
 
210
- raise ArgumentError, "output_policy must be a policy file path, a #call/#check object, or an array of those"
203
+ raise ArgumentError, "output_policy must be a policy file path, a skill, a #call/#check object, or an array of those"
211
204
  end
212
205
  end
213
206
 
@@ -223,12 +216,8 @@ module TurnKit
223
216
  )
224
217
  end
225
218
 
226
- def normalize_output_policy_mode(output_audit_mode:, output_policy_mode:)
227
- if output_audit_mode && output_policy_mode && output_audit_mode.to_sym != output_policy_mode.to_sym
228
- raise ArgumentError, "use output_policy_mode: or output_audit_mode:, not both"
229
- end
230
-
231
- value = output_policy_mode || output_audit_mode || :report
219
+ def normalize_output_policy_mode(value)
220
+ value ||= :fail
232
221
  mode = value.to_sym
233
222
  raise ArgumentError, "unknown output_policy_mode: #{value}" unless %i[report fail].include?(mode)
234
223
 
@@ -2,16 +2,24 @@
2
2
 
3
3
  module TurnKit
4
4
  class Budget
5
- attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :cost_limit
5
+ attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :max_spend
6
6
 
7
- def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, cost_limit: nil, root_started_at: Clock.now)
7
+ def self.resume(store:, root_turn_id:, limits: {})
8
+ turns = store.list_turns(root_turn_id: root_turn_id)
9
+ root = turns.find { |turn| turn.fetch("id") == root_turn_id } || turns.first || {}
10
+ budget = new(**limits.merge(root_started_at: root["started_at"] || Clock.now))
11
+ budget.seed!(turns: turns, tool_executions: turns.flat_map { |turn| store.list_tool_executions(turn_id: turn.fetch("id")) })
12
+ budget
13
+ end
14
+
15
+ def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, max_spend: nil, root_started_at: Clock.now)
8
16
  @root_started_at = root_started_at
9
17
  @max_iterations = max_iterations
10
18
  @timeout = timeout
11
19
  @max_depth = max_depth
12
20
  @max_tool_executions = max_tool_executions
13
21
  @max_tool_executions_by_name = normalize_tool_limits(max_tool_executions_by_name)
14
- @cost_limit = cost_limit
22
+ @max_spend = max_spend
15
23
  @iterations = 0
16
24
  @tool_executions = 0
17
25
  @tool_executions_by_name = Hash.new(0)
@@ -19,6 +27,17 @@ module TurnKit
19
27
  @mutex = Mutex.new
20
28
  end
21
29
 
30
+ def seed!(turns:, tool_executions:)
31
+ @mutex.synchronize do
32
+ @iterations = Array(turns).sum { |turn| (turn["options"] || {})["iterations"].to_i }
33
+ completed = Array(tool_executions).select { |execution| %w[completed failed].include?(execution["status"]) && !execution.dig("error", "details", "budget_denied") }
34
+ @tool_executions = completed.length
35
+ completed.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
36
+ @cost = Array(turns).sum { |turn| turn["cost"].to_f }
37
+ end
38
+ self
39
+ end
40
+
22
41
  def count_iteration!
23
42
  @mutex.synchronize do
24
43
  raise BudgetError, "maximum iterations reached" if max_iterations && @iterations >= max_iterations
@@ -44,11 +63,11 @@ module TurnKit
44
63
  end
45
64
 
46
65
  def add_cost!(cost)
47
- return unless cost && cost_limit
66
+ return unless cost && max_spend
48
67
 
49
68
  @mutex.synchronize do
50
69
  @cost += cost.to_f
51
- raise BudgetError, "cost limit reached" if @cost > cost_limit
70
+ raise BudgetError, "cost limit reached" if @cost > max_spend
52
71
  end
53
72
  end
54
73