turnkit 0.2.6 → 0.2.8
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 +8 -0
- data/README.md +250 -369
- data/UPGRADE.md +346 -0
- data/lib/turnkit/adapters/ruby_llm.rb +69 -5
- data/lib/turnkit/agent.rb +67 -2
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/conversation.rb +6 -5
- data/lib/turnkit/error.rb +2 -0
- data/lib/turnkit/event.rb +25 -0
- data/lib/turnkit/fleet.rb +105 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +6 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +6 -0
- data/lib/turnkit/model_request.rb +35 -0
- data/lib/turnkit/record.rb +2 -1
- data/lib/turnkit/result.rb +3 -2
- data/lib/turnkit/run.rb +74 -0
- data/lib/turnkit/stores/active_record_store.rb +14 -4
- data/lib/turnkit/sub_agent_tool.rb +13 -4
- data/lib/turnkit/system_prompt.rb +32 -2
- data/lib/turnkit/tool.rb +152 -8
- data/lib/turnkit/tool_call.rb +3 -1
- data/lib/turnkit/tool_runner.rb +21 -6
- data/lib/turnkit/turn.rb +92 -15
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +30 -0
- metadata +10 -6
data/UPGRADE.md
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# Upgrade Guide
|
|
2
|
+
|
|
3
|
+
This guide covers migrating to the newer task-runtime API. The changes are
|
|
4
|
+
mostly additive: existing `Agent`, `Conversation`, `Tool`, and `Fleet` code
|
|
5
|
+
should continue to work. The recommended migration is about improving developer
|
|
6
|
+
experience and making autonomous workflows easier to read.
|
|
7
|
+
|
|
8
|
+
## Quick summary
|
|
9
|
+
|
|
10
|
+
You do **not** need to rewrite existing code immediately.
|
|
11
|
+
|
|
12
|
+
Recommended new forms:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
TurnKit.configure do |config|
|
|
16
|
+
config.model = "gpt-5.2"
|
|
17
|
+
config.max_spend = 0.25
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
fleet = TurnKit.fleet("brief_writer", tools: [WebSearch, SaveBrief])
|
|
21
|
+
run = fleet.run("Create a source-grounded brief.", input: { topic: "Rails 8" })
|
|
22
|
+
|
|
23
|
+
puts run.output
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Old forms still work:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
TurnKit.default_model = "gpt-5.2"
|
|
30
|
+
|
|
31
|
+
fleet = TurnKit::Fleet.new(name: "brief_writer", tools: [WebSearch, SaveBrief])
|
|
32
|
+
run = fleet.run(task: "Create a source-grounded brief.", input: { topic: "Rails 8" })
|
|
33
|
+
|
|
34
|
+
puts run.output_text
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### Model name
|
|
40
|
+
|
|
41
|
+
Before:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
TurnKit.default_model = "gpt-5.2"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
After:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
TurnKit.model = "gpt-5.2"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`TurnKit.default_model` remains supported. `TurnKit.model` is the shorter public
|
|
54
|
+
alias for app code and initializers.
|
|
55
|
+
|
|
56
|
+
### Global setup
|
|
57
|
+
|
|
58
|
+
Before:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
TurnKit.default_model = "gpt-5.2"
|
|
62
|
+
TurnKit.cost_limit = 0.25
|
|
63
|
+
TurnKit.max_iterations = 12
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
After:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
TurnKit.configure do |config|
|
|
70
|
+
config.model = "gpt-5.2"
|
|
71
|
+
config.max_spend = 0.25
|
|
72
|
+
config.max_iterations = 12
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`TurnKit.configure` simply yields the `TurnKit` module. There is no separate
|
|
77
|
+
configuration object or DSL.
|
|
78
|
+
|
|
79
|
+
### Spend limit naming
|
|
80
|
+
|
|
81
|
+
Before:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
TurnKit.cost_limit = 0.25
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
After:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
TurnKit.max_spend = 0.25
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`cost_limit` remains supported. Prefer `max_spend` in application-facing code
|
|
94
|
+
because it matches how developers think about autonomous runs.
|
|
95
|
+
|
|
96
|
+
## Running application tasks
|
|
97
|
+
|
|
98
|
+
### Agent tasks
|
|
99
|
+
|
|
100
|
+
Before:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
run = agent.run(task: "Classify this lead.", input: lead.attributes)
|
|
104
|
+
puts run.output_text
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
After:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
run = agent.run("Classify this lead.", input: lead.attributes)
|
|
111
|
+
puts run.output
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The keyword form still works. The positional string is the recommended form for
|
|
115
|
+
the common case.
|
|
116
|
+
|
|
117
|
+
### Pending runs
|
|
118
|
+
|
|
119
|
+
No behavior change.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
run = agent.run("Classify later.", async: true)
|
|
123
|
+
request = run.preview
|
|
124
|
+
run.run!
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The existing keyword form remains valid:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
run = agent.run(task: "Classify later.", async: true)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Fleets
|
|
134
|
+
|
|
135
|
+
The fleet mental model changed from “many agents” to “one reusable autonomous
|
|
136
|
+
task runtime.” A fleet packages:
|
|
137
|
+
|
|
138
|
+
- one task-mode orchestrator
|
|
139
|
+
- workflow skills
|
|
140
|
+
- tools
|
|
141
|
+
- guardrails
|
|
142
|
+
- compaction
|
|
143
|
+
- optional persistence/action tools
|
|
144
|
+
|
|
145
|
+
### Construction
|
|
146
|
+
|
|
147
|
+
Before:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
fleet = TurnKit::Fleet.new(
|
|
151
|
+
name: "sales_enrichment",
|
|
152
|
+
tools: [AccountLookup, WebSearch, SaveEnrichment],
|
|
153
|
+
skills: [sales_research_skill],
|
|
154
|
+
max_spend: 0.25
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
After:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
fleet = TurnKit.fleet(
|
|
162
|
+
"sales_enrichment",
|
|
163
|
+
tools: [AccountLookup, WebSearch, SaveEnrichment],
|
|
164
|
+
skills: [sales_research_skill],
|
|
165
|
+
max_spend: 0.25
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`TurnKit::Fleet.new` remains supported.
|
|
170
|
+
|
|
171
|
+
### Running
|
|
172
|
+
|
|
173
|
+
Before:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
run = fleet.run(
|
|
177
|
+
task: "Enrich this account for responsible outreach.",
|
|
178
|
+
input: account.attributes
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
After:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
run = fleet.run(
|
|
186
|
+
"Enrich this account for responsible outreach.",
|
|
187
|
+
input: account.attributes
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`task:` remains supported.
|
|
192
|
+
|
|
193
|
+
### Auto-run alias
|
|
194
|
+
|
|
195
|
+
No behavior change.
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
run = fleet.auto_run("Enrich this account.", input: account.attributes)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use `auto_run` when the name helps communicate that the fleet should navigate
|
|
202
|
+
from input to output on its own. It is an alias for `run`.
|
|
203
|
+
|
|
204
|
+
## Run inspection
|
|
205
|
+
|
|
206
|
+
New convenience methods were added to `TurnKit::Run`.
|
|
207
|
+
|
|
208
|
+
Before:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
run.output_text
|
|
212
|
+
run.tool_executions
|
|
213
|
+
run.turn_records.length
|
|
214
|
+
TurnKit.store.load_turn(run.id)["error"]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
After:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
run.output
|
|
221
|
+
run.tool_calls
|
|
222
|
+
run.steps
|
|
223
|
+
run.error
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Old methods remain available. Prefer the shorter methods in application code,
|
|
227
|
+
examples, and docs.
|
|
228
|
+
|
|
229
|
+
## Save/action tools
|
|
230
|
+
|
|
231
|
+
Use `terminal!` for tools that complete the run by saving an artifact or taking
|
|
232
|
+
the final action.
|
|
233
|
+
|
|
234
|
+
Before:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class SaveBrief < TurnKit::Tool
|
|
238
|
+
def self.ends_turn? = true
|
|
239
|
+
def self.completion_message(result) = "Saved #{result.fetch("id")}."
|
|
240
|
+
|
|
241
|
+
def call(title:, body:, context:)
|
|
242
|
+
{ "id" => Brief.create!(title: title, body: body).id }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
After:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
class SaveBrief < TurnKit::Tool
|
|
251
|
+
terminal! { |result| "Saved #{result.fetch("id")}." }
|
|
252
|
+
|
|
253
|
+
def call(title:, body:, context:)
|
|
254
|
+
{ "id" => Brief.create!(title: title, body: body).id }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The old `ends_turn?` and `completion_message` methods remain supported. Prefer
|
|
260
|
+
`terminal!` for readability.
|
|
261
|
+
|
|
262
|
+
## Tool instances
|
|
263
|
+
|
|
264
|
+
If a tool needs constructor arguments, register an instance instead of a class.
|
|
265
|
+
|
|
266
|
+
Before, this may have failed at runtime:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class WebSearch < TurnKit::Tool
|
|
270
|
+
def initialize(client:)
|
|
271
|
+
@client = client
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
agent = TurnKit::Agent.new(tools: [WebSearch])
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
After:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
client = SearchClient.new(api_key: ENV.fetch("SEARCH_API_KEY"))
|
|
282
|
+
agent = TurnKit::Agent.new(tools: [WebSearch.new(client: client)])
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
This is the recommended pattern for API clients, test doubles, and per-tenant
|
|
286
|
+
dependencies.
|
|
287
|
+
|
|
288
|
+
## Multi-agent fleets
|
|
289
|
+
|
|
290
|
+
If you previously modeled every role as a separate agent, consider migrating the
|
|
291
|
+
default path to one fleet with a workflow skill.
|
|
292
|
+
|
|
293
|
+
Before:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
researcher = TurnKit::Agent.new(name: "researcher", tools: [WebSearch])
|
|
297
|
+
writer = TurnKit::Agent.new(name: "writer")
|
|
298
|
+
verifier = TurnKit::Agent.new(name: "verifier")
|
|
299
|
+
|
|
300
|
+
orchestrator = TurnKit::Agent.new(
|
|
301
|
+
name: "orchestrator",
|
|
302
|
+
sub_agents: [researcher, writer, verifier]
|
|
303
|
+
)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
After:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
workflow = TurnKit::Skill.new(
|
|
310
|
+
key: "source_grounded_brief",
|
|
311
|
+
name: "Source Grounded Brief",
|
|
312
|
+
content: <<~TEXT
|
|
313
|
+
Research first. Build an evidence pack. Draft only from evidence. Verify
|
|
314
|
+
important claims. Revise unsupported claims before final output.
|
|
315
|
+
TEXT
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
fleet = TurnKit.fleet(
|
|
319
|
+
"source_brief",
|
|
320
|
+
skills: [workflow],
|
|
321
|
+
tools: [WebSearch, ReadWebPage, SaveBrief],
|
|
322
|
+
max_spend: 0.25,
|
|
323
|
+
max_tool_executions: 20
|
|
324
|
+
)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Keep separate agents when the isolation is worth the extra model calls:
|
|
328
|
+
|
|
329
|
+
- different models
|
|
330
|
+
- different tool permissions
|
|
331
|
+
- adversarial review
|
|
332
|
+
- parallel specialist research
|
|
333
|
+
- separate durable child conversations
|
|
334
|
+
|
|
335
|
+
## Suggested migration order
|
|
336
|
+
|
|
337
|
+
1. Replace `TurnKit.default_model =` with `TurnKit.model =` in app-level config.
|
|
338
|
+
2. Wrap global settings in `TurnKit.configure` if you have more than one.
|
|
339
|
+
3. Replace `TurnKit::Fleet.new(name: ...)` with `TurnKit.fleet("...")` in new code.
|
|
340
|
+
4. Replace `run(task: "...")` with `run("...")` where it improves readability.
|
|
341
|
+
5. Replace `run.output_text` with `run.output` in application code.
|
|
342
|
+
6. Replace save/action tool overrides with `terminal!` when convenient.
|
|
343
|
+
7. Consider collapsing role-agent fleets into one fleet plus workflow skills if
|
|
344
|
+
cost or complexity is a concern.
|
|
345
|
+
|
|
346
|
+
None of these steps are required for existing code to keep working.
|
|
@@ -3,7 +3,28 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Adapters
|
|
5
5
|
class RubyLLM < Client
|
|
6
|
-
|
|
6
|
+
KEY_BY_PROVIDER = {
|
|
7
|
+
openai: "OPENAI_API_KEY",
|
|
8
|
+
gemini: "GEMINI_API_KEY",
|
|
9
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
10
|
+
openrouter: "OPENROUTER_API_KEY"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def validate!(model:)
|
|
14
|
+
require "ruby_llm"
|
|
15
|
+
|
|
16
|
+
raise ModelAccessError, "model is required" if model.to_s.empty?
|
|
17
|
+
|
|
18
|
+
configure_from_environment
|
|
19
|
+
provider = provider_for(model)
|
|
20
|
+
key_name = KEY_BY_PROVIDER[provider]
|
|
21
|
+
return true unless key_name
|
|
22
|
+
return true if ENV[key_name].to_s != "" || config_key_present?(provider)
|
|
23
|
+
|
|
24
|
+
raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
7
28
|
require "ruby_llm"
|
|
8
29
|
|
|
9
30
|
configure_from_environment
|
|
@@ -12,6 +33,7 @@ module TurnKit
|
|
|
12
33
|
add_instructions(chat, instructions, model: model)
|
|
13
34
|
chat.with_temperature(temperature) if temperature
|
|
14
35
|
apply_thinking(chat, thinking)
|
|
36
|
+
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
15
37
|
Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
|
|
16
38
|
Array(messages).each { |message| add_message(chat, message) }
|
|
17
39
|
|
|
@@ -28,11 +50,39 @@ module TurnKit
|
|
|
28
50
|
config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
def provider_for(model)
|
|
54
|
+
value = model.to_s.downcase
|
|
55
|
+
return :openrouter if value.start_with?("openrouter/")
|
|
56
|
+
return :anthropic if value.start_with?("anthropic/", "claude")
|
|
57
|
+
return :gemini if value.start_with?("gemini/", "gemini")
|
|
58
|
+
return :openai if value.start_with?("openai/", "gpt", "o1", "o3", "o4")
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def config_key_present?(provider)
|
|
64
|
+
value = ::RubyLLM.config.public_send("#{provider}_api_key") if ::RubyLLM.config.respond_to?("#{provider}_api_key")
|
|
65
|
+
value.to_s != ""
|
|
66
|
+
end
|
|
67
|
+
|
|
31
68
|
def apply_thinking(chat, thinking)
|
|
32
69
|
thinking = Agent.normalize_thinking(thinking)
|
|
33
70
|
chat.with_thinking(**thinking) if thinking
|
|
34
71
|
end
|
|
35
72
|
|
|
73
|
+
def normalize_schema(schema)
|
|
74
|
+
case schema
|
|
75
|
+
when Hash
|
|
76
|
+
normalized = schema.transform_keys(&:to_s).transform_values { |value| normalize_schema(value) }
|
|
77
|
+
normalized["additionalProperties"] = false if normalized["type"] == "object" && !normalized.key?("additionalProperties")
|
|
78
|
+
normalized
|
|
79
|
+
when Array
|
|
80
|
+
schema.map { |value| normalize_schema(value) }
|
|
81
|
+
else
|
|
82
|
+
schema
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
36
86
|
def complete_without_tool_execution(chat)
|
|
37
87
|
provider = chat.instance_variable_get(:@provider)
|
|
38
88
|
provider.complete(
|
|
@@ -110,9 +160,7 @@ module TurnKit
|
|
|
110
160
|
Class.new(::RubyLLM::Tool) do
|
|
111
161
|
define_singleton_method(:name) { tool.tool_name }
|
|
112
162
|
description tool.description
|
|
113
|
-
tool.
|
|
114
|
-
param(param.fetch(:name).to_sym, type: param.fetch(:type), required: param.fetch(:required), desc: param.fetch(:description))
|
|
115
|
-
end
|
|
163
|
+
params tool.input_schema
|
|
116
164
|
|
|
117
165
|
define_method(:execute) do |**arguments|
|
|
118
166
|
raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
|
|
@@ -133,13 +181,29 @@ module TurnKit
|
|
|
133
181
|
cost: response_cost(response)
|
|
134
182
|
)
|
|
135
183
|
Result.new(
|
|
136
|
-
text: response
|
|
184
|
+
text: response_text(response),
|
|
185
|
+
output_data: response_data(response),
|
|
137
186
|
tool_calls: tool_calls,
|
|
138
187
|
usage: usage,
|
|
139
188
|
model: response.respond_to?(:model_id) ? response.model_id : model
|
|
140
189
|
)
|
|
141
190
|
end
|
|
142
191
|
|
|
192
|
+
def response_text(response)
|
|
193
|
+
content = response.respond_to?(:content) ? response.content : response
|
|
194
|
+
content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def response_data(response)
|
|
198
|
+
content = response.respond_to?(:content) ? response.content : nil
|
|
199
|
+
return content if content.is_a?(Hash) || content.is_a?(Array)
|
|
200
|
+
return nil unless content.is_a?(String)
|
|
201
|
+
|
|
202
|
+
JSON.parse(content)
|
|
203
|
+
rescue JSON::ParserError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
143
207
|
def token_value(response, method)
|
|
144
208
|
response.respond_to?(method) ? response.public_send(method).to_i : 0
|
|
145
209
|
end
|
data/lib/turnkit/agent.rb
CHANGED
|
@@ -4,11 +4,12 @@ module TurnKit
|
|
|
4
4
|
class Agent
|
|
5
5
|
attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
|
|
6
6
|
attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
|
|
7
|
-
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction
|
|
7
|
+
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
|
|
8
8
|
|
|
9
9
|
def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
|
|
10
10
|
system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
|
|
11
|
-
max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil
|
|
11
|
+
max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil,
|
|
12
|
+
output_schema: nil, on_event: nil)
|
|
12
13
|
@name = name.to_s
|
|
13
14
|
@description = description.to_s
|
|
14
15
|
@model = model
|
|
@@ -29,7 +30,10 @@ module TurnKit
|
|
|
29
30
|
@max_tool_executions = max_tool_executions
|
|
30
31
|
@thinking = self.class.normalize_thinking(thinking)
|
|
31
32
|
@compaction = compaction
|
|
33
|
+
@output_schema = output_schema
|
|
34
|
+
@on_event = on_event
|
|
32
35
|
raise ArgumentError, "name is required" if @name.empty?
|
|
36
|
+
validate_tools!
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
def self.normalize_thinking(value)
|
|
@@ -58,6 +62,21 @@ module TurnKit
|
|
|
58
62
|
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, **options)
|
|
66
|
+
task = task || prompt
|
|
67
|
+
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
68
|
+
|
|
69
|
+
conversation = self.conversation(subject: subject, metadata: metadata)
|
|
70
|
+
message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
|
|
71
|
+
turn = conversation.build_turn(
|
|
72
|
+
trigger_message_id: message.id,
|
|
73
|
+
root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
|
|
74
|
+
**options
|
|
75
|
+
)
|
|
76
|
+
run = Run.new(turn)
|
|
77
|
+
async ? run : run.run!
|
|
78
|
+
end
|
|
79
|
+
|
|
61
80
|
def cost
|
|
62
81
|
Cost.from_records(effective_store.list_turns(agent_name: name))
|
|
63
82
|
end
|
|
@@ -86,6 +105,10 @@ module TurnKit
|
|
|
86
105
|
tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
|
|
87
106
|
end
|
|
88
107
|
|
|
108
|
+
def effective_on_event
|
|
109
|
+
on_event || TurnKit.on_event
|
|
110
|
+
end
|
|
111
|
+
|
|
89
112
|
def effective_available_skills
|
|
90
113
|
(Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
|
|
91
114
|
end
|
|
@@ -129,5 +152,47 @@ module TurnKit
|
|
|
129
152
|
parts << SystemPrompt.loaded_skills_text(skills)
|
|
130
153
|
parts.reject(&:empty?).join("\n\n")
|
|
131
154
|
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
def validate_tools!
|
|
158
|
+
effective_tools.each do |tool|
|
|
159
|
+
next if tool.is_a?(Class) && tool < Tool
|
|
160
|
+
next if tool.is_a?(Tool)
|
|
161
|
+
|
|
162
|
+
raise ArgumentError, "tools must be TurnKit::Tool classes or instances"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
names = effective_tools.map(&:tool_name)
|
|
166
|
+
duplicate = names.find { |name| names.count(name) > 1 }
|
|
167
|
+
raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
|
|
168
|
+
|
|
169
|
+
effective_tools.each(&:validate_definition!)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def task_message(task, input)
|
|
173
|
+
text = task.to_s
|
|
174
|
+
return text if input.nil?
|
|
175
|
+
|
|
176
|
+
"Task:\n#{text}\n\nInput:\n#{format_task_input(input)}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def format_task_input(input)
|
|
180
|
+
case input
|
|
181
|
+
when String
|
|
182
|
+
input
|
|
183
|
+
else
|
|
184
|
+
JSON.pretty_generate(input)
|
|
185
|
+
end
|
|
186
|
+
rescue JSON::GeneratorError
|
|
187
|
+
input.inspect
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def parent_run_root_turn_id(parent_run)
|
|
191
|
+
return nil unless parent_run
|
|
192
|
+
return parent_run.root_turn_id if parent_run.respond_to?(:root_turn_id)
|
|
193
|
+
return parent_run.fetch("root_turn_id") if parent_run.respond_to?(:fetch)
|
|
194
|
+
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
132
197
|
end
|
|
133
198
|
end
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Client
|
|
5
|
-
def
|
|
5
|
+
def validate!(model:)
|
|
6
|
+
true
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
6
10
|
raise NotImplementedError
|
|
7
11
|
end
|
|
8
12
|
end
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -26,28 +26,29 @@ module TurnKit
|
|
|
26
26
|
async ? turn : turn.run!
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
|
|
30
|
-
build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking, compact: compact).run!
|
|
29
|
+
def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
|
|
30
|
+
build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, on_event: on_event).run!
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
|
|
33
|
+
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
|
|
34
34
|
snapshot = latest_message_sequence
|
|
35
35
|
effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
|
|
36
36
|
options = { "trigger_message_id" => trigger_message_id }.compact
|
|
37
37
|
options["thinking"] = effective_thinking
|
|
38
38
|
options["compact"] = compact unless compact.nil?
|
|
39
|
+
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
39
40
|
record = store.create_turn(
|
|
40
41
|
"conversation_id" => id,
|
|
41
42
|
"agent_name" => agent.name,
|
|
42
43
|
"parent_turn_id" => parent_turn&.id,
|
|
43
44
|
"parent_tool_execution_id" => parent_tool_execution&.id,
|
|
44
|
-
"root_turn_id" => parent_turn&.root_turn_id,
|
|
45
|
+
"root_turn_id" => parent_turn&.root_turn_id || root_turn_id,
|
|
45
46
|
"context_message_sequence" => snapshot,
|
|
46
47
|
"status" => "pending",
|
|
47
48
|
"model" => model || self.model || agent.effective_model,
|
|
48
49
|
"options" => options
|
|
49
50
|
)
|
|
50
|
-
Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
|
|
51
|
+
Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth, on_event: on_event)
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def compact!(focus: nil, model: nil)
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -4,6 +4,8 @@ module TurnKit
|
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
class ConfigError < Error; end
|
|
6
6
|
class CompactionError < Error; end
|
|
7
|
+
class ModelAccessError < ConfigError; end
|
|
7
8
|
class StoreError < Error; end
|
|
8
9
|
class ToolError < Error; end
|
|
10
|
+
class ToolValidationError < ToolError; end
|
|
9
11
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class Event
|
|
5
|
+
attr_reader :type, :turn_id, :conversation_id, :payload, :created_at
|
|
6
|
+
|
|
7
|
+
def initialize(type:, turn_id:, conversation_id:, payload: {}, created_at: Clock.now)
|
|
8
|
+
@type = type.to_s
|
|
9
|
+
@turn_id = turn_id
|
|
10
|
+
@conversation_id = conversation_id
|
|
11
|
+
@payload = payload || {}
|
|
12
|
+
@created_at = created_at
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
"type" => type,
|
|
18
|
+
"turn_id" => turn_id,
|
|
19
|
+
"conversation_id" => conversation_id,
|
|
20
|
+
"payload" => payload,
|
|
21
|
+
"created_at" => created_at
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|