yorishiro 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.yorishirorc.sample +51 -0
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +134 -8
  5. data/docs/en/README.md +97 -2
  6. data/docs/ja/README.md +97 -2
  7. data/docs/ja/how_to_create_skills.md +0 -0
  8. data/docs/ja/ollama_setup.md +148 -0
  9. data/lib/yorishiro/cli.rb +474 -93
  10. data/lib/yorishiro/compactor.rb +53 -0
  11. data/lib/yorishiro/configuration.rb +69 -1
  12. data/lib/yorishiro/conversation.rb +117 -0
  13. data/lib/yorishiro/diff.rb +92 -0
  14. data/lib/yorishiro/hooks.rb +64 -0
  15. data/lib/yorishiro/input_history.rb +49 -0
  16. data/lib/yorishiro/mcp/server_manager.rb +4 -4
  17. data/lib/yorishiro/provider/anthropic.rb +37 -8
  18. data/lib/yorishiro/provider/base.rb +20 -4
  19. data/lib/yorishiro/provider/ollama.rb +70 -45
  20. data/lib/yorishiro/provider/open_ai.rb +21 -5
  21. data/lib/yorishiro/session_resume.rb +73 -0
  22. data/lib/yorishiro/session_store.rb +168 -0
  23. data/lib/yorishiro/skill.rb +11 -0
  24. data/lib/yorishiro/skill_loader.rb +53 -0
  25. data/lib/yorishiro/sub_agent.rb +143 -0
  26. data/lib/yorishiro/tool.rb +7 -0
  27. data/lib/yorishiro/tool_result_cap.rb +31 -0
  28. data/lib/yorishiro/tools/edit_file.rb +87 -0
  29. data/lib/yorishiro/tools/execute_command.rb +8 -0
  30. data/lib/yorishiro/tools/exit_plan_mode.rb +41 -0
  31. data/lib/yorishiro/tools/grep.rb +118 -0
  32. data/lib/yorishiro/tools/list_files.rb +10 -8
  33. data/lib/yorishiro/tools/read_file.rb +27 -9
  34. data/lib/yorishiro/tools/task.rb +74 -0
  35. data/lib/yorishiro/tools/write_file.rb +9 -0
  36. data/lib/yorishiro/version.rb +1 -1
  37. data/lib/yorishiro.rb +13 -1
  38. metadata +33 -5
  39. data/lib/yorishiro/mcp/stdio_transport.rb +0 -85
@@ -4,9 +4,13 @@ module Yorishiro
4
4
  module Provider
5
5
  class Ollama < Base
6
6
  DEFAULT_BASE_URL = "http://localhost:11434"
7
+ DEFAULT_NUM_CTX = 8192
8
+ OUTPUT_TOKEN_RESERVE = 2048
9
+ MIN_CONTEXT_BUDGET = 1024
7
10
 
8
- def initialize(api_key: nil, model: nil, base_url: nil)
11
+ def initialize(api_key: nil, model: nil, base_url: nil, num_ctx: nil)
9
12
  @base_url = base_url || ENV.fetch("OLLAMA_HOST", DEFAULT_BASE_URL)
13
+ @num_ctx = num_ctx
10
14
  super(api_key: api_key || "unused", model: model)
11
15
  end
12
16
 
@@ -22,32 +26,60 @@ module Yorishiro
22
26
  []
23
27
  end
24
28
 
25
- def chat(conversation, tools: [], &block)
29
+ def chat(conversation, tools: [], &)
26
30
  uri = URI("#{@base_url}/api/chat")
27
31
 
28
32
  body = {
29
33
  model: @model_name,
30
- messages: format_messages(conversation.to_api_messages)
34
+ messages: format_messages(conversation.to_api_messages),
35
+ keep_alive: ENV.fetch("OLLAMA_KEEP_ALIVE", "10m"),
36
+ options: { num_ctx: num_ctx },
37
+ stream: true
31
38
  }
32
39
 
33
40
  headers = { "Content-Type" => "application/json" }
34
41
 
