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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2859e971c248c783c407f498d81c9dc489f89120dc273edb517b19e56ef42111
4
- data.tar.gz: 2afce740b36683dd513a47770353b7fb74ed174297e96ab7e17efece82d446a6
3
+ metadata.gz: a7069b120432ec902d846961157f5635c946602a8298ed4471f09dde3e3e3e0d
4
+ data.tar.gz: '09a5d64ff294f89ebde99a6cf1d36dc8731c6cabbf06216d4e9b9551cbe88a1e'
5
5
  SHA512:
6
- metadata.gz: 3e70a71f00507cad12b7ea9d8f8506d4ae16ddbd7dbfbf3e59808ebdc244392e9dde14cf7ee4ee2d7578351bca0af906bb4ede6a6419e8136cf00706b2115307
7
- data.tar.gz: ba707c678fb3dee1211d0e0d2e57eccfe1ac4e15567fb985127602043085693643c70f6f7f5779259f2baf3b437b739d23db3196f500f00ae157a854d6543a44
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.8 - 2026-06-08
3
+ ## 0.2.9 - 2026-06-08
4
4
 
5
- - Add autonomous task fleets as reusable single-orchestrator runtimes with workflow skills, tools, guardrails, compaction, and run monitoring.
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.fleet`, positional `run("task")`, `run.output`, `run.tool_calls`, and `Tool.terminal!`.
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 fleet researcher example and upgrade guide.
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
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg)](https://www.ruby-lang.org)
5
5
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md)
6
6
 
7
- Build durable Ruby and Rails agents with tools, skills, sub-agents, and persistence.
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
- ### Application Tasks
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
- Use `Agent#run` when your application is executing a task instead of chatting
124
- with a user:
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` is a small wrapper over TurnKit's existing conversation and turn
150
- engine. Existing `conversation.ask` usage is still supported.
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
- ### Fleets
189
+ ### Workflows
161
190
 
162
- Use a fleet when you want to package a reusable autonomous workflow: one
163
- task-mode orchestrator, workflow skills, tools, defaults, and guardrails. A
164
- fleet is not a requirement for multi-agent work; it is the reusable runtime for
165
- getting from input to output.
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
- fleet = TurnKit.fleet(
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 = fleet.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
- `auto_run` is an alias for `run` when you want the name to emphasize autonomous
202
- execution:
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 = fleet.auto_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.cost_limit` | Limit estimated turn cost. |
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 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.
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
- You do **not** need to rewrite existing code immediately.
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
- fleet = TurnKit.fleet("brief_writer", tools: [WebSearch, SaveBrief])
21
- run = fleet.run("Create a source-grounded brief.", input: { topic: "Rails 8" })
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
- ## Fleets
137
+ ## Workflows
134
138
 
135
- The fleet mental model changed from “many agents” to “one reusable autonomous
136
- task runtime.” A fleet packages:
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
- fleet = TurnKit::Fleet.new(
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 = 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(
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 fleets
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 fleet with a workflow skill.
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
- fleet = TurnKit.fleet(
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. Replace `TurnKit::Fleet.new(name: ...)` with `TurnKit.fleet("...")` in new code.
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 fleets into one fleet plus workflow skills if
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
- None of these steps are required for existing code to keep working.
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)
@@ -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(
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.9"
5
5
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent"
4
+
3
5
  module TurnKit
4
- class Fleet
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: "orchestrator", description: "", instructions: nil,
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/fleet"
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.8
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, tool
28
- calling, skills, sub-agents, context compaction, and persistence.
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: []