turnkit 0.2.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +34 -9
- data/UPGRADE.md +37 -299
- data/lib/turnkit/adapters/ruby_llm.rb +29 -0
- data/lib/turnkit/agent.rb +22 -33
- data/lib/turnkit/budget.rb +24 -5
- data/lib/turnkit/compaction.rb +1 -0
- data/lib/turnkit/error.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +0 -1
- data/lib/turnkit/load_skill_tool.rb +29 -0
- data/lib/turnkit/memory_store.rb +11 -0
- data/lib/turnkit/message.rb +14 -7
- data/lib/turnkit/message_projection.rb +17 -2
- data/lib/turnkit/output_policy.rb +6 -0
- data/lib/turnkit/result.rb +29 -4
- data/lib/turnkit/run.rb +4 -5
- data/lib/turnkit/schema_check.rb +68 -0
- data/lib/turnkit/skill.rb +16 -2
- data/lib/turnkit/store.rb +6 -0
- data/lib/turnkit/stores/active_record_store.rb +10 -2
- data/lib/turnkit/sub_agent_tool.rb +2 -1
- data/lib/turnkit/system_prompt.rb +1 -1
- data/lib/turnkit/tool.rb +2 -21
- data/lib/turnkit/tool_call.rb +3 -3
- data/lib/turnkit/tool_runner.rb +38 -16
- data/lib/turnkit/turn.rb +90 -23
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/workflow.rb +20 -94
- data/lib/turnkit.rb +5 -10
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5694bc97b2f735e5076574e2863ee5addc41926bd85edf02e1835263ffb3516
|
|
4
|
+
data.tar.gz: 65286330a1d0b4bbd0e3e6c11ba73abd836fb22a44ae4b3ab48a58ecf9d19425
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
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::
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
4
|
-
recommended migration is about making the three work shapes easier to read:
|
|
3
|
+
## 0.3.0 is a clean break
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
9
|
+
### Renames
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
bundle update turnkit
|
|
22
|
-
```
|
|
23
|
+
### Message schema
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
`turnkit_messages.text` was removed. Message `content` is now the canonical
|
|
26
|
+
ordered array of parts:
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
- `text`
|
|
29
|
+
- `thinking`
|
|
30
|
+
- `tool_call`
|
|
31
|
+
- `tool_result`
|
|
32
|
+
- opaque provider parts
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
run = workflow.run("Create a source-grounded brief.", input: { topic: "Rails 8" })
|
|
37
|
+
### Workflows
|
|
36
38
|
|
|
37
|
-
|
|
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
|
-
|
|
44
|
+
### Skills and policy loops
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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, :
|
|
7
|
-
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
|
|
8
|
-
attr_reader :
|
|
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,
|
|
13
|
-
output_schema: 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
|
-
@
|
|
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
|
-
@
|
|
38
|
-
@
|
|
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
|
|
103
|
-
Array(
|
|
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
|
-
|
|
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(
|
|
227
|
-
|
|
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
|
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
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
|
-
@
|
|
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 &&
|
|
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 >
|
|
70
|
+
raise BudgetError, "cost limit reached" if @cost > max_spend
|
|
52
71
|
end
|
|
53
72
|
end
|
|
54
73
|
|