35
- if tools.empty?
36
- body[:stream] = true
37
- post_stream(uri, headers: headers, body: body, &block)
38
- else
39
- body[:stream] = false
40
- body[:tools] = format_tools(tools)
41
- post_no_stream(uri, headers: headers, body: body, &block)
42
- end
42
+ body[:tools] = format_tools(tools) unless tools.empty?
43
+
44
+ debug_log("Ollama request", body)
45
+
46
+ @last_meta = {}
47
+ result = post_stream(uri, headers: headers, body: body, &)
48
+ result[:meta] = @last_meta
49
+ result[:usage] = { input: @last_meta[:prompt_eval_count], output: @last_meta[:eval_count] }
50
+ debug_log("Ollama response", result)
51
+ result
52
+ end
53
+
54
+ # Token budget the conversation should be trimmed to before each request.
55
+ # Reserves headroom for the model's output within the configured context window.
56
+ def context_budget_tokens
57
+ [num_ctx - OUTPUT_TOKEN_RESERVE, MIN_CONTEXT_BUDGET].max
43
58
  end
44
59
 
45
60
  private
46
61
 
62
+ # Resolve the Ollama context window (num_ctx). Priority:
63
+ # 1. explicit value from .yorishirorc (constructor argument)
64
+ # 2. OLLAMA_NUM_CTX environment variable
65
+ # 3. DEFAULT_NUM_CTX
66
+ def num_ctx
67
+ return @num_ctx.to_i if @num_ctx
68
+
69
+ env = ENV.fetch("OLLAMA_NUM_CTX", nil)
70
+ return env.to_i if env && !env.empty?
71
+
72
+ DEFAULT_NUM_CTX
73
+ end
74
+
47
75
  def default_model
48
76
  "llama3.1"
49
77
  end
50
78
 
79
+ def read_timeout
80
+ nil # local inference (prompt eval on large inputs) can take arbitrarily long
81
+ end
82
+
51
83
  def format_messages(api_messages)
52
84
  api_messages.map do |msg|
53
85
  if msg[:role] == "tool"
@@ -83,39 +115,6 @@ module Yorishiro
83
115
  end
84
116
  end
85
117
 
86
- def post_no_stream(uri, headers:, body:, &block)
87
- http = Net::HTTP.new(uri.host, uri.port)
88
- http.use_ssl = (uri.scheme == "https")
89
- http.read_timeout = 120
90
-
91
- request = Net::HTTP::Post.new(uri)
92
- headers.each { |k, v| request[k] = v }
93
- request.body = JSON.generate(body)
94
-
95
- debug_log("Ollama request", body)
96
-
97
- response = http.request(request)
98
- handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess)
99
-
100
- data = JSON.parse(response.body)
101
- debug_log("Ollama response", data)
102
- message = data["message"] || {}
103
-
104
- content = message["content"] || ""
105
- block&.call(content) unless content.empty?
106
-
107
- tool_calls = (message["tool_calls"] || []).map do |tc|
108
- func = tc["function"]
109
- {
110
- id: "ollama_#{SecureRandom.hex(8)}",
111
- name: func["name"],
112
- arguments: func["arguments"].is_a?(Hash) ? func["arguments"] : JSON.parse(func["arguments"].to_s)
113
- }
114
- end
115
-
116
- { content: content, tool_calls: tool_calls }
117
- end
118
-
119
118
  def parse_stream(response, tool_calls:, &block)
120
119
  buffer = +""
121
120
 
@@ -125,7 +124,13 @@ module Yorishiro
125
124
  line = buffer.slice!(0, idx + 1).strip
126
125
  next if line.empty?
127
126
 
128
- data = JSON.parse(line)
127
+ data = parse_ndjson_line(line)
128
+ next unless data
129
+
130
+ raise ProviderError, "Ollama error: #{data["error"]}" if data["error"]
131
+
132
+ capture_meta(data)
133
+
129
134
  message = data["message"]
130
135
  next unless message
131
136
 
@@ -146,6 +151,26 @@ module Yorishiro
146
151
  end
147
152
  end
148
153
  end
