turnkit 0.2.8 → 0.2.9
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 +5 -5
- data/README.md +81 -23
- data/UPGRADE.md +35 -68
- data/lib/turnkit/adapters/codex.rb +160 -0
- data/lib/turnkit/agent.rb +2 -1
- data/lib/turnkit/conversation.rb +4 -3
- data/lib/turnkit/turn.rb +9 -2
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/{fleet.rb → workflow.rb} +4 -6
- data/lib/turnkit.rb +2 -5
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7069b120432ec902d846961157f5635c946602a8298ed4471f09dde3e3e3e0d
|
|
4
|
+
data.tar.gz: '09a5d64ff294f89ebde99a6cf1d36dc8731c6cabbf06216d4e9b9551cbe88a1e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de794838f5979194aa2469890848eb7cd60932d6f223e95d17be4d8912a6f2777afb55143f9776d7093be2072451c4a7ba0aa83ca8783c82a29375da56a11c90
|
|
7
|
+
data.tar.gz: c037fb4946a252ebf9bb2e0f99b76cca23d60f29275ce4e07a15f71232d4fdc0dce23337ad1b4b47bacd7df50ca7eedd3cf050c82167bcccb30debaa70cdfe22
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
3
|
+
## 0.2.9 - 2026-06-08
|
|
4
4
|
|
|
5
|
-
- Add
|
|
6
|
-
- Add `Agent#run` and `TurnKit::Run` for non-interactive application tasks.
|
|
7
|
-
- Improve task-runtime DX with `TurnKit.configure`, `TurnKit.model`, `TurnKit.max_spend`, `TurnKit
|
|
5
|
+
- Add `TurnKit::Workflow` for reusable single-orchestrator task runtimes with workflow skills, tools, guardrails, compaction, and run monitoring.
|
|
6
|
+
- Add `Agent#run` and `TurnKit::Run` for non-interactive application tasks, with task prompt behavior by default.
|
|
7
|
+
- Improve task-runtime DX with `TurnKit.configure`, `TurnKit.model`, `TurnKit.max_spend`, `TurnKit::Workflow`, positional `run("task")`, `run.output`, `run.tool_calls`, and `Tool.terminal!`.
|
|
8
8
|
- Support tool instances with constructor-injected dependencies.
|
|
9
|
-
- Add a
|
|
9
|
+
- Add a workflow researcher example and upgrade guide.
|
|
10
10
|
|
|
11
11
|
## 0.2.6 - 2026-06-07
|
|
12
12
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
[](https://www.ruby-lang.org)
|
|
5
5
|
[](LICENSE.md)
|
|
6
6
|
|
|
7
|
-
Build durable Ruby and Rails agents with
|
|
7
|
+
Build durable Ruby and Rails agents with conversations, runs, workflows, tools,
|
|
8
|
+
skills, sub-agents, and persistence.
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
10
11
|
|
|
@@ -57,6 +58,13 @@ puts run.output
|
|
|
57
58
|
|
|
58
59
|
## Usage
|
|
59
60
|
|
|
61
|
+
For runnable, API-key-free examples of the three core entry points, see
|
|
62
|
+
[`examples/core_api`](examples/core_api):
|
|
63
|
+
|
|
64
|
+
- conversation: durable thread over time;
|
|
65
|
+
- agent run: one bounded application task;
|
|
66
|
+
- workflow: reusable task runner with skills, tools, and limits.
|
|
67
|
+
|
|
60
68
|
### Models
|
|
61
69
|
|
|
62
70
|
Set a model:
|
|
@@ -92,6 +100,23 @@ Use these common providers:
|
|
|
92
100
|
|
|
93
101
|
Expect `TurnKit::ModelAccessError` for obvious key mistakes.
|
|
94
102
|
|
|
103
|
+
To run eligible coding tasks against a ChatGPT Plus/Pro Codex subscription instead of provider API-key billing, use the Codex adapter. It shells out to the official `codex exec` CLI, so authenticate Codex first:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
codex login --device-auth
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Then configure TurnKit:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
TurnKit.configure do |config|
|
|
113
|
+
config.client = TurnKit::Adapters::Codex.new(sandbox: "read-only")
|
|
114
|
+
config.model = "gpt-5.4"
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The Codex adapter does not store ChatGPT tokens or read `~/.codex/auth.json` directly. It reuses Codex CLI auth and records token usage with no TurnKit provider cost, because usage is charged against the user's ChatGPT/Codex plan limits.
|
|
119
|
+
|
|
95
120
|
### Conversations
|
|
96
121
|
|
|
97
122
|
Create a conversation:
|
|
@@ -118,10 +143,13 @@ turn = conversation.run!
|
|
|
118
143
|
puts turn.output_text
|
|
119
144
|
```
|
|
120
145
|
|
|
121
|
-
###
|
|
146
|
+
### Runs
|
|
147
|
+
|
|
148
|
+
Use `Agent#run` when your application needs one non-interactive result. A run is
|
|
149
|
+
the AI equivalent of a service object call: one input, one job, one output.
|
|
122
150
|
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
Reach for a run when the task is bounded, such as classification, extraction,
|
|
152
|
+
summarization, routing, scoring, or structured JSON generation.
|
|
125
153
|
|
|
126
154
|
```ruby
|
|
127
155
|
agent = TurnKit::Agent.new(
|
|
@@ -135,7 +163,6 @@ agent = TurnKit::Agent.new(
|
|
|
135
163
|
},
|
|
136
164
|
required: ["priority", "reason"]
|
|
137
165
|
},
|
|
138
|
-
prompt_mode: :task
|
|
139
166
|
)
|
|
140
167
|
|
|
141
168
|
run = agent.run(
|
|
@@ -146,8 +173,10 @@ run = agent.run(
|
|
|
146
173
|
puts run.output_data
|
|
147
174
|
```
|
|
148
175
|
|
|
149
|
-
`Agent#run`
|
|
150
|
-
|
|
176
|
+
`Agent#run` uses task prompt behavior by default: it treats the input as the
|
|
177
|
+
contract, avoids follow-up questions, and returns the best result it can. It is a
|
|
178
|
+
small wrapper over TurnKit's existing conversation and turn engine. Existing
|
|
179
|
+
`conversation.ask` usage is still supported for multi-turn threads.
|
|
151
180
|
|
|
152
181
|
Prepare a pending run without calling the model:
|
|
153
182
|
|
|
@@ -157,18 +186,22 @@ request = run.preview
|
|
|
157
186
|
run.run!
|
|
158
187
|
```
|
|
159
188
|
|
|
160
|
-
###
|
|
189
|
+
### Workflows
|
|
161
190
|
|
|
162
|
-
Use a
|
|
163
|
-
task
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
Use a workflow when a run graduates into a reusable production capability: a
|
|
192
|
+
named task runner with workflow skills, tools, defaults, guardrails, compaction,
|
|
193
|
+
and output policy.
|
|
194
|
+
|
|
195
|
+
Workflows fight for their life when the task has a repeatable operating
|
|
196
|
+
procedure: inspect app data, gather context, use sources, draft, verify, save,
|
|
197
|
+
and stop under budget. They are overkill for simple classification or extraction
|
|
198
|
+
runs.
|
|
166
199
|
|
|
167
200
|
```ruby
|
|
168
201
|
source_grounded_brief = TurnKit::Skill.from_file("app/ai/skills/source_grounded_brief.md")
|
|
169
202
|
|
|
170
|
-
|
|
171
|
-
"brief_writer",
|
|
203
|
+
workflow = TurnKit::Workflow.new(
|
|
204
|
+
name: "brief_writer",
|
|
172
205
|
instructions: "Create source-grounded briefs and verify claims before final output.",
|
|
173
206
|
skills: [source_grounded_brief],
|
|
174
207
|
tools: [WebSearch.new, ReadWebPage.new, SaveBrief],
|
|
@@ -181,7 +214,7 @@ fleet = TurnKit.fleet(
|
|
|
181
214
|
}
|
|
182
215
|
)
|
|
183
216
|
|
|
184
|
-
run =
|
|
217
|
+
run = workflow.run(
|
|
185
218
|
"Create a source-grounded brief.",
|
|
186
219
|
input: { topic: "Rails 8 Solid Queue" }
|
|
187
220
|
)
|
|
@@ -198,11 +231,36 @@ model-tool loop:
|
|
|
198
231
|
model → tool → result → model → tool → result → final
|
|
199
232
|
```
|
|
200
233
|
|
|
201
|
-
|
|
202
|
-
|
|
234
|
+
For repeated workflows, keep instructions, skills, and tools stable and pass the
|
|
235
|
+
per-run data through `input:`. This gives provider prompt caching the best chance
|
|
236
|
+
to reuse the stable workflow prompt while each run supplies dynamic data.
|
|
237
|
+
|
|
238
|
+
### Choosing runs, conversations, and workflows
|
|
239
|
+
|
|
240
|
+
Use the smallest entry point that matches the shape of work:
|
|
241
|
+
|
|
242
|
+
| Entry point | Use when | Tradeoffs |
|
|
243
|
+
| --- | --- | --- |
|
|
244
|
+
| `Conversation` | A user or app will keep adding messages over time. | Best for durable threads and follow-up steering; history grows, so long threads need compaction. |
|
|
245
|
+
| `Agent#run` | Your app needs one bounded result now. | Best for simple production tasks; repeated complex policies can sprawl across callers. |
|
|
246
|
+
| `TurnKit::Workflow` | A task becomes a named reusable workflow with tools, skills, limits, and observability. | Best cache and packaging story for repeated autonomous work; overkill for one-off/simple tasks. |
|
|
247
|
+
|
|
248
|
+
Prompt caching and compaction solve different problems:
|
|
249
|
+
|
|
250
|
+
- prompt caching reduces the cost of repeated stable instructions, tools, and
|
|
251
|
+
skills;
|
|
252
|
+
- compaction reduces the cost of long dynamic histories;
|
|
253
|
+
- budgets (`max_spend`, `max_iterations`, `max_tool_executions`) keep autonomous
|
|
254
|
+
loops bounded.
|
|
255
|
+
|
|
256
|
+
Reach for separate agents and `sub_agents` only when the isolation is worth the
|
|
257
|
+
extra model calls, such as different models, different tool permissions,
|
|
258
|
+
parallel specialist review, or separate durable child conversations.
|
|
259
|
+
|
|
260
|
+
Run a workflow with `run`:
|
|
203
261
|
|
|
204
262
|
```ruby
|
|
205
|
-
run =
|
|
263
|
+
run = workflow.run(
|
|
206
264
|
"Create compliant outreach for this account.",
|
|
207
265
|
input: lead.attributes,
|
|
208
266
|
max_spend: 0.25,
|
|
@@ -215,10 +273,6 @@ run = fleet.auto_run(
|
|
|
215
273
|
)
|
|
216
274
|
```
|
|
217
275
|
|
|
218
|
-
Reach for separate agents and `sub_agents` only when the isolation is worth the
|
|
219
|
-
extra model calls, such as different models, different tool permissions,
|
|
220
|
-
parallel specialist review, or separate durable child conversations.
|
|
221
|
-
|
|
222
276
|
Use `terminal!` for save or action tools that complete the run:
|
|
223
277
|
|
|
224
278
|
```ruby
|
|
@@ -491,7 +545,7 @@ TurnKit.reconcile_stale!
|
|
|
491
545
|
| `TurnKit.max_depth` | Limit sub-agent depth. |
|
|
492
546
|
| `TurnKit.max_tool_executions` | Limit tool calls per turn. |
|
|
493
547
|
| `TurnKit.timeout` | Limit turn runtime. |
|
|
494
|
-
| `TurnKit.
|
|
548
|
+
| `TurnKit.max_spend` | Limit estimated turn cost. |
|
|
495
549
|
| `TurnKit.compaction` | Configure context compaction. |
|
|
496
550
|
| `TurnKit.on_event` | Subscribe to lifecycle events. |
|
|
497
551
|
|
|
@@ -499,10 +553,14 @@ Set options globally:
|
|
|
499
553
|
|
|
500
554
|
```ruby
|
|
501
555
|
TurnKit.default_model = "gpt-4.1-mini"
|
|
556
|
+
TurnKit.max_spend = 0.25
|
|
502
557
|
TurnKit.max_iterations = 25
|
|
503
558
|
TurnKit.timeout = 300
|
|
504
559
|
```
|
|
505
560
|
|
|
561
|
+
`TurnKit.cost_limit` remains supported as the internal/legacy name for
|
|
562
|
+
`max_spend`.
|
|
563
|
+
|
|
506
564
|
Set options per agent:
|
|
507
565
|
|
|
508
566
|
```ruby
|
data/UPGRADE.md
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
# Upgrade Guide
|
|
2
2
|
|
|
3
|
-
This guide covers migrating to the
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
5
|
+
|
|
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.
|
|
7
9
|
|
|
8
10
|
## Quick summary
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
Before changing call sites, bump TurnKit to the latest version and run your
|
|
13
|
+
test suite against the new release.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "turnkit", "~> 0.2.9"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle update turnkit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use workflows for reusable autonomous task runners.
|
|
11
25
|
|
|
12
26
|
Recommended new forms:
|
|
13
27
|
|
|
@@ -17,23 +31,12 @@ TurnKit.configure do |config|
|
|
|
17
31
|
config.max_spend = 0.25
|
|
18
32
|
end
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
run =
|
|
34
|
+
workflow = TurnKit::Workflow.new(name: "brief_writer", tools: [WebSearch, SaveBrief])
|
|
35
|
+
run = workflow.run("Create a source-grounded brief.", input: { topic: "Rails 8" })
|
|
22
36
|
|
|
23
37
|
puts run.output
|
|
24
38
|
```
|
|
25
39
|
|
|
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
40
|
## Configuration
|
|
38
41
|
|
|
39
42
|
### Model name
|
|
@@ -112,7 +115,8 @@ puts run.output
|
|
|
112
115
|
```
|
|
113
116
|
|
|
114
117
|
The keyword form still works. The positional string is the recommended form for
|
|
115
|
-
the common case.
|
|
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.
|
|
116
120
|
|
|
117
121
|
### Pending runs
|
|
118
122
|
|
|
@@ -130,10 +134,10 @@ The existing keyword form remains valid:
|
|
|
130
134
|
run = agent.run(task: "Classify later.", async: true)
|
|
131
135
|
```
|
|
132
136
|
|
|
133
|
-
##
|
|
137
|
+
## Workflows
|
|
134
138
|
|
|
135
|
-
The
|
|
136
|
-
|
|
139
|
+
The preferred name for reusable autonomous task runtimes is now workflow. A
|
|
140
|
+
workflow packages:
|
|
137
141
|
|
|
138
142
|
- one task-mode orchestrator
|
|
139
143
|
- workflow skills
|
|
@@ -144,10 +148,8 @@ task runtime.” A fleet packages:
|
|
|
144
148
|
|
|
145
149
|
### Construction
|
|
146
150
|
|
|
147
|
-
Before:
|
|
148
|
-
|
|
149
151
|
```ruby
|
|
150
|
-
|
|
152
|
+
workflow = TurnKit::Workflow.new(
|
|
151
153
|
name: "sales_enrichment",
|
|
152
154
|
tools: [AccountLookup, WebSearch, SaveEnrichment],
|
|
153
155
|
skills: [sales_research_skill],
|
|
@@ -155,34 +157,10 @@ fleet = TurnKit::Fleet.new(
|
|
|
155
157
|
)
|
|
156
158
|
```
|
|
157
159
|
|
|
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
160
|
### Running
|
|
172
161
|
|
|
173
|
-
Before:
|
|
174
|
-
|
|
175
162
|
```ruby
|
|
176
|
-
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(
|
|
163
|
+
run = workflow.run(
|
|
186
164
|
"Enrich this account for responsible outreach.",
|
|
187
165
|
input: account.attributes
|
|
188
166
|
)
|
|
@@ -190,17 +168,6 @@ run = fleet.run(
|
|
|
190
168
|
|
|
191
169
|
`task:` remains supported.
|
|
192
170
|
|
|
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
171
|
## Run inspection
|
|
205
172
|
|
|
206
173
|
New convenience methods were added to `TurnKit::Run`.
|
|
@@ -285,10 +252,10 @@ agent = TurnKit::Agent.new(tools: [WebSearch.new(client: client)])
|
|
|
285
252
|
This is the recommended pattern for API clients, test doubles, and per-tenant
|
|
286
253
|
dependencies.
|
|
287
254
|
|
|
288
|
-
## Multi-agent
|
|
255
|
+
## Multi-agent workflows
|
|
289
256
|
|
|
290
257
|
If you previously modeled every role as a separate agent, consider migrating the
|
|
291
|
-
default path to one
|
|
258
|
+
default path to one workflow with a workflow skill.
|
|
292
259
|
|
|
293
260
|
Before:
|
|
294
261
|
|
|
@@ -315,8 +282,8 @@ workflow = TurnKit::Skill.new(
|
|
|
315
282
|
TEXT
|
|
316
283
|
)
|
|
317
284
|
|
|
318
|
-
|
|
319
|
-
"source_brief",
|
|
285
|
+
source_brief = TurnKit::Workflow.new(
|
|
286
|
+
name: "source_brief",
|
|
320
287
|
skills: [workflow],
|
|
321
288
|
tools: [WebSearch, ReadWebPage, SaveBrief],
|
|
322
289
|
max_spend: 0.25,
|
|
@@ -336,11 +303,11 @@ Keep separate agents when the isolation is worth the extra model calls:
|
|
|
336
303
|
|
|
337
304
|
1. Replace `TurnKit.default_model =` with `TurnKit.model =` in app-level config.
|
|
338
305
|
2. Wrap global settings in `TurnKit.configure` if you have more than one.
|
|
339
|
-
3.
|
|
306
|
+
3. Use `TurnKit::Workflow.new(name: "...")` for reusable autonomous task runners.
|
|
340
307
|
4. Replace `run(task: "...")` with `run("...")` where it improves readability.
|
|
341
308
|
5. Replace `run.output_text` with `run.output` in application code.
|
|
342
309
|
6. Replace save/action tool overrides with `terminal!` when convenient.
|
|
343
|
-
7. Consider collapsing role-agent
|
|
310
|
+
7. Consider collapsing role-agent workflows into one workflow plus workflow skills if
|
|
344
311
|
cost or complexity is a concern.
|
|
345
312
|
|
|
346
|
-
|
|
313
|
+
Run your test suite after migrating call sites.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module TurnKit
|
|
8
|
+
module Adapters
|
|
9
|
+
class Codex < Client
|
|
10
|
+
Status = Struct.new(:successful, keyword_init: true) do
|
|
11
|
+
def success? = successful
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :command, :sandbox, :working_directory
|
|
15
|
+
|
|
16
|
+
def initialize(command: ENV.fetch("CODEX_COMMAND", "codex"), sandbox: "read-only", working_directory: Dir.pwd, runner: nil)
|
|
17
|
+
@command = command.to_s
|
|
18
|
+
@sandbox = sandbox
|
|
19
|
+
@working_directory = working_directory
|
|
20
|
+
@runner = runner || method(:run_command)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!(model:)
|
|
24
|
+
raise ModelAccessError, "codex command is required" if command.empty?
|
|
25
|
+
raise ModelAccessError, "#{command.inspect} was not found. Install OpenAI Codex CLI and run `codex login --device-auth`." unless executable?(command)
|
|
26
|
+
|
|
27
|
+
stdout, stderr, status = @runner.call([ command, "login", "status" ], stdin_data: nil, chdir: working_directory)
|
|
28
|
+
return true if status.success?
|
|
29
|
+
|
|
30
|
+
message = [ stderr, stdout ].join("\n").strip
|
|
31
|
+
hint = "Run `#{command} login --device-auth` to connect your ChatGPT/Codex subscription."
|
|
32
|
+
raise ModelAccessError, [ "Codex is not authenticated.", message, hint ].reject(&:empty?).join(" ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
36
|
+
raise ToolError, "TurnKit tools are not supported by the Codex adapter; Codex uses its own local tools" if Array(tools).any?
|
|
37
|
+
|
|
38
|
+
with_tempfiles(output_schema: output_schema) do |schema_file, output_file|
|
|
39
|
+
command = exec_command(model: model, schema_file: schema_file&.path, output_file: output_file.path)
|
|
40
|
+
stdout, stderr, status = @runner.call(command, stdin_data: prompt_for(messages: messages, instructions: instructions), chdir: working_directory)
|
|
41
|
+
emit_codex_events(stdout, on_event: on_event)
|
|
42
|
+
raise ModelAccessError, stderr.strip.empty? ? "codex exec failed" : stderr.strip unless status.success?
|
|
43
|
+
|
|
44
|
+
text = read_output(output_file, stdout)
|
|
45
|
+
Result.new(
|
|
46
|
+
text: text,
|
|
47
|
+
output_data: parse_output_data(text, output_schema: output_schema),
|
|
48
|
+
usage: usage_from_jsonl(stdout),
|
|
49
|
+
model: model
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def exec_command(model:, schema_file:, output_file:)
|
|
56
|
+
args = [ command, "exec", "--json" ]
|
|
57
|
+
args += [ "--sandbox", sandbox.to_s ] if sandbox
|
|
58
|
+
args += [ "--model", model.to_s ] unless model.to_s.empty? || model.to_s == "codex"
|
|
59
|
+
args += [ "--output-schema", schema_file ] if schema_file
|
|
60
|
+
args += [ "-o", output_file, "-" ]
|
|
61
|
+
args
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def prompt_for(messages:, instructions:)
|
|
65
|
+
parts = []
|
|
66
|
+
parts << "System instructions:\n#{instructions}" unless instructions.to_s.empty?
|
|
67
|
+
Array(messages).each do |message|
|
|
68
|
+
attrs = message.respond_to?(:to_h) ? message.to_h : message
|
|
69
|
+
attrs = attrs.transform_keys(&:to_s)
|
|
70
|
+
role = attrs["role"] || "user"
|
|
71
|
+
content = attrs["content"] || attrs["text"] || ""
|
|
72
|
+
parts << "#{role}:\n#{content}"
|
|
73
|
+
end
|
|
74
|
+
parts.join("\n\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def with_tempfiles(output_schema:)
|
|
78
|
+
output_file = Tempfile.new([ "turnkit-codex-output", ".txt" ])
|
|
79
|
+
schema_file = nil
|
|
80
|
+
if output_schema
|
|
81
|
+
schema_file = Tempfile.new([ "turnkit-codex-schema", ".json" ])
|
|
82
|
+
schema_file.write(JSON.generate(output_schema))
|
|
83
|
+
schema_file.flush
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
yield schema_file, output_file
|
|
87
|
+
ensure
|
|
88
|
+
schema_file&.close!
|
|
89
|
+
output_file&.close!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_output(output_file, stdout)
|
|
93
|
+
output_file.rewind
|
|
94
|
+
text = output_file.read.to_s
|
|
95
|
+
return text unless text.empty?
|
|
96
|
+
|
|
97
|
+
final_message_from_jsonl(stdout) || stdout.to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def final_message_from_jsonl(stdout)
|
|
101
|
+
events = parse_jsonl(stdout)
|
|
102
|
+
messages = events.filter_map do |event|
|
|
103
|
+
item = event["item"]
|
|
104
|
+
next unless item.is_a?(Hash) && item["type"] == "agent_message"
|
|
105
|
+
|
|
106
|
+
item["text"]
|
|
107
|
+
end
|
|
108
|
+
messages.last
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_output_data(text, output_schema:)
|
|
112
|
+
return nil unless output_schema
|
|
113
|
+
|
|
114
|
+
JSON.parse(text)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def usage_from_jsonl(stdout)
|
|
120
|
+
usage = parse_jsonl(stdout).filter_map { |event| event["usage"] if event.is_a?(Hash) }.last || {}
|
|
121
|
+
input = usage["input_tokens"].to_i
|
|
122
|
+
cached = usage["cached_input_tokens"].to_i
|
|
123
|
+
Usage.new(
|
|
124
|
+
input_tokens: [ input - cached, 0 ].max,
|
|
125
|
+
output_tokens: usage["output_tokens"].to_i,
|
|
126
|
+
cached_tokens: cached,
|
|
127
|
+
thinking_tokens: usage["reasoning_output_tokens"].to_i
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def emit_codex_events(stdout, on_event:)
|
|
132
|
+
return unless on_event
|
|
133
|
+
|
|
134
|
+
parse_jsonl(stdout).each do |event|
|
|
135
|
+
on_event.call(type: "codex.#{event.fetch("type", "event")}", payload: event)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_jsonl(stdout)
|
|
140
|
+
stdout.to_s.each_line.filter_map do |line|
|
|
141
|
+
JSON.parse(line)
|
|
142
|
+
rescue JSON::ParserError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def executable?(name)
|
|
148
|
+
return true if @runner != method(:run_command)
|
|
149
|
+
return File.executable?(name) if name.include?(File::SEPARATOR)
|
|
150
|
+
|
|
151
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |path| File.executable?(File.join(path, name)) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def run_command(command, stdin_data:, chdir:)
|
|
155
|
+
stdout, stderr, status = Open3.capture3(*command, stdin_data: stdin_data, chdir: chdir)
|
|
156
|
+
[ stdout, stderr, status ]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/turnkit/agent.rb
CHANGED
|
@@ -62,7 +62,7 @@ module TurnKit
|
|
|
62
62
|
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, **options)
|
|
65
|
+
def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
|
|
66
66
|
task = task || prompt
|
|
67
67
|
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
68
68
|
|
|
@@ -71,6 +71,7 @@ module TurnKit
|
|
|
71
71
|
turn = conversation.build_turn(
|
|
72
72
|
trigger_message_id: message.id,
|
|
73
73
|
root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
|
|
74
|
+
prompt_mode: prompt_mode,
|
|
74
75
|
**options
|
|
75
76
|
)
|
|
76
77
|
run = Run.new(turn)
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -26,17 +26,18 @@ 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, 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!
|
|
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, prompt_mode: 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, prompt_mode: prompt_mode, 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, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: 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, prompt_mode: 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
39
|
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
40
|
+
options["prompt_mode"] = prompt_mode.to_sym if prompt_mode
|
|
40
41
|
record = store.create_turn(
|
|
41
42
|
"conversation_id" => id,
|
|
42
43
|
"agent_name" => agent.name,
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -6,7 +6,7 @@ module TurnKit
|
|
|
6
6
|
|
|
7
7
|
attr_reader :agent, :conversation, :store, :budget, :depth
|
|
8
8
|
attr_reader :id, :conversation_id, :agent_name, :parent_turn_id, :parent_tool_execution_id
|
|
9
|
-
attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact, :output_schema
|
|
9
|
+
attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact, :output_schema, :prompt_mode
|
|
10
10
|
attr_reader :started_at
|
|
11
11
|
|
|
12
12
|
def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0, on_event: nil)
|
|
@@ -25,6 +25,7 @@ module TurnKit
|
|
|
25
25
|
@thinking = thinking_from_options
|
|
26
26
|
@compact = compact_from_options
|
|
27
27
|
@output_schema = output_schema_from_options
|
|
28
|
+
@prompt_mode = prompt_mode_from_options
|
|
28
29
|
@started_at = @record["started_at"]
|
|
29
30
|
@budget = budget || agent.build_budget
|
|
30
31
|
@depth = depth
|
|
@@ -112,6 +113,7 @@ module TurnKit
|
|
|
112
113
|
@thinking = thinking_from_options
|
|
113
114
|
@compact = compact_from_options
|
|
114
115
|
@output_schema = output_schema_from_options
|
|
116
|
+
@prompt_mode = prompt_mode_from_options
|
|
115
117
|
self
|
|
116
118
|
end
|
|
117
119
|
|
|
@@ -125,7 +127,7 @@ module TurnKit
|
|
|
125
127
|
|
|
126
128
|
private
|
|
127
129
|
def model_request
|
|
128
|
-
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: agent.effective_prompt_mode(turn: self))
|
|
130
|
+
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
129
131
|
instructions = case agent.system_prompt
|
|
130
132
|
when nil
|
|
131
133
|
prompt.to_s
|
|
@@ -191,6 +193,11 @@ module TurnKit
|
|
|
191
193
|
options["output_schema"] if options.key?("output_schema")
|
|
192
194
|
end
|
|
193
195
|
|
|
196
|
+
def prompt_mode_from_options
|
|
197
|
+
options = (@record["options"] || {}).transform_keys(&:to_s)
|
|
198
|
+
options["prompt_mode"]&.to_sym if options.key?("prompt_mode")
|
|
199
|
+
end
|
|
200
|
+
|
|
194
201
|
def persist_assistant_message(result)
|
|
195
202
|
if result.tool_calls?
|
|
196
203
|
message = conversation.append_message(
|
data/lib/turnkit/version.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "agent"
|
|
4
|
+
|
|
3
5
|
module TurnKit
|
|
4
|
-
class
|
|
6
|
+
class Workflow
|
|
5
7
|
attr_reader :name, :description, :instructions, :tools, :skills, :available_skills
|
|
6
8
|
attr_reader :model, :client, :store, :prompt_mode, :thinking, :compaction, :output_schema
|
|
7
9
|
attr_reader :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
|
|
@@ -21,7 +23,7 @@ module TurnKit
|
|
|
21
23
|
limits.
|
|
22
24
|
TEXT
|
|
23
25
|
|
|
24
|
-
def initialize(name: "
|
|
26
|
+
def initialize(name: "workflow", description: "", instructions: nil,
|
|
25
27
|
tools: [], skills: [], available_skills: [], model: nil, client: nil,
|
|
26
28
|
store: nil, prompt_mode: :task, thinking: nil, compaction: nil,
|
|
27
29
|
output_schema: nil, max_iterations: nil, timeout: nil, max_spend: nil,
|
|
@@ -64,9 +66,6 @@ module TurnKit
|
|
|
64
66
|
)
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
alias_method :auto_run, :run
|
|
68
|
-
alias_method :autorun, :run
|
|
69
|
-
|
|
70
69
|
def agent(**options)
|
|
71
70
|
build_agent(**options)
|
|
72
71
|
end
|
|
@@ -101,5 +100,4 @@ module TurnKit
|
|
|
101
100
|
Agent.new(**attrs)
|
|
102
101
|
end
|
|
103
102
|
end
|
|
104
|
-
|
|
105
103
|
end
|
data/lib/turnkit.rb
CHANGED
|
@@ -15,7 +15,7 @@ require_relative "turnkit/budget"
|
|
|
15
15
|
require_relative "turnkit/event"
|
|
16
16
|
require_relative "turnkit/model_request"
|
|
17
17
|
require_relative "turnkit/agent"
|
|
18
|
-
require_relative "turnkit/
|
|
18
|
+
require_relative "turnkit/workflow"
|
|
19
19
|
require_relative "turnkit/client"
|
|
20
20
|
require_relative "turnkit/conversation"
|
|
21
21
|
require_relative "turnkit/message"
|
|
@@ -38,6 +38,7 @@ require_relative "turnkit/tool_runner"
|
|
|
38
38
|
require_relative "turnkit/turn"
|
|
39
39
|
require_relative "turnkit/usage"
|
|
40
40
|
require_relative "turnkit/run"
|
|
41
|
+
require_relative "turnkit/adapters/codex"
|
|
41
42
|
require_relative "turnkit/adapters/ruby_llm"
|
|
42
43
|
require_relative "turnkit/stores/active_record_store"
|
|
43
44
|
|
|
@@ -96,10 +97,6 @@ module TurnKit
|
|
|
96
97
|
self.cost_limit = value
|
|
97
98
|
end
|
|
98
99
|
|
|
99
|
-
def self.fleet(name = "orchestrator", **options)
|
|
100
|
-
Fleet.new(name: name, **options)
|
|
101
|
-
end
|
|
102
|
-
|
|
103
100
|
def self.reconcile_stale!(before: Clock.now - (timeout || 300))
|
|
104
101
|
store.find_stale_turns(before: before).each do |turn|
|
|
105
102
|
store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: turnkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sam Couch
|
|
@@ -24,8 +24,9 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '1.14'
|
|
27
|
-
description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations,
|
|
28
|
-
calling, skills, sub-agents, context compaction,
|
|
27
|
+
description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
|
|
28
|
+
runs, reusable workflows, tool calling, skills, sub-agents, context compaction,
|
|
29
|
+
and persistence.
|
|
29
30
|
email:
|
|
30
31
|
- sam@samcouch.com
|
|
31
32
|
executables: []
|
|
@@ -37,6 +38,7 @@ files:
|
|
|
37
38
|
- README.md
|
|
38
39
|
- UPGRADE.md
|
|
39
40
|
- lib/turnkit.rb
|
|
41
|
+
- lib/turnkit/adapters/codex.rb
|
|
40
42
|
- lib/turnkit/adapters/ruby_llm.rb
|
|
41
43
|
- lib/turnkit/agent.rb
|
|
42
44
|
- lib/turnkit/budget.rb
|
|
@@ -47,7 +49,6 @@ files:
|
|
|
47
49
|
- lib/turnkit/cost.rb
|
|
48
50
|
- lib/turnkit/error.rb
|
|
49
51
|
- lib/turnkit/event.rb
|
|
50
|
-
- lib/turnkit/fleet.rb
|
|
51
52
|
- lib/turnkit/generators/turnkit/install/templates/conversation.rb
|
|
52
53
|
- lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb
|
|
53
54
|
- lib/turnkit/generators/turnkit/install/templates/initializer.rb
|
|
@@ -79,6 +80,7 @@ files:
|
|
|
79
80
|
- lib/turnkit/turn.rb
|
|
80
81
|
- lib/turnkit/usage.rb
|
|
81
82
|
- lib/turnkit/version.rb
|
|
83
|
+
- lib/turnkit/workflow.rb
|
|
82
84
|
homepage: https://github.com/samuelcouch/turnkit
|
|
83
85
|
licenses:
|
|
84
86
|
- MIT
|
|
@@ -106,5 +108,5 @@ requirements: []
|
|
|
106
108
|
rubygems_version: 3.5.22
|
|
107
109
|
signing_key:
|
|
108
110
|
specification_version: 4
|
|
109
|
-
summary: Ruby/Rails agent runtime for durable AI conversations.
|
|
111
|
+
summary: Ruby/Rails agent runtime for durable AI conversations, runs, and workflows.
|
|
110
112
|
test_files: []
|