turnkit 0.1.0 → 0.2.1

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: 7aec304178bd79782515aaacd238e19de0556e64947406f14cdc8d6ccf05f71b
4
- data.tar.gz: 92691d763c7cc007fb5cde9b9af2fbc28c69db9078b334f6b9a10892971a4644
3
+ metadata.gz: 5eaf1e8d25237f97b607cf5a1c80fced59e8e737cd4cdbf3a9d6dca61227b19b
4
+ data.tar.gz: da3cc66e95e3ce0ec5df6fbc8b051fc0f6750a0c2175d0cdc09f8eb4dc4ef96e
5
5
  SHA512:
6
- metadata.gz: ab078a5e371c67774b9232ef90f3726fb630f1d1b4d13b71cdd09e51263074f9135ab87b9371e31e149b2d982e6ec4dea9950a2cf0fac7677fe334e950bc0071
7
- data.tar.gz: c0637c433abbfc2e2c03bdc4632b3416c6596ffa20cc1855d537c68e4b54120033b78a435ffd8a311f9d29f8e0c0ae6f3ca06dd9a75d9a29f26e5a87f173bb78
6
+ metadata.gz: 9c50e9bd40a36392496a88f202bb5dbda51883c4ae686d8089849c6cb2fef903102efcb2a868617cca80891860849b65140888bc3bf250f620ac8bf9ea61a8e8
7
+ data.tar.gz: a054d0de8955d20f4d9ac60351507f56e98bf8137828d599ac5681ff593b5039f4d672dc16323f30a700a4f25aef3360fb6f97c64d75ce08f10eedf3fde692c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-06-04
4
+
5
+ - Add configurable system prompt sections and custom system prompt builders.
6
+ - Add globally and per-agent available skills for prompt guidance.
7
+ - Add skill loading from directories.
8
+
3
9
  ## 0.1.0 - 2026-06-04
4
10
 
5
11
  - Initial release of TurnKit.
