turnkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7aec304178bd79782515aaacd238e19de0556e64947406f14cdc8d6ccf05f71b
4
- data.tar.gz: 92691d763c7cc007fb5cde9b9af2fbc28c69db9078b334f6b9a10892971a4644
3
+ metadata.gz: 4a7dcc3aa1a8456411f915c927657756e2c879919d3d4f931b93674a305baa91
4
+ data.tar.gz: 3ae8bdbc9e66f6c966e6d8cf0b9790a0e432b6f0be93e2b02c71e72b5035c9c0
5
5
  SHA512:
6
- metadata.gz: ab078a5e371c67774b9232ef90f3726fb630f1d1b4d13b71cdd09e51263074f9135ab87b9371e31e149b2d982e6ec4dea9950a2cf0fac7677fe334e950bc0071
7
- data.tar.gz: c0637c433abbfc2e2c03bdc4632b3416c6596ffa20cc1855d537c68e4b54120033b78a435ffd8a311f9d29f8e0c0ae6f3ca06dd9a75d9a29f26e5a87f173bb78
6
+ metadata.gz: 8ab51bd650b36b7dd6b0ff8e423e64ae53cf98a7c89ce9a789f86cc2bbfa5421019f9860597bfe3a1169cb17d23a58b7d32f7c12cc15a7aaa7b2257a02718bd9
7
+ data.tar.gz: 300c9c80847bcc9022fc2b4339ae12e6bb12953c55f0229e92cb0870cb0431c9939f74af9f68eef1341fada31afeedfd766dc706115f71420bf2eef2616e88b0
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,14 @@ 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=...
30
29
  ```
31
30
 
31
+ Ask an agent:
32
+
32
33
  ```ruby
33
34
  require "turnkit"
34
35
 
@@ -99,6 +100,55 @@ agent = TurnKit::Agent.new(
99
100
  )
100
101
  ```
101
102
 
103
+ List available skills:
104
+
105
+ ```ruby
106
+ research = TurnKit::Skill.from_file(
107
+ "skills/research.md",
108
+ description: "Use for source-backed research tasks."
109
+ )
110
+
111
+ agent = TurnKit::Agent.new(
112
+ name: "researcher",
113
+ instructions: "Prefer primary sources.",
114
+ tools: [WebSearch, ReadWebPage],
115
+ available_skills: [research]
116
+ )
117
+ ```
118
+
119
+ Add subject context:
120
+
121
+ ```ruby
122
+ article = Article.find(1)
123
+ conversation = agent.conversation(subject: article)
124
+ ```
125
+
126
+ Choose prompt sections:
127
+
128
+ ```ruby
129
+ agent = TurnKit::Agent.new(
130
+ name: "writer",
131
+ instructions: "Write plainly.",
132
+ prompt_sections: %i[agent instructions tools environment]
133
+ )
134
+ ```
135
+
136
+ Build a custom prompt:
137
+
138
+ ```ruby
139
+ agent = TurnKit::Agent.new(
140
+ name: "custom",
141
+ instructions: "Answer in JSON.",
142
+ system_prompt: ->(prompt) {
143
+ [
144
+ prompt.agent_section,
145
+ prompt.instructions_section,
146
+ "Return only valid JSON."
147
+ ].compact.join("\n\n")
148
+ }
149
+ )
150
+ ```
151
+
102
152
  Delegate to sub-agents:
103
153
 
104
154
  ```ruby
@@ -126,7 +176,6 @@ bin/rails db:migrate
126
176
  Configure Rails:
127
177
 
128
178
  ```ruby
129
- # config/initializers/turnkit.rb
130
179
  TurnKit.store = TurnKit::ActiveRecordStore.new
131
180
  TurnKit.default_model = "claude-sonnet-4-5"
132
181
  TurnKit.timeout = 300
@@ -140,7 +189,7 @@ TurnKit.reconcile_stale!
140
189
 
141
190
  ## Options
142
191
 
143
- Configure defaults globally:
192
+ Configure defaults:
144
193
 
145
194
  ```ruby
146
195
  TurnKit.default_model = "claude-sonnet-4-5"
@@ -165,18 +214,18 @@ agent = TurnKit::Agent.new(
165
214
 
166
215
  | Option | Description |
167
216
  | --- | --- |
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. |
217
+ | `default_model` | Set the default model. |
218
+ | `client` | Set the model client. |
219
+ | `store` | Set the conversation store. |
220
+ | `max_iterations` | Limit model calls per turn. |
221
+ | `timeout` | Limit seconds per root turn. |
222
+ | `max_depth` | Limit sub-agent nesting. |
223
+ | `max_tool_executions` | Limit tool calls per root turn. |
224
+ | `cost_limit` | Limit cost per root turn. |
176
225
 
177
226
  ## Contributing
178
227
 
179
- Open bug reports and pull requests on GitHub:
228
+ Report bugs and open pull requests on GitHub:
180
229
 
181
230
  ```text
182
231
  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.0"
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,7 +1,7 @@
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
@@ -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