154
+
155
+ # Parse a single NDJSON line, skipping (rather than crashing on) malformed
156
+ # or partial lines that Ollama can emit under load or on unexpected output.
157
+ def parse_ndjson_line(line)
158
+ JSON.parse(line)
159
+ rescue JSON::ParserError => e
160
+ debug_log("Ollama: skipping malformed NDJSON line", e.message)
161
+ nil
162
+ end
163
+
164
+ # Capture the final-chunk stats so the CLI can detect context truncation
165
+ # (prompt_eval_count approaching num_ctx) and empty responses.
166
+ def capture_meta(data)
167
+ return unless data["done"]
168
+
169
+ @last_meta ||= {}
170
+ %w[done_reason prompt_eval_count eval_count].each do |key|
171
+ @last_meta[key.to_sym] = data[key] if data.key?(key)
172
+ end
173
+ end
149
174
  end
150
175
  end
151
176
  end
@@ -5,15 +5,15 @@ module Yorishiro
5
5
  class OpenAI < Base
6
6
  API_URL = "https://api.openai.com/v1/chat/completions"
7
7
 
8
+ # Chat-completions models compatible with this client, which always
9
+ # streams and sends system messages and tools — the o1/o3 reasoning
10
+ # series rejects parts of that flow, so it is intentionally absent.
8
11
  SUPPORTED_MODELS = %w[
9
12
  gpt-4o
10
13
  gpt-4o-mini
11
14
  gpt-4-turbo
12
15
  gpt-4
13
16
  gpt-3.5-turbo
14
- o1
15
- o1-mini
16
- o3-mini
17
17
  ].freeze
18
18
 
19
19
  def self.supported_models
@@ -26,7 +26,8 @@ module Yorishiro
26
26
  body = {
27
27
  model: @model_name,
28
28
  messages: format_messages(conversation.to_api_messages),
29
- stream: true
29
+ stream: true,
30
+ stream_options: { include_usage: true }
30
31
  }
31
32
  body[:tools] = format_tools(tools) unless tools.empty?
32
33
 
@@ -35,7 +36,10 @@ module Yorishiro
35
36
  "Authorization" => "Bearer #{@api_key}"
36
37
  }
37
38
 
38
- post_stream(uri, headers: headers, body: body, &)
39
+ @last_usage = {}
40
+ result = post_stream(uri, headers: headers, body: body, &)
41
+ result[:usage] = @last_usage
42
+ result
39
43
  end
40
44
 
41
45
  private
@@ -85,6 +89,16 @@ module Yorishiro
85
89
  end
86
90
  end
87
91
 
92
+ # A trailing usage-only chunk (with an empty choices array) carries the
93
+ # token counts when stream_options.include_usage is set.
94
+ def capture_usage(data)
95
+ usage = data["usage"]
96
+ return unless usage
97
+
98
+ @last_usage[:input] = usage["prompt_tokens"]
99
+ @last_usage[:output] = usage["completion_tokens"]
100
+ end
101
+
88
102
  def parse_stream(response, tool_calls:, &block)
89
103
  buffer = +""
90
104
  tool_call_buffers = {}
@@ -96,6 +110,8 @@ module Yorishiro
96
110
  parsed = parse_sse_event(event_str)
97
111
  next unless parsed[:data]
98
112
 
113
+ capture_usage(parsed[:data])
114
+
99
115
  choice = parsed[:data].dig("choices", 0)
100
116
  next unless choice