data/README.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # TurnKit
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/turnkit.svg)](https://rubygems.org/gems/turnkit)
4
- [![Build](https://github.com/samuelcouch/turnkit/actions/workflows/ci.yml/badge.svg)](https://github.com/samuelcouch/turnkit/actions)
5
4
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg)](https://www.ruby-lang.org)
6
5
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md)
7
6
 
8
- Ruby AI agent runtime with durable turns, tools, skills, and Rails persistence.
7
+ Build durable Ruby AI agents with turns, tools, skills, and Rails persistence.
9
8
 
10
9
  ## Installation
11
10
 
@@ -23,12 +22,38 @@ bundle install
23
22
 
24
23
  ## Quick Start
25
24
 
26
- Set a provider key, then ask an agent:
25
+ Set a provider key:
27
26
 
28
27
  ```sh
29
28
  export ANTHROPIC_API_KEY=...
29
+ # or OPENAI_API_KEY=..., GEMINI_API_KEY=..., OPENROUTER_API_KEY=...
30
30
  ```
31
31
 
32
+ TurnKit uses RubyLLM by default. Choose the provider by choosing a RubyLLM model name:
33
+
34
+ ```ruby
35
+ TurnKit.default_model = "claude-sonnet-4-5" # Anthropic
36
+ # TurnKit.default_model = "gpt-4.1-mini" # OpenAI
37
+ # TurnKit.default_model = "gemini-2.5-flash" # Gemini
38
+ ```
39
+
40
+ You can also override the model per agent or per run.
41
+
42
+ To use a different model SDK, provide a client object that responds to `chat`:
43
+
44
+ ```ruby
45
+ class MyClient < TurnKit::Client
46
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
47
+ # Call your provider here.
48
+ TurnKit::Result.new(text: "provider response", model: model)
49
+ end
50
+ end
51
+
52
+ TurnKit.client = MyClient.new
53
+ ```
54
+
55
+ Ask an agent:
56
+
32
57
  ```ruby
33
58
  require "turnkit"
34
59
 
@@ -99,6 +124,55 @@ agent = TurnKit::Agent.new(
99
124
  )
100
125
  ```
101
126
 
127
+ List available skills:
128
+
129
+ ```ruby
130
+ research = TurnKit::Skill.from_file(
131
+ "skills/research.md",
132
+ description: "Use for source-backed research tasks."
133
+ )
134
+
135
+ agent = TurnKit::Agent.new(
136
+ name: "researcher",
137
+ instructions: "Prefer primary sources.",
138
+ tools: [WebSearch, ReadWebPage],
139
+ available_skills: [research]
140
+ )
141
+ ```
142
+
143
+ Add subject context:
144
+
145
+ ```ruby
146
+ article = Article.find(1)
147
+ conversation = agent.conversation(subject: article)
148
+ ```
149
+
150
+ Choose prompt sections:
151
+
152
+ ```ruby
153
+ agent = TurnKit::Agent.new(
154
+ name: "writer",
155
+ instructions: "Write plainly.",
156
+ prompt_sections: %i[agent instructions tools environment]
157
+ )
158
+ ```
159
+
160
+ Build a custom prompt:
161
+
162
+ ```ruby
163
+ agent = TurnKit::Agent.new(
164
+ name: "custom",
165
+ instructions: "Answer in JSON.",
166
+ system_prompt: ->(prompt) {
167
+ [
168
+ prompt.agent_section,
169
+ prompt.instructions_section,
170
+ "Return only valid JSON."
171
+ ].compact.join("\n\n")
172
+ }
173
+ )
174
+ ```
175
+
102
176
  Delegate to sub-agents:
103
177
 
104
178
  ```ruby
@@ -126,7 +200,6 @@ bin/rails db:migrate
126
200
  Configure Rails:
127
201
 
128
202
  ```ruby
129
- # config/initializers/turnkit.rb
130
203
  TurnKit.store = TurnKit::ActiveRecordStore.new
131
204
  TurnKit.default_model = "claude-sonnet-4-5"
132
205
  TurnKit.timeout = 300
@@ -140,7 +213,7 @@ TurnKit.reconcile_stale!
140
213
 
141
214
  ## Options
142
215
 
143
- Configure defaults globally:
216
+ Configure defaults:
144
217
 
145
218
  ```ruby
146
219
  TurnKit.default_model = "claude-sonnet-4-5"
@@ -163,20 +236,27 @@ agent = TurnKit::Agent.new(
163
236
  )
164
237
  ```
165
238
 
239
+ Override the model for a single conversation or turn:
240
+
241
+ ```ruby
242
+ conversation = agent.conversation(model: "claude-opus-4-1")
243
+ turn = conversation.run!(model: "gpt-4.1-mini")
244
+ ```
245
+
166
246
  | Option | Description |
167
247
  | --- | --- |
168
- | `default_model` | Default model for new turns. |
169
- | `client` | Client adapter for model calls. |
170
- | `store` | Store for conversations and turns. |
171
- | `max_iterations` | Maximum model calls per turn. |
172
- | `timeout` | Maximum seconds per root turn. |
173
- | `max_depth` | Maximum sub-agent nesting depth. |
174
- | `max_tool_executions` | Maximum tool calls per root turn. |
175
- | `cost_limit` | Maximum cost per root turn. |
248
+ | `default_model` | Set the default RubyLLM model. The model name determines the provider. |
249
+ | `client` | Set the model client. Defaults to `TurnKit::Adapters::RubyLLM.new`. |
250
+ | `store` | Set the conversation store. |
251
+ | `max_iterations` | Limit model calls per turn. |
252
+ | `timeout` | Limit seconds per root turn. |
253
+ | `max_depth` | Limit sub-agent nesting. |
254
+ | `max_tool_executions` | Limit tool calls per root turn. |
255
+ | `cost_limit` | Limit cost per root turn. |
176
256
 
177
257
  ## Contributing
178
258
 
179
- Open bug reports and pull requests on GitHub:
259
+ Report bugs and open pull requests on GitHub:
180
260
 
181
261
  ```text
182
262
  https://github.com/samuelcouch/turnkit
data/lib/turnkit/agent.rb CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  module TurnKit
4
4
  class Agent
5
- attr_reader :name, :description, :model, :instructions, :tools, :skills, :sub_agents
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
7
8
 
8
- def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], sub_agents: [], client: nil, store: nil,
9
+ def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
+ system_prompt: nil, prompt_sections: nil, client: nil, store: nil,
9
11
  max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil)
10
12
  @name = name.to_s
11
13
  @description = description.to_s
@@ -13,7 +15,10 @@ module TurnKit
13
15
  @instructions = instructions.to_s
14
16
  @tools = Array(tools)
15
17
  @skills = Array(skills)
18
+ @available_skills = Array(available_skills)
16
19
  @sub_agents = Array(sub_agents)
20
+ @system_prompt = system_prompt
21
+ @prompt_sections = prompt_sections
17
22
  @client = client
18
23
  @store = store
19
24
  @max_iterations = max_iterations
@@ -51,6 +56,27 @@ module TurnKit
51
56
  tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
52
57
  end
53
58
 
59
+ def effective_available_skills
60
+ (Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
61
+ end
62
+
63
+ def effective_prompt_sections
64
+ prompt_sections || TurnKit.prompt_sections
65
+ end
66
+
67
+ def system_prompt_for(turn:, conversation:)
68
+ prompt = SystemPrompt.new(agent: self, turn: turn, conversation: conversation)
69
+
70
+ case system_prompt
71
+ when nil
72
+ prompt.to_s
73
+ when String
74
+ system_prompt
75
+ else
76
+ system_prompt.call(prompt).to_s
77
+ end
78
+ end
79
+
54
80
  def build_budget(root_started_at: Clock.now)
55
81
  Budget.new(
56
82
  max_iterations: max_iterations || TurnKit.max_iterations,
@@ -64,9 +90,7 @@ module TurnKit
64
90
 
65
91
  def instructions_with_skills
66
92
  parts = [ instructions ]
67
- skills.each do |skill|
68
- parts << "## Skill: #{skill.name}\n\n#{skill.content}"
69
- end
93
+ parts << SystemPrompt.loaded_skills_text(skills)
70
94
  parts.reject(&:empty?).join("\n\n")
71
95
  end
72
96
  end
@@ -12,3 +12,8 @@ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
12
12
  # TurnKit.timeout = 300
13
13
  # TurnKit.max_depth = 3
14
14
  # TurnKit.max_tool_executions = 100
15
+
16
+ # TurnKit builds each system prompt from these sections by default.
17
+ # TurnKit.prompt_sections = %i[agent instructions behavior loaded_skills available_skills tools subject environment]
18
+ # TurnKit.prompt_behavior = "Custom behavior instructions."
19
+ # TurnKit.available_skills = TurnKit::Skill.from_directory(Rails.root.join("app/ai/skills"))
@@ -8,6 +8,8 @@ module TurnKit
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  include Rails::Generators::Migration
10
10
 
11
+ namespace "turnkit:install"
12
+
11
13
  source_root File.expand_path("install/templates", __dir__)
12
14
 
13
15
  class_option :table_prefix, type: :string, default: "turnkit", desc: "Database table prefix."
@@ -28,13 +30,21 @@ module TurnKit
28
30
  end
29
31
 
30
32
  def self.next_migration_number(dirname)
31
- if ActiveRecord::Base.timestamped_migrations
33
+ if timestamped_migrations?
32
34
  Time.now.utc.strftime("%Y%m%d%H%M%S")
33
35
  else
34
36
  "%.3d" % (current_migration_number(dirname) + 1)
35
37
  end
36
38
  end
37
39
 
40
+ def self.timestamped_migrations?
41
+ if ActiveRecord.respond_to?(:timestamped_migrations)
42
+ ActiveRecord.timestamped_migrations
43
+ else
44
+ ActiveRecord::Base.timestamped_migrations
45
+ end
46
+ end
47
+
38
48
  private
39
49
  def table_prefix
40
50
  options[:table_prefix]
data/lib/turnkit/skill.rb CHANGED
@@ -10,6 +10,10 @@ module TurnKit
10
10
  new(key: key || base, name: name || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description, content: content)
11
11
  end
12
12
 
13
+ def self.from_directory(path, pattern: "*.md")
14
+ Dir.glob(File.join(path, pattern)).sort.map { |file| from_file(file) }
15
+ end
16
+
13
17
  def initialize(key:, name:, content:, description: "")
14
18
  @key = key.to_s
15
19
  @name = name.to_s
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class SystemPrompt
5
+ DEFAULT_SECTIONS = %i[agent instructions behavior loaded_skills available_skills tools subject environment].freeze
6
+ SECTION_METHODS = {
7
+ agent: :agent_section,
8
+ instructions: :instructions_section,
9
+ behavior: :behavior_section,
10
+ loaded_skills: :loaded_skills_section,
11
+ available_skills: :available_skills_section,
12
+ tools: :tools_section,
13
+ subject: :subject_section,
14
+ environment: :environment_section
15
+ }.freeze
16
+
17
+ DEFAULT_BEHAVIOR = <<~TEXT.strip
18
+ Treat each user message as a constraint on the current task. Follow the
19
+ agent instructions and loaded skills first, then use tools when they are
20
+ available and needed.
21
+
22
+ Use the provided environment as the source of truth for the current date
23
+ and time. Do not guess relative dates like "today", "tomorrow", or
24
+ "yesterday" when the environment gives an exact calendar anchor.
25
+
26
+ Only use tools listed in <tools_available>. If a tool you want is not
27
+ listed, it is unavailable for this turn; adjust your answer instead of
28
+ pretending to call it.
29
+
30
+ If a tool returns an error, read the error and fix your inputs before
31
+ trying again. Do not retry the identical failing call blindly.
32
+
33
+ Report outcomes honestly. If you cannot verify something, say so or omit
34
+ the claim instead of inventing details.
35
+ TEXT
36
+
37
+ attr_reader :agent, :turn, :conversation, :sections
38
+
39
+ def initialize(agent:, turn:, conversation:, sections: nil)
40
+ @agent = agent
41
+ @turn = turn
42
+ @conversation = conversation
43
+ @sections = Array(sections || agent.effective_prompt_sections)
44
+ end
45
+
46
+ def to_s
47
+ sections.map { |section| render(section) }.compact.reject { |value| value.strip.empty? }.join("\n\n")
48
+ end
49
+
50
+ def render(section)
51
+ method = SECTION_METHODS[section.to_sym]
52
+ raise ArgumentError, "unknown prompt section: #{section}" unless method
53
+
54
+ public_send(method)
55
+ end
56
+
57
+ def agent_section
58
+ lines = [
59
+ "- Name: #{agent.name}",
60
+ agent.description.empty? ? nil : "- Description: #{agent.description}",
61
+ "- Model: #{turn.model || agent.effective_model}"
62
+ ].compact
63
+
64
+ tagged("agent", lines.join("\n"))
65
+ end
66
+
67
+ def instructions_section
68
+ return nil if agent.instructions.empty?
69
+
70
+ tagged("instructions", agent.instructions)
71
+ end
72
+
73
+ def behavior_section
74
+ tagged("behavior", TurnKit.prompt_behavior || DEFAULT_BEHAVIOR)
75
+ end
76
+
77
+ def loaded_skills_section
78
+ return nil if agent.skills.empty?
79
+
80
+ tagged(
81
+ "skills_loaded",
82
+ self.class.loaded_skills_text(agent.skills)
83
+ )
84
+ end
85
+
86
+ def self.loaded_skills_text(skills)
87
+ skills.map { |skill| "## Skill: #{skill.key}\n\n#{skill.content}" }.join("\n\n")
88
+ end
89
+
90
+ def available_skills_section
91
+ skills = agent.effective_available_skills
92
+ return nil if skills.empty?
93
+
94
+ entries = skills.map do |skill|
95
+ description = skill.description.empty? ? nil : " — #{skill.description}"
96
+ "- #{skill.key}: #{skill.name}#{description}"
97
+ end
98
+
99
+ tagged(
100
+ "skills_available",
101
+ "Load or follow a skill when the task matches its description.\n\n#{entries.join("\n")}"
102
+ )
103
+ end
104
+
105
+ def tools_section
106
+ tools = agent.effective_tools
107
+
108
+ if tools.empty?
109
+ tagged("tools_available", "(none)\n\nNo tools are available for this turn.")
110
+ else
111
+ tagged("tools_available", tools.map { |tool| tool_line(tool) }.join("\n"))
112
+ end
113
+ end
114
+
115
+ def subject_section
116
+ return nil unless conversation.subject&.respond_to?(:to_prompt)
117
+
118
+ value = conversation.subject.to_prompt.to_s.strip
119
+ return nil if value.empty?
120
+
121
+ tagged("subject_context", value)
122
+ end
123
+
124
+ def environment_section
125
+ anchor = turn.started_at || Clock.now
126
+ today = anchor.to_date
127
+ yesterday = today - 1
128
+ tomorrow = today + 1
129
+
130
+ tagged(
131
+ "environment",
132
+ [
133
+ "- Today: #{today.strftime('%A, %B %-d, %Y')} (#{today.iso8601})",
134
+ "- Current time: #{anchor.strftime('%-I:%M %Z')}",
135
+ "- Yesterday: #{yesterday.strftime('%A, %B %-d, %Y')} (#{yesterday.iso8601})",
136
+ "- Tomorrow: #{tomorrow.strftime('%A, %B %-d, %Y')} (#{tomorrow.iso8601})"
137
+ ].join("\n")
138
+ )
139
+ end
140
+
141
+ private
142
+ def tagged(name, content)
143
+ "<#{name}>\n#{content}\n</#{name}>"
144
+ end
145
+
146
+ def tool_line(tool)
147
+ description = tool.description.empty? ? nil : ": #{tool.description}"
148
+ params = tool.parameters.map do |param|
149
+ required = param.fetch(:required) ? " required" : ""
150
+ enum = param[:enum] ? " enum=#{Array(param[:enum]).join('|')}" : ""
151
+ "#{param.fetch(:name)}(#{param.fetch(:type)}#{required}#{enum})"
152
+ end
153
+ suffix = params.empty? ? "" : " Parameters: #{params.join(', ')}."
154
+ terminal = tool.ends_turn? ? " Ends the turn." : ""
155
+ "- #{tool.tool_name}#{description}#{suffix}#{terminal}"
156
+ end
157
+ end
158
+ end
data/lib/turnkit/turn.rb CHANGED
@@ -7,6 +7,7 @@ module TurnKit
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
9
  attr_reader :root_turn_id, :context_message_sequence, :model
10
+ attr_reader :started_at
10
11
 
11
12
  def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
12
13
  @agent = agent
@@ -21,6 +22,7 @@ module TurnKit
21
22
  @root_turn_id = @record["root_turn_id"] || id
22
23
  @context_message_sequence = @record["context_message_sequence"].to_i
23
24
  @model = @record["model"] || agent.effective_model
25
+ @started_at = @record["started_at"]
24
26
  @budget = budget || agent.build_budget
25
27
  @depth = depth
26
28
  end
@@ -37,7 +39,7 @@ module TurnKit
37
39
  model: model,
38
40
  messages: llm_messages,
39
41
  tools: agent.effective_tools,
40
- instructions: agent.instructions_with_skills,
42
+ instructions: agent.system_prompt_for(turn: self, conversation: conversation),
41
43
  metadata: { turn_id: id, conversation_id: conversation.id }
42
44
  )
43
45
 
@@ -128,6 +130,9 @@ module TurnKit
128
130
 
129
131
  def update!(attributes)
130
132
  @record = store.update_turn(id, attributes)
133
+ @started_at = @record["started_at"]
134
+ @model = @record["model"] || agent.effective_model
135
+ @record
131
136
  end
132
137
  end
133
138
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
+ require "date"
6
7
 
7
8
  require_relative "turnkit/version"
8
9
  require_relative "turnkit/error"
@@ -16,6 +17,7 @@ require_relative "turnkit/message"
16
17
  require_relative "turnkit/record"
17
18
  require_relative "turnkit/result"
18
19
  require_relative "turnkit/skill"
20
+ require_relative "turnkit/system_prompt"
19
21
  require_relative "turnkit/store"
20
22
  require_relative "turnkit/memory_store"
21
23
  require_relative "turnkit/tool"
@@ -36,6 +38,7 @@ module TurnKit
36
38
  attr_accessor :default_model, :client, :store, :logger
37
39
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
38
40
  attr_accessor :cost_limit
41
+ attr_accessor :prompt_sections, :prompt_behavior, :available_skills
39
42
  attr_accessor :conversation_record_class, :turn_record_class
40
43
  attr_accessor :message_record_class, :tool_execution_record_class
41
44
  end
@@ -47,6 +50,8 @@ module TurnKit
47
50
  self.timeout = 300
48
51
  self.max_depth = 3
49
52
  self.max_tool_executions = 100
53
+ self.prompt_sections = SystemPrompt::DEFAULT_SECTIONS.dup
54
+ self.available_skills = []
50
55
 
51
56
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
52
57
  store.find_stale_turns(before: before).each do |turn|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-04 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -62,6 +62,7 @@ files:
62
62
  - lib/turnkit/store.rb
63
63
  - lib/turnkit/stores/active_record_store.rb
64
64
  - lib/turnkit/sub_agent_tool.rb
65
+ - lib/turnkit/system_prompt.rb
65
66
  - lib/turnkit/tool.rb
66
67
  - lib/turnkit/tool_call.rb
67
68
  - lib/turnkit/tool_execution.rb