ruby_llm-providers-copilot 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ca732cce7361f198dca083229e205cd488da6e9dc39e8f37c85db61e714b86b2
4
+ data.tar.gz: db1ca71d41292caa7d029ab0441215e6c9f9a844986d0114bbcda160f5205e3e
5
+ SHA512:
6
+ metadata.gz: b1aa9714488725e3ea95fd671e4cd89f7f20102c03c2d3299f25969143d5f6702ac131aeee9b27b9ee674a5de8e5982bbfcf716f3c2ffb09fe70716de95cee9f
7
+ data.tar.gz: cfca36186324fb9eddd04335406433689805c3b74c9ddf32e2a19d1ad2a15921eaaaa54020852606cf8a30264ab9da67e2b2130ba70551a0e8a3f2a12f7ea9f1
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-11-06
4
+
5
+ - Initial release: the GitHub Copilot CLI as a RubyLLM provider with resumable
6
+ sessions, streaming, reasoning effort and attachment notes.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # ruby_llm-providers-copilot
2
+
3
+ A [RubyLLM](https://rubyllm.com) provider that uses the **GitHub Copilot CLI
4
+ agent** as the model. Each completion runs `copilot` in non-interactive mode,
5
+ pipes the prompt on stdin and reads the JSON event stream from stdout.
6
+
7
+ This is deliberately *not* an isolated stand-in for a model API. The agent
8
+ keeps its own tools, MCP servers and instructions, and it works in the
9
+ current directory. Multi-turn chats resume the same Copilot session, so the
10
+ agent keeps its context between turns.
11
+
12
+ ```ruby
13
+ require 'ruby_llm/providers/copilot'
14
+
15
+ chat = RubyLLM.chat(model: 'default', provider: :copilot)
16
+ chat.ask('What does the Rakefile in this directory do?')
17
+ chat.ask('Add a `lint` task to it') { |chunk| print chunk.content }
18
+ ```
19
+
20
+ With [aia](https://github.com/madbomber/aia) the provider is picked from the
21
+ model prefix:
22
+
23
+ ```bash
24
+ aia --model copilot/default review_prompt
25
+ aia --model claude_code/sonnet=implementer,copilot/auto=reviewer --consensus review
26
+ ```
27
+
28
+ ## Requirements
29
+
30
+ - Ruby 3.2 or newer
31
+ - RubyLLM 2.1 or newer
32
+ - The GitHub Copilot CLI on your PATH (`copilot --version`), logged in with
33
+ `copilot login`. No API key is read by this gem.
34
+
35
+ ## Installation
36
+
37
+ ```ruby
38
+ gem 'ruby_llm-providers-copilot', require: 'ruby_llm/providers/copilot'
39
+ ```
40
+
41
+ The provider registers itself as `:copilot` when required.
42
+
43
+ ## Models
44
+
45
+ The part after the slash goes straight to `copilot --model`. The id `default`
46
+ (or an empty id) sends no `--model` flag; `auto` lets Copilot route.
47
+
48
+ ## Sessions
49
+
50
+ Copilot lets the caller choose the session id, so every new session gets a
51
+ UUID from this gem (`--session-id`). When the transcript minus the newest
52
+ message matches what that session already saw, only the new message is sent
53
+ with `--resume <id>`. Any other shape (rewound or edited history, a cleared
54
+ chat, a changed system prompt) starts a fresh session with the conversation
55
+ replayed as a tagged transcript.
56
+
57
+ ```ruby
58
+ chat.with_provider_options(copilot: { resume: false }) # fresh session every turn
59
+ chat.with_provider_options(copilot: { session_id: 'uuid' }) # continue a known session
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Global options are `RubyLLM.config.copilot_*`; the same keys can be set per
65
+ chat with `with_provider_options(copilot: { ... })`.
66
+
67
+ | Option | Default | Meaning |
68
+ |---|---|---|
69
+ | `command` | `copilot` | CLI executable |
70
+ | `cwd` | current directory | Directory the agent works in |
71
+ | `timeout` | `600` | Seconds before the process group is killed; `nil` or `0` disables |
72
+ | `allow_all_tools` | `false` | Pass `--allow-all-tools` so the agent runs tools without confirmation |
73
+ | `args` | none | Extra CLI arguments, array or shell string (`--add-dir`, `--allow-tool`, `--agent`, ...) |
74
+ | `env` | none | Extra environment variables for the process |
75
+ | `resume` | `true` | Reuse the session across turns |
76
+ | `session_id` | none | Per-request only: resume this session id |
77
+
78
+ ## What is supported
79
+
80
+ | Feature | How |
81
+ |---|---|
82
+ | Text, system prompts, multi-turn | Copilot has no system-prompt flag, so instructions lead the first prompt in an `<instructions>` block; see Sessions |
83
+ | Streaming | `assistant.message_delta` events become chunks |
84
+ | `with_thinking(effort:)` | `--reasoning-effort` (none, minimal, low, medium, high, xhigh, max) |
85
+ | Token counts | Summed from the session usage checkpoint: input, output, cache read and write |
86
+ | Attachments | Noted in the prompt as `[Attached file 'name': path]`; the agent opens local files itself |
87
+ | RubyLLM tools (`with_tools`) | **Not supported**: raises `UnsupportedFeatureError`. The provider class answers `accepts_tools?` with `false` so hosts such as aia can skip attaching tools |
88
+ | Structured output (`with_schema`) | **Not supported**: the CLI has no schema option |
89
+ | temperature, max tokens | Ignored (logged at debug level) |
90
+
91
+ ## Safety
92
+
93
+ Without `allow_all_tools` the CLI applies its own confirmation rules, and in
94
+ non-interactive mode an unapproved tool simply is not run. Turn it on only in
95
+ a directory you are happy to let the agent change.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ bundle install
101
+ asgard test_verbose # offline: the CLI is replaced by a fake runner
102
+ asgard quality # tests, rubocop, flog, flay, reek, ...
103
+ ```
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Copilot < Provider
6
+ # Builds the argv for one non-interactive `copilot` run. The prompt is
7
+ # piped on stdin; copilot has no system-prompt flag, so instructions are
8
+ # folded into the prompt by the provider.
9
+ class Command
10
+ EFFORTS = %w[none minimal low medium high xhigh max].freeze
11
+
12
+ def initialize(settings)
13
+ @settings = settings
14
+ end
15
+
16
+ # @param model [String, nil] model id; "default" means the CLI's own choice
17
+ # @param session_id [String] id for a new session (copilot lets the caller pick it)
18
+ # @param resume_id [String, nil] session id to continue instead
19
+ # @param effort [String, nil] one of EFFORTS
20
+ # @return [Array<String>]
21
+ def build(session_id:, model: nil, resume_id: nil, effort: nil)
22
+ argv = [@settings.command, '--silent', '--output-format', 'json']
23
+ argv.push('--model', model.to_s) if self.class.named?(model)
24
+ argv.push(*(resume_id ? ['--resume', resume_id] : ['--session-id', session_id]))
25
+ argv.push('--reasoning-effort', effort) if effort
26
+ argv << '--allow-all-tools' if @settings.allow_all_tools?
27
+ argv.concat(@settings.args)
28
+ end
29
+
30
+ class << self
31
+ def named?(model)
32
+ !(model.nil? || model.to_s.empty? || model.to_s == DEFAULT_MODEL)
33
+ end
34
+
35
+ # Instructions have no flag of their own, so they lead the prompt
36
+ # of a new session.
37
+ def prompt_with_instructions(prompt, system_prompt)
38
+ return prompt if system_prompt.nil? || system_prompt.strip.empty?
39
+
40
+ "<instructions>\n#{system_prompt}\n</instructions>\n\n#{prompt}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Copilot < Provider
6
+ # Raised when the copilot CLI cannot be run, exits without a result, or
7
+ # reports an error of its own.
8
+ class Error < RubyLLM::Error; end
9
+
10
+ # Raised when the CLI runs past the configured timeout and is killed.
11
+ class TimeoutError < Error; end
12
+
13
+ # Raised for RubyLLM features the CLI cannot honour, such as tools.
14
+ class UnsupportedFeatureError < Error; end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Copilot < Provider
8
+ # Reads `copilot --output-format json` events line by line. Message
9
+ # deltas are streamed as Chunks, the last assistant.message is the
10
+ # reply, and session.usage_checkpoint carries the token usage.
11
+ class Response
12
+ USAGE_KEYS = {
13
+ 'inputTokens' => :input, 'outputTokens' => :output,
14
+ 'cacheReadTokens' => :cache_read, 'cacheWriteTokens' => :cache_write, 'reasoningTokens' => :thinking
15
+ }.freeze
16
+
17
+ attr_reader :session_id, :model_id
18
+
19
+ def initialize(model:, session_id:)
20
+ @model = model
21
+ @session_id = session_id
22
+ @content = nil
23
+ @usage = nil
24
+ @error = nil
25
+ end
26
+
27
+ # @param line [String] one line of stdout
28
+ # @yield [RubyLLM::Chunk] a streamed piece of assistant text
29
+ # @return [Hash, nil] the parsed event, or nil for non-JSON lines
30
+ def ingest(line)
31
+ event = self.class.parse(line)
32
+ return nil unless event
33
+
34
+ data = event['data'] || {}
35
+ case event['type']
36
+ when 'assistant.message_delta'
37
+ text = data['deltaContent']
38
+ yield chunk(text) if text && !text.empty? && block_given?
39
+ when 'assistant.message'
40
+ @content = data['content'].to_s
41
+ @model_id = data['model']
42
+ when 'session.usage_checkpoint' then @usage = self.class.usage_from(data)
43
+ when 'error', 'session.error', 'assistant.error' then @error = (data['message'] || data['error'] || data).to_s
44
+ end
45
+ event
46
+ end
47
+
48
+ # @param run [Runner::Result, nil] used for diagnostics when no reply arrived
49
+ # @return [RubyLLM::Message]
50
+ def message(run = nil)
51
+ raise Error, "copilot failed: #{@error}" if @error
52
+
53
+ raise_without_result!(run) if @content.nil?
54
+
55
+ Message.new(
56
+ role: :assistant,
57
+ content: @content,
58
+ model: @model,
59
+ tokens: Tokens.new(**(@usage || {})),
60
+ finish_reason: :stop,
61
+ raw: { 'session_id' => @session_id, 'model' => @model_id, 'usage' => @usage }
62
+ )
63
+ end
64
+
65
+ class << self
66
+ def parse(line)
67
+ text = line.to_s.strip
68
+ return nil unless text.start_with?('{')
69
+
70
+ JSON.parse(text)
71
+ rescue JSON::ParserError
72
+ RubyLLM.logger.debug { "copilot: non-JSON output #{text.inspect}" }
73
+ nil
74
+ end
75
+
76
+ # Sums the per-model usage in a usage checkpoint into Tokens kwargs.
77
+ def usage_from(data)
78
+ metrics = data.dig('accountingSnapshot', 'modelMetrics') || {}
79
+ totals = Hash.new(0)
80
+ metrics.each_value do |entry|
81
+ (entry['usage'] || {}).each do |key, value|
82
+ field = USAGE_KEYS[key]
83
+ totals[field] += value.to_i if field
84
+ end
85
+ end
86
+ totals.empty? ? nil : totals
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def raise_without_result!(run)
93
+ status = run&.status&.exitstatus
94
+ stderr = run&.stderr.to_s.strip
95
+ raise Error,
96
+ "copilot exited#{" (#{status})" if status} without a reply#{": #{stderr}" unless stderr.empty?}"
97
+ end
98
+
99
+ def chunk(text)
100
+ Chunk.new(role: :assistant, content: text, model: @model)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Copilot < Provider
8
+ # Spawns the CLI, feeds it stdin, and yields each stdout line on the
9
+ # caller's thread as it arrives. The process group is killed when the
10
+ # timeout passes.
11
+ class Runner
12
+ # What the process produced: full stdout, full stderr, and its exit status.
13
+ Result = Data.define(:stdout, :stderr, :status)
14
+ GRACE_SECONDS = 2
15
+ EOF = Object.new.freeze
16
+
17
+ # @param argv [Array<String>] command and arguments
18
+ # @param stdin [String] text written to the process's stdin
19
+ # @param cwd [String, nil] working directory (default: Dir.pwd)
20
+ # @param timeout [Numeric, nil] seconds before the process is killed
21
+ # @param env [Hash] extra environment variables
22
+ # @yield [String] each line of stdout
23
+ # @return [Result]
24
+ def run(argv, stdin: '', cwd: nil, timeout: nil, env: {}, &block)
25
+ Open3.popen3(env.to_h, *argv, chdir: cwd || Dir.pwd, pgroup: true) do |input, output, error, wait|
26
+ collect(input, output, error, wait, stdin:, timeout:, &block)
27
+ rescue TimeoutError
28
+ terminate(wait)
29
+ raise TimeoutError, "#{argv.first} did not finish within #{timeout}s"
30
+ end
31
+ rescue Errno::ENOENT => e
32
+ raise Error, "cannot run #{argv.first.inspect}: #{e.message}"
33
+ end
34
+
35
+ private
36
+
37
+ # Drives one spawned process: feeds stdin and reads stderr on their own
38
+ # threads, pumps stdout through a queue so each line can be yielded
39
+ # before the process exits, and assembles the Result once it does.
40
+ def collect(input, output, error, wait, stdin:, timeout:)
41
+ feeder = Thread.new { feed(input, stdin) }
42
+ errors = Thread.new { error.read }
43
+ queue = Queue.new
44
+ reader = Thread.new { pump(output, queue) }
45
+ lines = []
46
+ drain(queue, deadline(timeout)) do |line|
47
+ lines << line
48
+ yield line if block_given?
49
+ end
50
+ [feeder, reader].each(&:join)
51
+ Result.new(stdout: lines.join, stderr: errors.value.to_s, status: wait.value)
52
+ end
53
+
54
+ def deadline(timeout)
55
+ timeout && (now + timeout)
56
+ end
57
+
58
+ def feed(input, text)
59
+ input.binmode
60
+ input.write(text.to_s)
61
+ rescue Errno::EPIPE, IOError
62
+ nil
63
+ ensure
64
+ input.close unless input.closed?
65
+ end
66
+
67
+ def pump(output, queue)
68
+ output.each_line { |line| queue << line }
69
+ ensure
70
+ queue << EOF
71
+ end
72
+
73
+ # Pops lines until EOF, raising TimeoutError once the deadline passes.
74
+ def drain(queue, deadline)
75
+ loop do
76
+ remaining = deadline && [deadline - now, 0].max
77
+ line = remaining ? queue.pop(timeout: remaining) : queue.pop
78
+ raise TimeoutError if line.nil?
79
+ break if line.equal?(EOF)
80
+
81
+ yield line
82
+ end
83
+ end
84
+
85
+ def terminate(wait)
86
+ pgid = -wait.pid
87
+ Process.kill('TERM', pgid)
88
+ return if wait.join(GRACE_SECONDS)
89
+
90
+ Process.kill('KILL', pgid)
91
+ wait.join
92
+ rescue Errno::ESRCH, Errno::ECHILD
93
+ nil
94
+ end
95
+
96
+ def now
97
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Copilot < Provider
6
+ # Decides, per completion, whether to resume the CLI session that
7
+ # answered the previous turn or to start a new one. A session is resumed
8
+ # only when the transcript RubyLLM hands over, minus the newest message,
9
+ # is exactly what that session already saw. A rewound, edited or cleared
10
+ # history gets a fresh session replayed from the whole transcript.
11
+ class Session
12
+ Plan = Data.define(:messages, :resume_id) do
13
+ def resume?
14
+ !resume_id.nil?
15
+ end
16
+ end
17
+
18
+ attr_reader :id
19
+
20
+ def initialize
21
+ reset!
22
+ end
23
+
24
+ # @param messages [Array<RubyLLM::Message>] the full RubyLLM transcript
25
+ # @param session_id [String, nil] a caller-chosen session to continue
26
+ # @param resume [Boolean] false forces a fresh session every time
27
+ # @return [Plan] the messages to send and the session to resume, if any
28
+ def plan(messages, session_id: nil, resume: true)
29
+ raise ArgumentError, 'no messages to send' if messages.empty?
30
+ return Plan.new(messages: [messages.last], resume_id: session_id) if session_id
31
+ return Plan.new(messages: messages, resume_id: nil) unless resume && continues?(messages)
32
+
33
+ Plan.new(messages: [messages.last], resume_id: @id)
34
+ end
35
+
36
+ # Remember which session answered, and what it has now seen.
37
+ def record(messages, reply, id:)
38
+ @id = id
39
+ @fingerprint = id ? Transcript.fingerprint(messages + [reply]) : nil
40
+ end
41
+
42
+ def reset!
43
+ @id = nil
44
+ @fingerprint = nil
45
+ end
46
+
47
+ private
48
+
49
+ def continues?(messages)
50
+ return false unless @id && @fingerprint && messages.size > 1
51
+
52
+ Transcript.fingerprint(messages[0...-1]) == @fingerprint
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Copilot < Provider
8
+ # The settings for one completion. Each value comes from, in order of
9
+ # precedence, chat.with_provider_options(copilot: {...}), the
10
+ # RubyLLM.config.copilot_* option, then the default here.
11
+ class Settings
12
+ DEFAULT_COMMAND = 'copilot'
13
+ DEFAULT_TIMEOUT = 600
14
+
15
+ attr_reader :command, :cwd, :timeout, :args, :env, :session_id
16
+
17
+ def initialize(config, provider_options = {})
18
+ opts = self.class.namespaced(provider_options)
19
+ @command = opts.fetch(:command) { config.copilot_command } || DEFAULT_COMMAND
20
+ @cwd = opts.fetch(:cwd) { config.copilot_cwd }
21
+ @timeout = self.class.positive_or_nil(opts.fetch(:timeout) { timeout_default(config) })
22
+ @allow_all_tools = opts.fetch(:allow_all_tools) { config.copilot_allow_all_tools }
23
+ @args = self.class.split_args(opts.fetch(:args) { config.copilot_args })
24
+ @env = self.class.stringify(opts.fetch(:env) { config.copilot_env })
25
+ @session_id = opts[:session_id]
26
+ @resume = opts.fetch(:resume) { config.copilot_resume.nil? || config.copilot_resume }
27
+ end
28
+
29
+ # True when the agent may run every tool without confirmation.
30
+ def allow_all_tools?
31
+ @allow_all_tools ? true : false
32
+ end
33
+
34
+ # False when every completion must start a fresh CLI session.
35
+ def resume?
36
+ @resume ? true : false
37
+ end
38
+
39
+ class << self
40
+ # The copilot: sub-hash of provider_options with symbol keys.
41
+ def namespaced(provider_options)
42
+ return {} unless provider_options.is_a?(Hash)
43
+
44
+ raw = provider_options[:copilot] || provider_options['copilot'] || {}
45
+ raise ArgumentError, 'provider_options[:copilot] must be a Hash' unless raw.is_a?(Hash)
46
+
47
+ raw.transform_keys(&:to_sym)
48
+ end
49
+
50
+ # A positive number of seconds, or nil for "no timeout".
51
+ def positive_or_nil(value)
52
+ return nil if value.nil?
53
+
54
+ number = Float(value)
55
+ number.positive? ? number : nil
56
+ end
57
+
58
+ # Extra CLI arguments given as an array or a shell-style string.
59
+ def split_args(value)
60
+ case value
61
+ when nil then []
62
+ when String then Shellwords.split(value)
63
+ else Array(value).map(&:to_s)
64
+ end
65
+ end
66
+
67
+ def stringify(value)
68
+ (value || {}).to_h.to_h { |key, item| [key.to_s, item.to_s] }
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def timeout_default(config)
75
+ config.copilot_timeout.nil? ? DEFAULT_TIMEOUT : config.copilot_timeout
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class Copilot < Provider
9
+ # Renders RubyLLM messages into the text the CLI reads from stdin, and
10
+ # fingerprints transcripts so Session can tell whether a CLI session
11
+ # already holds the earlier turns.
12
+ class Transcript
13
+ REPLAY_PREAMBLE = "The conversation so far follows. Continue it by writing the assistant's next reply " \
14
+ "to the last turn. Do not repeat the tags.\n\n"
15
+ EMPTY = '(empty message)'
16
+
17
+ class << self
18
+ def fingerprint(messages)
19
+ Digest::SHA256.hexdigest(JSON.generate(messages.map { |message| summary(message) }))
20
+ end
21
+
22
+ def summary(message)
23
+ {
24
+ role: message.role.to_s,
25
+ content: message.content.to_s,
26
+ tool_call_id: message.tool_call_id,
27
+ tool_calls: tool_calls(message).map { |call| [call.id, call.name, call.arguments] },
28
+ attachments: attachments(message).map { |attachment| attachment_note(attachment) }
29
+ }
30
+ end
31
+
32
+ # The message text plus a note per attachment naming its location;
33
+ # the agent can open local files with its own tools.
34
+ def text_of(message)
35
+ parts = [message.content.to_s]
36
+ attachments(message).each { |attachment| parts << attachment_note(attachment) }
37
+ parts.reject(&:empty?).join("\n").strip
38
+ end
39
+
40
+ def attachment_note(attachment)
41
+ source = attachment.respond_to?(:source) ? attachment.source : attachment
42
+ name = attachment.respond_to?(:filename) ? attachment.filename : nil
43
+ location = source.respond_to?(:to_path) ? source.to_path : source.to_s
44
+ "[Attached file#{" '#{name}'" if name}: #{location}]"
45
+ end
46
+
47
+ def attachments(message)
48
+ message.respond_to?(:attachments) ? Array(message.attachments) : []
49
+ end
50
+
51
+ def tool_calls(message)
52
+ calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
53
+ calls.respond_to?(:values) ? calls.values : Array(calls)
54
+ end
55
+ end
56
+
57
+ def initialize(messages)
58
+ @system, @turns = messages.partition { |message| message.role == :system }
59
+ end
60
+
61
+ # All system messages joined, or nil when there are none.
62
+ def system_prompt
63
+ text = @system.map { |message| self.class.text_of(message) }.reject(&:empty?).join("\n\n")
64
+ text.empty? ? nil : text
65
+ end
66
+
67
+ # A lone user turn goes through as-is; anything longer becomes a
68
+ # tagged replay of the conversation.
69
+ def prompt
70
+ return EMPTY if @turns.empty?
71
+ return single_turn if @turns.one? && @turns.first.role == :user
72
+
73
+ REPLAY_PREAMBLE + @turns.map { |message| turn(message) }.join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ def single_turn
79
+ text = self.class.text_of(@turns.first)
80
+ text.empty? ? EMPTY : text
81
+ end
82
+
83
+ def turn(message)
84
+ body = self.class.text_of(message)
85
+ case message.role
86
+ when :tool then "<tool_result id=\"#{message.tool_call_id}\">\n#{body}\n</tool_result>"
87
+ when :assistant then "<assistant>\n#{body}#{tool_calls_text(message)}\n</assistant>"
88
+ else "<user>\n#{body}\n</user>"
89
+ end
90
+ end
91
+
92
+ def tool_calls_text(message)
93
+ self.class.tool_calls(message).map do |call|
94
+ "\n<tool_call id=\"#{call.id}\" name=\"#{call.name}\">#{JSON.generate(call.arguments)}</tool_call>"
95
+ end.join
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Version of the ruby_llm-providers-copilot gem. Kept in its own file
6
+ # so the gemspec can read the literal without loading the provider.
7
+ class Copilot < Provider
8
+ VERSION = '0.0.1'
9
+
10
+ def self.version
11
+ VERSION
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'securerandom'
5
+ require 'ruby_llm/providers/copilot/version'
6
+ require 'ruby_llm/providers/copilot/errors'
7
+ require 'ruby_llm/providers/copilot/settings'
8
+ require 'ruby_llm/providers/copilot/command'
9
+ require 'ruby_llm/providers/copilot/transcript'
10
+ require 'ruby_llm/providers/copilot/session'
11
+ require 'ruby_llm/providers/copilot/runner'
12
+ require 'ruby_llm/providers/copilot/response'
13
+
14
+ module RubyLLM
15
+ module Providers
16
+ # Copilot: the GitHub Copilot CLI agent as a RubyLLM provider.
17
+ #
18
+ # Each completion spawns `copilot` in non-interactive mode, pipes the
19
+ # prompt on stdin and reads its JSON event stream from stdout. The agent
20
+ # keeps its own tools, MCP servers and instructions and works in the
21
+ # current directory (or config.copilot_cwd). Multi-turn chats resume the
22
+ # same session (--resume) as long as the RubyLLM transcript still matches
23
+ # what that session has seen; otherwise a fresh session is started from
24
+ # the whole transcript.
25
+ #
26
+ # require 'ruby_llm/providers/copilot'
27
+ # chat = RubyLLM.chat(model: 'default', provider: :copilot)
28
+ # chat.ask('Summarise the README in this directory')
29
+ class Copilot < Provider
30
+ DEFAULT_MODEL = 'default'
31
+ MODELS = %w[default auto].freeze
32
+ CAPABILITIES = %w[streaming].freeze
33
+
34
+ # Satisfies RubyLLM's protocol lookups; every request goes through
35
+ # #complete below, never through a wire protocol.
36
+ protocol :copilot, RubyLLM::Protocol
37
+
38
+ class << self
39
+ # Anything responding to #run like Runner; tests inject fakes here.
40
+ attr_accessor :runner
41
+
42
+ def configuration_options
43
+ %i[copilot_command copilot_cwd copilot_timeout copilot_allow_all_tools copilot_args copilot_env
44
+ copilot_resume]
45
+ end
46
+
47
+ def configuration_requirements
48
+ []
49
+ end
50
+
51
+ def local?
52
+ true
53
+ end
54
+
55
+ # Any id goes straight to --model, so there is no catalog to check.
56
+ def assume_models_exist?
57
+ true
58
+ end
59
+
60
+ # The agent runs its own tools; RubyLLM tools cannot be handed to it.
61
+ # Hosts such as aia check this before attaching tools or MCP servers.
62
+ def accepts_tools?
63
+ false
64
+ end
65
+
66
+ def display_name
67
+ 'GitHub Copilot CLI'
68
+ end
69
+
70
+ def model_id(model)
71
+ model.respond_to?(:id) ? model.id : model.to_s
72
+ end
73
+
74
+ def effort_for(thinking)
75
+ return nil unless thinking.respond_to?(:effort) && thinking.effort
76
+
77
+ value = thinking.effort.to_s
78
+ Command::EFFORTS.include?(value) ? value : nil
79
+ end
80
+
81
+ def new_session_id
82
+ SecureRandom.uuid
83
+ end
84
+ end
85
+
86
+ attr_reader :session
87
+
88
+ def initialize(config)
89
+ super
90
+ @session = Session.new
91
+ end
92
+
93
+ # Never contacted; the transport layer only needs a base URL.
94
+ def api_base
95
+ 'cli://copilot'
96
+ end
97
+
98
+ # Messages go to the CLI as text, so there is nothing to upload or
99
+ # convert beforehand.
100
+ def preprocess_messages(messages, **)
101
+ messages
102
+ end
103
+
104
+ def discard_missing_uploads(_messages, _error, **)
105
+ []
106
+ end
107
+
108
+ # :reek:LongParameterList -- the keyword set is RubyLLM's Provider#complete contract
109
+ def complete(messages, model:, tools: {}, provider_options: {}, schema: nil, thinking: nil,
110
+ temperature: nil, max_output_tokens: nil, before_request: [], **, &block)
111
+ reject_tools!(tools)
112
+ reject_schema!(schema)
113
+ log_ignored(temperature:, max_output_tokens:)
114
+ settings = Settings.new(@config, provider_options)
115
+ plan = session.plan(messages, session_id: settings.session_id, resume: settings.resume?)
116
+ transcript = Transcript.new(plan.messages)
117
+ session_id = plan.resume_id || self.class.new_session_id
118
+ argv = build_argv(settings, model, plan, session_id, thinking)
119
+ prompt = if plan.resume?
120
+ transcript.prompt
121
+ else
122
+ Command.prompt_with_instructions(transcript.prompt,
123
+ transcript.system_prompt)
124
+ end
125
+ before_request.each { |hook| hook.call({ argv: argv, prompt: prompt }) }
126
+ response = Response.new(model: model, session_id: session_id)
127
+ run = runner.run(argv, stdin: prompt, cwd: settings.cwd, timeout: settings.timeout,
128
+ env: settings.env) do |line|
129
+ response.ingest(line) { |chunk| block&.call(chunk) }
130
+ end
131
+ message = response.message(run)
132
+ session.record(messages, message, id: response.session_id)
133
+ message
134
+ end
135
+
136
+ # A static catalog: "default" sends no --model flag, "auto" lets
137
+ # Copilot route; any other id is passed through to --model.
138
+ def list_models
139
+ MODELS.map { |id| static_model(id) }
140
+ end
141
+
142
+ private
143
+
144
+ def runner
145
+ self.class.runner || Runner.new
146
+ end
147
+
148
+ def build_argv(settings, model, plan, session_id, thinking)
149
+ Command.new(settings).build(
150
+ model: self.class.model_id(model),
151
+ session_id: session_id,
152
+ resume_id: plan.resume_id,
153
+ effort: self.class.effort_for(thinking)
154
+ )
155
+ end
156
+
157
+ def static_model(id)
158
+ Model.new(
159
+ id: id,
160
+ name: "GitHub Copilot CLI (#{id})",
161
+ provider: slug,
162
+ capabilities: CAPABILITIES,
163
+ modalities: { input: %w[text], output: %w[text] },
164
+ metadata: { source: 'ruby_llm-providers-copilot' }
165
+ )
166
+ end
167
+
168
+ def reject_tools!(tools)
169
+ return if tools.nil? || tools.empty?
170
+
171
+ raise UnsupportedFeatureError,
172
+ 'RubyLLM tools cannot be handed to the copilot CLI; the agent brings its own tools. ' \
173
+ 'Remove with_tools or choose another provider.'
174
+ end
175
+
176
+ def reject_schema!(schema)
177
+ return if schema.nil?
178
+
179
+ raise UnsupportedFeatureError, 'the copilot CLI has no structured-output option; remove with_schema'
180
+ end
181
+
182
+ def log_ignored(**options)
183
+ ignored = options.compact.keys
184
+ return if ignored.empty?
185
+
186
+ RubyLLM.logger.debug { "copilot: ignoring #{ignored.join(', ')} (not supported by the copilot CLI)" }
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ RubyLLM::Provider.register :copilot, RubyLLM::Providers::Copilot unless RubyLLM::Provider.resolve(:copilot)
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-providers-copilot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ -
17
+ - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: "2.1"
20
+ -
21
+ - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: "3"
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ -
29
+ - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: "2.1"
32
+ -
33
+ - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: "3"
36
+ description: "Adds a copilot provider to RubyLLM. Prompts are piped to the GitHub Copilot CLI's stdin and its JSON events are read back from stdout, so the agent (with its own tools, MCP servers and resumable sessions) can be used wherever RubyLLM expects a model, e.g. `--model copilot/default` in aia."
37
+ email:
38
+ - dvanhoozer@gmail.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - lib/ruby_llm/providers/copilot.rb
47
+ - lib/ruby_llm/providers/copilot/command.rb
48
+ - lib/ruby_llm/providers/copilot/errors.rb
49
+ - lib/ruby_llm/providers/copilot/response.rb
50
+ - lib/ruby_llm/providers/copilot/runner.rb
51
+ - lib/ruby_llm/providers/copilot/session.rb
52
+ - lib/ruby_llm/providers/copilot/settings.rb
53
+ - lib/ruby_llm/providers/copilot/transcript.rb
54
+ - lib/ruby_llm/providers/copilot/version.rb
55
+ homepage: "https://github.com/madbomber/ruby_llm-providers-copilot"
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: "https://github.com/madbomber/ruby_llm-providers-copilot"
60
+ source_code_uri: "https://github.com/madbomber/ruby_llm-providers-copilot"
61
+ changelog_uri: "https://github.com/madbomber/ruby_llm-providers-copilot/blob/main/CHANGELOG.md"
62
+ bug_tracker_uri: "https://github.com/madbomber/ruby_llm-providers-copilot/issues"
63
+ rubygems_mfa_required: "true"
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ -
70
+ - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: "3.2"
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ -
76
+ - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: "0"
79
+ requirements: []
80
+ rubygems_version: 4.1.0.beta3
81
+ specification_version: 4
82
+ summary: RubyLLM provider that drives the GitHub Copilot CLI agent in non-interactive mode.
83
+ test_files: []