101
117
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Yorishiro
6
+ # Interactive session-resume flows shared by the --continue/--resume CLI
7
+ # flags and the /resume command. Applies the chosen session to the
8
+ # conversation and returns its id, or nil when nothing was resumed (the
9
+ # caller keeps its current session in that case).
10
+ class SessionResume
11
+ def initialize(store:, conversation:, output:, current_target:)
12
+ @store = store
13
+ @conversation = conversation
14
+ @output = output
15
+ @current_target = current_target # "provider:model" of the running CLI
16
+ end
17
+
18
+ def continue_latest
19
+ apply(@store.latest, missing: "No previous session found. Starting a new one.")
20
+ end
21
+
22
+ def resume_by_id(id)
23
+ apply(@store.load(id), missing: "Session not found: #{id}")
24
+ end
25
+
26
+ def pick
27
+ sessions = @store.list
28
+ if sessions.empty?
29
+ @output.puts "[i] No saved sessions."
30
+ return nil
31
+ end
32
+
33
+ print_list(sessions)
34
+ answer = Reline.readline("Select session [1-#{sessions.length}]: ", false)&.strip
35
+ index = answer.to_i
36
+ unless index.between?(1, sessions.length)
37
+ @output.puts "Cancelled."
38
+ return nil
39
+ end
40
+
41
+ apply(sessions[index - 1])
42
+ end
43
+
44
+ private
45
+
46
+ def apply(session, missing: "Session not found.")
47
+ unless session
48
+ @output.puts "[i] #{missing}"
49
+ return nil
50
+ end
51
+
52
+ unless @store.claim(session[:id])
53
+ @output.puts "[i] Session #{session[:id]} is in use by another process. Starting a new session."
54
+ return nil
55
+ end
56
+
57
+ @conversation.restore_messages!(session[:messages])
58
+
59
+ recorded = "#{session[:provider]}:#{session[:model]}"
60
+ @output.puts "[i] This session was recorded with #{recorded}. Continuing with #{@current_target}." if recorded != @current_target
61
+ @output.puts "[i] Resumed session #{session[:id]} (#{session[:title]}, #{session[:messages].length} messages)"
62
+ session[:id]
63
+ end
64
+
65
+ def print_list(sessions)
66
+ sessions.each_with_index do |session, index|
67
+ stamp = session[:updated_at].to_s[0, 16].tr("T", " ")
68
+ @output.puts "#{(index + 1).to_s.rjust(3)}. [#{stamp}] #{session[:provider]}:#{session[:model]} " \
69
+ "(#{session[:messages].length} msgs) #{session[:title]}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Yorishiro
6
+ # Persists conversations to .yorishiro/sessions/<id>.json under the
7
+ # launch directory so they can be resumed later (--continue / --resume /
8
+ # /resume). Each session is one JSON file rewritten wholesale through an
9
+ # atomic tmp-file rename: compaction rewrites history destructively (an
10
+ # append-only log would need replaying), and the atomic swap means a
11
+ # crash mid-write never corrupts the previously saved state.
12
+ class SessionStore
13
+ DIR_NAME = File.join(".yorishiro", "sessions")
14
+ MAX_SESSIONS = 50
15
+ SCHEMA_VERSION = 1
16
+ TITLE_MAX_LENGTH = 60
17
+
18
+ def initialize(dir: Dir.pwd, max_sessions: MAX_SESSIONS)
19
+ @sessions_dir = File.join(dir, DIR_NAME)
20
+ @max_sessions = max_sessions
21
+ @locks = {}
22
+ end
23
+
24
+ attr_reader :sessions_dir
25
+
26
+ # Take exclusive ownership of a session id for this process, so a second
27
+ # `yorishiro` resuming the same session in the same directory doesn't
28
+ # write over this one's history (last-writer-wins data loss). Returns
29
+ # true when acquired (or already held by us), false when another live
30
+ # process owns it. The advisory lock is released automatically on
31
+ # process exit. Filesystems without flock fail open to preserve the
32
+ # single-user behaviour.
33
+ def claim(id)
34
+ return true if @locks.key?(id)
35
+
36
+ FileUtils.mkdir_p(@sessions_dir)
37
+ # Held open for the process lifetime so the advisory lock persists;
38
+ # the block form would close it and release the lock immediately.
39
+ lock = File.open(lock_path_for(id), File::CREAT | File::RDWR, 0o644) # rubocop:disable Style/FileOpen
40
+ if lock.flock(File::LOCK_EX | File::LOCK_NB)
41
+ @locks[id] = lock
42
+ true
43
+ else
44
+ lock.close
45
+ false
46
+ end
47
+ rescue SystemCallError
48
+ true
49
+ end
50
+
51
+ # Release every lock held by this store. The OS also releases them on
52
+ # process exit; this exists for tests and explicit teardown.
53
+ def release_locks
54
+ @locks.each_value { |lock| lock.close unless lock.closed? }
55
+ @locks.clear
56
+ end
57
+
58
+ # Write the session and return its id (generating one when nil). The
59
+ # original created_at is preserved across saves. Returns nil when the
60
+ # write fails — persistence must never break a REPL turn.
61
+ def save(id:, messages:, provider:, model:)
62
+ id ||= generate_id
63
+ claim(id)
64
+ existing = parse_session(path_for(id))
65
+ now = Time.now.utc.iso8601
66
+
67
+ session = {
68
+ version: SCHEMA_VERSION,
69
+ id: id,
70
+ provider: provider,
71
+ model: model,
72
+ created_at: existing&.dig(:created_at) || now,
73
+ updated_at: now,
74
+ title: title_from(messages),
75
+ messages: messages
76
+ }
77
+
78
+ write_atomically(path_for(id), JSON.generate(session))
79
+ prune!
80
+ id
81
+ rescue SystemCallError
82
+ nil
83
+ end
84
+
85
+ # Load a session by exact id or id prefix (most recent match wins).
86
+ # Returns nil when missing or corrupt.
87
+ def load(id)
88
+ path = path_for(id)
89
+ path = Dir.glob(File.join(@sessions_dir, "#{id}*.json")).max unless File.exist?(path)
90
+ return nil unless path
91
+
92
+ parse_session(path)
93
+ end
94
+
95
+ def latest
96
+ list.first
97
+ end
98
+
99
+ # All sessions, most recently updated first. Corrupt files are skipped.
100
+ def list
101
+ Dir.glob(File.join(@sessions_dir, "*.json"))
102
+ .filter_map { |path| parse_session(path) }
103
+ .sort_by { |session| session[:updated_at].to_s }
104
+ .reverse
105
+ end
106
+
107
+ private
108
+
109
+ def parse_session(path)
110
+ return nil unless File.exist?(path)
111
+
112
+ data = JSON.parse(File.read(path))
113
+ return nil unless data.is_a?(Hash) && data["messages"].is_a?(Array)
114
+
115
+ {
116
+ id: data["id"],
117
+ provider: data["provider"],
118
+ model: data["model"],
119
+ created_at: data["created_at"],
120
+ updated_at: data["updated_at"],
121
+ title: data["title"],
122
+ messages: data["messages"]
123
+ }
124
+ rescue JSON::ParserError, SystemCallError
125
+ nil
126
+ end
127
+
128
+ def write_atomically(path, content)
129
+ FileUtils.mkdir_p(@sessions_dir)
130
+ tmp_path = "#{path}.#{Process.pid}.tmp"
131
+ File.write(tmp_path, content)
132
+ File.rename(tmp_path, path)
133
+ end
134
+
135
+ def prune!
136
+ paths = Dir.glob(File.join(@sessions_dir, "*.json")).sort_by { |path| File.mtime(path) }
137
+ excess = paths.length - @max_sessions
138
+ return unless excess.positive?
139
+
140
+ paths.first(excess).each do |path|
141
+ File.delete(path)
142
+ FileUtils.rm_f("#{path}.lock")
143
+ end
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+
148
+ def path_for(id)
149
+ File.join(@sessions_dir, "#{id}.json")
150
+ end
151
+
152
+ def lock_path_for(id)
153
+ "#{path_for(id)}.lock"
154
+ end
155
+
156
+ # Timestamp prefix keeps ids readable and roughly sortable; the random
157
+ # suffix avoids collisions between sessions started in the same second.
158
+ def generate_id
159
+ "#{Time.now.strftime("%Y%m%dT%H%M%S")}-#{SecureRandom.hex(3)}"
160
+ end
161
+
162
+ def title_from(messages)
163
+ first_user = messages.find { |msg| msg["role"].to_s == "user" }
164
+ first_line = first_user&.fetch("content", nil).to_s.lines.first.to_s.strip
165
+ first_line.length > TITLE_MAX_LENGTH ? "#{first_line[0...TITLE_MAX_LENGTH]}..." : first_line
166
+ end
167
+ end
168
+ end
@@ -2,6 +2,17 @@
2
2
 
3
3
  module Yorishiro
4
4
  class Skill
5
+ # Returned from #execute to inject a prompt into the LLM instead of just
6
+ # printing output. The CLI feeds +text+ to the model as a user message and
7
+ # runs the agent/plan loop. Build one with the #prompt helper.
8
+ Prompt = Struct.new(:text)
9
+
10
+ # Convenience for subclasses: `prompt("...")` inside #execute returns a
11
+ # Prompt so the returned text is sent to the LLM.
12
+ def prompt(text)
13
+ Prompt.new(text)
14
+ end
15
+
5
16
  def name
6
17
  raise SkillNotImplementedError, "#{self.class}#name is not implemented"
7
18
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yorishiro
4
+ # Auto-loads custom skills (slash commands) from skill directories such
5
+ # as ~/.yorishiro/skills and ./.yorishiro/skills. A skill file just
6
+ # defines Yorishiro::Skill subclasses — no registration call needed.
7
+ # New classes are detected by diffing the Skill class tree around the
8
+ # load and registered through Configuration#replace_skill, so the usual
9
+ # validation applies and later directories override same-name skills.
10
+ class SkillLoader
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ end
14
+
15
+ def load_dir(dir)
16
+ return unless Dir.exist?(dir)
17
+
18
+ Dir.glob(File.join(dir, "*.rb")).each { |path| load_file(path) }
19
+ end
20
+
21
+ private
22
+
23
+ def load_file(path)
24
+ before = skill_classes
25
+ load path
26
+ (skill_classes - before).each { |klass| register(klass, path) }
27
+ rescue ConfigurationError
28
+ raise
29
+ rescue StandardError, SyntaxError => e
30
+ raise ConfigurationError, "Error loading skill file #{path}: #{e.message}"
31
+ end
32
+
33
+ def register(klass, path)
34
+ instance = klass.new
35
+ instance.name # abstract intermediate classes raise here — skip them
36
+ @configuration.replace_skill(instance)
37
+ rescue SkillNotImplementedError
38
+ nil
39
+ rescue StandardError => e
40
+ raise ConfigurationError, "Error registering skill #{klass} from #{path}: #{e.message}"
41
+ end
42
+
43
+ # Walk the whole subtree so skills inheriting from an intermediate
44
+ # base class (not directly from Skill) are found too.
45
+ def skill_classes
46
+ collect_subclasses(Skill)
47
+ end
48
+
49
+ def collect_subclasses(klass)
50
+ klass.subclasses.flat_map { |sub| [sub] + collect_subclasses(sub) }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yorishiro
4
+ # Runs a delegated task in its own fresh Conversation so exploratory tool
5
+ # output never enters the parent conversation — only the final assistant
6
+ # text is returned. The caller decides which tools the subagent may use;
7
+ # Tools::Task passes read-only tools only, so no permission prompts fire.
8
+ class SubAgent
9
+ MAX_ITERATIONS = 15
10
+
11
+ SYSTEM_PROMPT = <<~PROMPT
12
+ You are a subagent handling a task delegated by a coding assistant.
13
+ Investigate using the available read-only tools, then reply with your
14
+ findings as plain text. Only your final message is returned to the
15
+ caller, so make it complete and self-contained. Do not ask questions —
16
+ nobody can answer them.
17
+ PROMPT
18
+
19
+ def initialize(provider:, tools:, output: $stdout,
20
+ hooks: Yorishiro.configuration.hooks,
21
+ max_iterations: MAX_ITERATIONS)
22
+ @provider = provider
23
+ @tools = tools
24
+ @output = output
25
+ @hooks = hooks
26
+ @max_iterations = max_iterations
27
+ end
28
+
29
+ # Run the agent loop for +prompt+ and return the final assistant text.
30
+ def run(prompt)
31
+ conversation = Conversation.new(system_prompt: SYSTEM_PROMPT)
32
+ conversation.add_message(:user, prompt)
33
+ last_content = nil
34
+
35
+ @max_iterations.times do |iteration|
36
+ manage_context!(conversation)
37
+
38
+ begin
39
+ result = @provider.chat(conversation, tools: @tools.map(&:definition))
40
+ rescue StandardError => e
41
+ # A failed completion must not throw away the investigation done so
42
+ # far — salvage it the same way the iteration limit does.
43
+ return provider_error_notice(last_content, e)
44
+ end
45
+ content = result[:content]
46
+ tool_calls = result[:tool_calls]
47
+
48
+ conversation.add_message(:assistant, content, tool_calls: tool_calls.empty? ? nil : tool_calls)
49
+ last_content = content unless content.to_s.empty?
50
+
51
+ return final_answer(content) if tool_calls.empty?
52
+
53
+ # Pending tool calls on the last iteration are pointless to execute —
54
+ # there is no follow-up completion to consume their results.
55
+ break if iteration == @max_iterations - 1
56
+
57
+ execute_tool_calls(conversation, tool_calls)
58
+ end
59
+
60
+ iteration_limit_notice(last_content)
61
+ end
62
+
63
+ private
64
+
65
+ # Same escalation as the CLI minus compaction (an extra LLM call is
66
+ # overkill for a short-lived loop) and minus the "[i]" notices — the
67
+ # subagent shrinks its context silently.
68
+ def manage_context!(conversation)
69
+ budget = @provider.context_budget_tokens
70
+ return unless budget
71
+
72
+ conversation.elide_old_tool_results!(max_tokens: budget)
73
+ conversation.trim_to_budget!(max_tokens: budget)
74
+ end
75
+
76
+ def final_answer(content)
77
+ content.to_s.empty? ? "The subagent returned no findings." : content
78
+ end
79
+
80
+ def iteration_limit_notice(last_content)
81
+ notice = "[Subagent reached the #{@max_iterations}-iteration limit without a final answer.]"
82
+ last_content ? "#{last_content}\n\n#{notice}" : notice
83
+ end
84
+
85
+ def provider_error_notice(last_content, error)
86
+ notice = "[Subagent stopped early after a provider error: #{error.message}]"
87
+ last_content ? "#{last_content}\n\n#{notice}" : notice
88
+ end
89
+
90
+ def execute_tool_calls(conversation, tool_calls)
91
+ tool_calls.each do |tc|
92
+ @output.puts " [task] #{tc[:name]}(#{format_args(tc[:arguments])})"
93
+
94
+ denial = @hooks.run_before_tool_use(tc[:name], tc[:arguments])
95
+ if denial
96
+ conversation.add_tool_result(tool_call_id: tc[:id], content: "Tool call denied by hook: #{denial.reason}")
97
+ next
98
+ end
99
+
100
+ tool = @tools.find { |t| t.name == tc[:name] }
101
+ unless tool
102
+ conversation.add_tool_result(tool_call_id: tc[:id], content: "Error: Unknown tool '#{tc[:name]}'")
103
+ next
104
+ end
105
+
106
+ run_tool(conversation, tool, tc)
107
+ end
108
+ end
109
+
110
+ def run_tool(conversation, tool, tool_call)
111
+ output = tool.execute(**symbolize_keys(tool_call[:arguments]))
112
+ conversation.add_tool_result(
113
+ tool_call_id: tool_call[:id],
114
+ content: ToolResultCap.cap(output, budget: @provider.context_budget_tokens)
115
+ )
116
+ run_after_hooks(tool_call, output) # hooks (e.g. audit logs) see the full output
117
+ rescue StandardError => e
118
+ conversation.add_tool_result(tool_call_id: tool_call[:id], content: "Error: #{e.message}")
119
+ end
120
+
121
+ # after hooks are observational: a failure is warned about but never
122
+ # alters the already-recorded tool result.
123
+ def run_after_hooks(tool_call, output)
124
+ @hooks.run_after_tool_use(tool_call[:name], tool_call[:arguments], output)
125
+ rescue StandardError => e
126
+ @output.puts "[!] after_tool_use hook error: #{e.message}"
127
+ end
128
+
129
+ def format_args(args)
130
+ return "" unless args
131
+
132
+ args.map { |k, v| "#{k}: #{truncate(v.to_s, 50)}" }.join(", ")
133
+ end
134
+
135
+ def truncate(str, max)
136
+ str.length > max ? "#{str[0...max]}..." : str
137
+ end
138
+
139
+ def symbolize_keys(hash)
140
+ hash.transform_keys(&:to_sym)
141
+ end
142
+ end
143
+ end
@@ -37,5 +37,12 @@ module Yorishiro
37
37
  def configure(_options)
38
38
  # Override in subclasses to handle allow_tool options
39
39
  end
40
+
41
+ # Return a human-readable preview of what execute would do (e.g. a
42
+ # diff), shown in the permission prompt instead of the raw argument
43
+ # dump. Return nil to keep the default argument dump.
44
+ def preview(_arguments)
45
+ nil
46
+ end
40
47
  end
41
48
  end