kairos-chain 3.8.0 → 3.9.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: b7d045865d5f35b2ceba8abf858cc33219f88690feaf5591183b20154cda36e4
4
- data.tar.gz: f333e43070e2268da45be99949d3c85217bfcf2a5d7d691e707fbe26f5b96796
3
+ metadata.gz: 4facdf3bcb070b304f05d2ba2edd1d115e2f743db24c47864d953280da4c80f0
4
+ data.tar.gz: debbc87b1676e18e7a3ff200132d956ac8531324aecbf957e625fc6e8cdab059
5
5
  SHA512:
6
- metadata.gz: 4951cb6216a2f85212c12882c02337fc86e2a8abd906bcfd1322ca7d23760226692608a92626ea807c7a67b44c3f94b6eadd96ef88a6125a551dcca30a951954
7
- data.tar.gz: ccf714bf15d9b54219373f9a83560943fcff476cb700a459cf5d62b5c02c02ce87c0986bf81d8b5b3d7c9708b1eb58c94c5e1ecb96c5c5909ec9bf6f562c1d78
6
+ metadata.gz: af08ba6ddefba694090ca8f989b215ae80e2417e794ad3f3ece1bfb8b73e8aa428c1ea636e2ad9278a268b6f95435a180c043a96db15293e2f3351c2c2efc10b
7
+ data.tar.gz: c4d15cc38e425154e64cfd5aa52c80c939c3f00ce4ed8d0036c12e282816c0f320dae27bac46df968a8c74d1ac926037b0883244b7ed64827ff16dec18cc4c84
data/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.9.0] - 2026-03-30
8
+
9
+ ### Added
10
+
11
+ - **agent_execute** — Claude Code subprocess delegation for file operations
12
+ - Delegates Read/Edit/Write/Glob/Grep to a sandboxed `claude -p` subprocess
13
+ - `--permission-mode acceptEdits` for auto-approval of file edits
14
+ - `--output-format stream-json` for structured result parsing (files_modified, tool_calls)
15
+ - 8 security layers: Agent blacklist, tool restriction, Bash gating (requires
16
+ `Bash(pattern)` in allowed_tools), acceptEdits mode, env scrubbing
17
+ (unsetenv_others: true), project root lock, --max-budget-usd (clamped to
18
+ agent.yml max), external timeout (SIGTERM -> SIGKILL)
19
+ - Configurable via `agent.yml` `agent_execute:` section
20
+ - Design: 2 rounds x 2-3 LLMs. Implementation: 1 round x 3 LLMs.
21
+
22
+ - **Agent ACT routing** — automatic delegation based on task plan
23
+ - `requires_file_operations?` detects Edit/Write/Read/Bash in task steps
24
+ - File operations route to `agent_execute`; MCP tools route to `autoexec_run`
25
+ - Context injection via `--append-system-prompt` (goal + progress)
26
+
27
+ - **SkillSet discovery & install** via `system_upgrade`
28
+ - `system_upgrade command="skillsets"` lists all available SkillSets with status
29
+ - New SkillSets in gem templates auto-detected by `upgrade_check`
30
+ - `system_upgrade command="apply" names=["dream"]` installs specific SkillSets
31
+ - `available_skillsets` method on SkillSetManager
32
+
33
+ ### Fixed
34
+
35
+ - `ClaudeCodeAdapter`: removed invalid `--max-turns 1` flag (not in claude CLI)
36
+ - `agent_execute` blacklist: properly removes `agent_*` wildcard + re-adds other agent tools
37
+ - `agent_execute` error propagation: subprocess failures now set `error` key for ACT gates
38
+
7
39
  ## [3.8.0] - 2026-03-30
8
40
 
9
41
  ### Added
@@ -131,14 +131,18 @@ module KairosMcp
131
131
  { success: true, name: installed.name, version: installed.version, layer: installed.layer, path: dest }
132
132
  end
133
133
 
134
- # Check for available SkillSet upgrades from gem templates
134
+ # Check for available SkillSet upgrades from gem templates.
135
+ # Also detects NEW SkillSets in templates that are not yet installed.
135
136
  #
136
- # @return [Array<Hash>] List of upgradable skillsets with version info
137
+ # @return [Array<Hash>] List of upgradable/new skillsets with version info
137
138
  def upgrade_check
138
139
  results = []
139
140
  templates_dir = File.join(KairosMcp.gem_root, 'templates', 'skillsets')
140
141
  return results unless File.directory?(templates_dir)
141
142
 
143
+ installed_names = all_skillsets.map(&:name)
144
+
145
+ # Check existing installed SkillSets for upgrades
142
146
  all_skillsets.each do |installed|
143
147
  template_path = File.join(templates_dir, installed.name)
144
148
  next unless File.directory?(template_path)
@@ -157,17 +161,39 @@ module KairosMcp
157
161
  installed_version: installed.version,
158
162
  available_version: template_ss.version,
159
163
  version_bump: template_ver > installed_ver,
160
- changed_files: changed_files
164
+ changed_files: changed_files,
165
+ new_skillset: false
161
166
  }
162
167
  end
163
168
  end
164
169
 
170
+ # Detect NEW SkillSets in templates not yet installed
171
+ Dir.children(templates_dir).sort.each do |name|
172
+ template_path = File.join(templates_dir, name)
173
+ next unless File.directory?(template_path)
174
+ next if installed_names.include?(name)
175
+
176
+ template_ss = Skillset.new(template_path)
177
+ next unless template_ss.valid?
178
+
179
+ results << {
180
+ name: name,
181
+ installed_version: nil,
182
+ available_version: template_ss.version,
183
+ description: template_ss.description,
184
+ version_bump: false,
185
+ changed_files: [],
186
+ new_skillset: true
187
+ }
188
+ end
189
+
165
190
  results
166
191
  end
167
192
 
168
- # Apply SkillSet upgrades from gem templates
193
+ # Apply SkillSet upgrades from gem templates.
194
+ # Handles both upgrades (existing) and new installs.
169
195
  #
170
- # @param names [Array<String>, nil] specific names to upgrade, or nil for all
196
+ # @param names [Array<String>, nil] specific names to upgrade/install, or nil for all
171
197
  # @return [Array<Hash>] results
172
198
  def upgrade_apply(names: nil)
173
199
  upgrades = upgrade_check
@@ -176,24 +202,61 @@ module KairosMcp
176
202
  results = []
177
203
  upgrades.each do |info|
178
204
  template_path = File.join(KairosMcp.gem_root, 'templates', 'skillsets', info[:name])
179
- dest = File.join(@skillsets_dir, info[:name])
180
205
 
181
- info[:changed_files].each do |rel_path|
182
- src = File.join(template_path, rel_path)
183
- dst = File.join(dest, rel_path)
184
- FileUtils.mkdir_p(File.dirname(dst))
185
- FileUtils.cp(src, dst) if File.exist?(src)
186
- end
206
+ if info[:new_skillset]
207
+ # Install new SkillSet from template
208
+ result = install(template_path)
209
+ results << { name: info[:name], from: nil, to: info[:available_version],
210
+ action: 'installed', files_updated: 0 }
211
+ else
212
+ # Upgrade existing: copy changed files
213
+ dest = File.join(@skillsets_dir, info[:name])
214
+ info[:changed_files].each do |rel_path|
215
+ src = File.join(template_path, rel_path)
216
+ dst = File.join(dest, rel_path)
217
+ FileUtils.mkdir_p(File.dirname(dst))
218
+ FileUtils.cp(src, dst) if File.exist?(src)
219
+ end
187
220
 
188
- installed = Skillset.new(dest)
189
- record_skillset_event(installed, 'upgrade')
190
- results << { name: info[:name], from: info[:installed_version], to: info[:available_version],
191
- files_updated: info[:changed_files].size }
221
+ installed = Skillset.new(dest)
222
+ record_skillset_event(installed, 'upgrade')
223
+ results << { name: info[:name], from: info[:installed_version], to: info[:available_version],
224
+ action: 'upgraded', files_updated: info[:changed_files].size }
225
+ end
192
226
  end
193
227
 
194
228
  results
195
229
  end
196
230
 
231
+ # List all available SkillSets from gem templates with install status.
232
+ #
233
+ # @return [Array<Hash>] list with name, version, description, installed status
234
+ def available_skillsets
235
+ templates_dir = File.join(KairosMcp.gem_root, 'templates', 'skillsets')
236
+ return [] unless File.directory?(templates_dir)
237
+
238
+ installed_map = all_skillsets.each_with_object({}) { |ss, h| h[ss.name] = ss }
239
+
240
+ Dir.children(templates_dir).sort.filter_map do |name|
241
+ template_path = File.join(templates_dir, name)
242
+ next unless File.directory?(template_path)
243
+
244
+ template_ss = Skillset.new(template_path)
245
+ next unless template_ss.valid?
246
+
247
+ installed = installed_map[name]
248
+ {
249
+ name: name,
250
+ available_version: template_ss.version,
251
+ description: template_ss.description,
252
+ installed: !installed.nil?,
253
+ installed_version: installed&.version,
254
+ enabled: installed ? enabled?(name) : false,
255
+ upgrade_available: installed ? Gem::Version.new(template_ss.version) > Gem::Version.new(installed.version) : false
256
+ }
257
+ end
258
+ end
259
+
197
260
  # Remove a SkillSet
198
261
  def remove(name)
199
262
  skillset = find_skillset(name)
@@ -57,12 +57,18 @@ module KairosMcp
57
57
  properties: {
58
58
  command: {
59
59
  type: 'string',
60
- description: 'Command: "check", "preview", "apply", or "status"',
61
- enum: %w[check preview apply status]
60
+ description: 'Command: "check", "preview", "apply", "status", or "skillsets". ' \
61
+ '"skillsets" lists all available SkillSets with install status.',
62
+ enum: %w[check preview apply status skillsets]
62
63
  },
63
64
  approved: {
64
65
  type: 'boolean',
65
66
  description: 'Set to true to approve and apply the upgrade (required for apply command)'
67
+ },
68
+ names: {
69
+ type: 'array',
70
+ items: { type: 'string' },
71
+ description: 'Specific SkillSet names to install/upgrade (optional, for apply command)'
66
72
  }
67
73
  },
68
74
  required: ['command']
@@ -72,6 +78,7 @@ module KairosMcp
72
78
  def call(arguments)
73
79
  command = arguments['command']
74
80
  approved = arguments['approved'] || false
81
+ names = arguments['names']
75
82
 
76
83
  case command
77
84
  when 'check'
@@ -79,11 +86,13 @@ module KairosMcp
79
86
  when 'preview'
80
87
  handle_preview
81
88
  when 'apply'
82
- handle_apply(approved)
89
+ handle_apply(approved, names: names)
83
90
  when 'status'
84
91
  handle_status
92
+ when 'skillsets'
93
+ handle_skillsets
85
94
  else
86
- text_content("Unknown command: #{command}. Use check, preview, apply, or status.")
95
+ text_content("Unknown command: #{command}. Use check, preview, apply, status, or skillsets.")
87
96
  end
88
97
  end
89
98
 
@@ -122,6 +131,18 @@ module KairosMcp
122
131
  output += "No upgrade needed. Data directory is up to date.\n"
123
132
  end
124
133
 
134
+ # SkillSet status (always show, even if no L0/L1 upgrade needed)
135
+ ss_info = skillset_upgrade_summary
136
+ if ss_info[:new_count] > 0 || ss_info[:upgrade_count] > 0
137
+ output += "\n### SkillSets\n"
138
+ output += " New available: #{ss_info[:new_count]}\n" if ss_info[:new_count] > 0
139
+ output += " Upgrades available: #{ss_info[:upgrade_count]}\n" if ss_info[:upgrade_count] > 0
140
+ ss_info[:new_names].each { |n| output += " + #{n[:name]} (#{n[:version]}) — #{n[:description]}\n" }
141
+ output += "\nRun `system_upgrade command=\"skillsets\"` for full list.\n"
142
+ output += "Run `system_upgrade command=\"apply\" approved=true` to install all.\n"
143
+ output += "Run `system_upgrade command=\"apply\" approved=true names=[\"dream\"]` to install specific.\n"
144
+ end
145
+
125
146
  text_content(output)
126
147
  end
127
148
 
@@ -207,7 +228,7 @@ module KairosMcp
207
228
  # =====================================================================
208
229
  # apply — Execute the upgrade
209
230
  # =====================================================================
210
- def handle_apply(approved)
231
+ def handle_apply(approved, names: nil)
211
232
  unless approved
212
233
  return text_content(
213
234
  "Upgrade requires approval.\n\n" \
@@ -318,6 +339,27 @@ module KairosMcp
318
339
  end
319
340
  end
320
341
 
342
+ # Apply SkillSet upgrades/installs
343
+ begin
344
+ ss_mgr = KairosMcp::SkillSetManager.new
345
+ ss_results = ss_mgr.upgrade_apply(names: names)
346
+ if ss_results.any?
347
+ output += "\n## SkillSets\n\n"
348
+ ss_results.each do |r|
349
+ if r[:action] == 'installed'
350
+ actions[:skillsets_installed] = (actions[:skillsets_installed] || []) << r[:name]
351
+ output += " [INSTALLED] #{r[:name]} v#{r[:to]}\n"
352
+ else
353
+ actions[:skillsets_upgraded] = (actions[:skillsets_upgraded] || []) << r[:name]
354
+ output += " [UPGRADED] #{r[:name]} v#{r[:from]} → v#{r[:to]} (#{r[:files_updated]} files)\n"
355
+ end
356
+ end
357
+ end
358
+ rescue => e
359
+ output += "\n## SkillSets\n\n"
360
+ output += " [ERROR] SkillSet upgrade failed: #{e.message}\n"
361
+ end
362
+
321
363
  # Update .kairos_meta.yml
322
364
  update_meta(analyzer.gem_version)
323
365
  output += "\n [UPDATED] .kairos_meta.yml → v#{analyzer.gem_version}\n"
@@ -392,10 +434,63 @@ module KairosMcp
392
434
  text_content(output)
393
435
  end
394
436
 
437
+ # =====================================================================
438
+ # skillsets — List all available SkillSets with install status
439
+ # =====================================================================
440
+ def handle_skillsets
441
+ ss_mgr = KairosMcp::SkillSetManager.new
442
+ available = ss_mgr.available_skillsets
443
+
444
+ output = "# Available SkillSets\n\n"
445
+ output += "| Name | Available | Installed | Status |\n"
446
+ output += "|------|-----------|-----------|--------|\n"
447
+
448
+ available.each do |ss|
449
+ status = if !ss[:installed]
450
+ 'Not installed'
451
+ elsif ss[:upgrade_available]
452
+ "Upgrade: #{ss[:installed_version]} -> #{ss[:available_version]}"
453
+ elsif ss[:enabled]
454
+ 'Installed, enabled'
455
+ else
456
+ 'Installed, disabled'
457
+ end
458
+ output += "| #{ss[:name]} | #{ss[:available_version]} | #{ss[:installed_version] || '-'} | #{status} |\n"
459
+ end
460
+
461
+ not_installed = available.select { |ss| !ss[:installed] }
462
+ if not_installed.any?
463
+ output += "\n## Not Installed\n\n"
464
+ not_installed.each do |ss|
465
+ output += "- **#{ss[:name]}** v#{ss[:available_version]}"
466
+ output += " — #{ss[:description]}" if ss[:description]
467
+ output += "\n"
468
+ end
469
+ output += "\nTo install: `system_upgrade command=\"apply\" approved=true names=[\"#{not_installed.first[:name]}\"]`\n"
470
+ output += "To install all: `system_upgrade command=\"apply\" approved=true`\n"
471
+ end
472
+
473
+ text_content(output)
474
+ end
475
+
395
476
  # =====================================================================
396
477
  # Helpers
397
478
  # =====================================================================
398
479
 
480
+ def skillset_upgrade_summary
481
+ ss_mgr = KairosMcp::SkillSetManager.new
482
+ checks = ss_mgr.upgrade_check
483
+ new_ss = checks.select { |c| c[:new_skillset] }
484
+ upgrades = checks.reject { |c| c[:new_skillset] }
485
+ {
486
+ new_count: new_ss.size,
487
+ upgrade_count: upgrades.size,
488
+ new_names: new_ss.map { |s| { name: s[:name], version: s[:available_version], description: s[:description] } }
489
+ }
490
+ rescue StandardError
491
+ { new_count: 0, upgrade_count: 0, new_names: [] }
492
+ end
493
+
399
494
  def pattern_icon(pattern)
400
495
  case pattern
401
496
  when :unchanged then 'OK'
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.8.0"
2
+ VERSION = "3.9.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -35,6 +35,7 @@ tool_blacklist:
35
35
  - "challenge_*"
36
36
  - "autoexec_plan"
37
37
  - "autoexec_run"
38
+ - "agent_execute"
38
39
  # MCP client: user-only (requires token)
39
40
  - "mcp_connect"
40
41
  - "mcp_disconnect"
@@ -52,5 +53,14 @@ autonomous:
52
53
  min_cycles_before_exit: 2 # confidence exit disabled for first N cycles
53
54
  confidence_exit_threshold: 0.9 # minimum confidence for early exit
54
55
 
56
+ # agent_execute: Claude Code subprocess delegation for file operations
57
+ agent_execute:
58
+ default_tools: ["Read", "Edit", "Write", "Glob", "Grep"]
59
+ default_model: "sonnet"
60
+ default_timeout: 120
61
+ max_timeout: 600
62
+ default_budget_usd: 0.50
63
+ max_budget_usd: 2.00
64
+
55
65
  # Audit
56
66
  audit_level: summary
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'set'
6
+
7
+ module KairosMcp
8
+ module SkillSets
9
+ module Agent
10
+ module Tools
11
+ class AgentExecute < KairosMcp::Tools::BaseTool
12
+ DEFAULT_TOOLS = %w[Read Edit Write Glob Grep].freeze
13
+ # Include auth + config vars Claude Code needs to function
14
+ SAFE_ENV_VARS = %w[
15
+ PATH HOME LANG LC_ALL TERM USER SHELL TMPDIR
16
+ XDG_CONFIG_HOME XDG_DATA_HOME
17
+ ANTHROPIC_API_KEY CLAUDE_CODE_USE_BEDROCK
18
+ AWS_PROFILE AWS_REGION AWS_DEFAULT_REGION
19
+ ].freeze
20
+ MAX_OUTPUT_BYTES = 1_048_576 # 1MB
21
+ MAX_STDERR_BYTES = 1_048_576 # 1MB
22
+ DEFAULT_TIMEOUT = 120
23
+ MAX_TIMEOUT = 600
24
+ DEFAULT_BUDGET_USD = 0.50
25
+
26
+ def name
27
+ 'agent_execute'
28
+ end
29
+
30
+ def description
31
+ 'Execute a software engineering task via Claude Code subprocess. ' \
32
+ 'Delegates file operations (Read/Edit/Write) to a sandboxed Claude Code instance. ' \
33
+ 'Bash is excluded by default; requires allowed_tools patterns (e.g., Bash(git:*)). ' \
34
+ 'Mandate risk_budget is enforced at the ACT routing level, not within this tool.'
35
+ end
36
+
37
+ def category
38
+ :agent
39
+ end
40
+
41
+ def usecase_tags
42
+ %w[agent execute file edit code subprocess]
43
+ end
44
+
45
+ def related_tools
46
+ %w[agent_start agent_step autoexec_run]
47
+ end
48
+
49
+ def input_schema
50
+ {
51
+ type: 'object',
52
+ properties: {
53
+ task: {
54
+ type: 'string',
55
+ description: 'Task description for Claude Code to execute'
56
+ },
57
+ context: {
58
+ type: 'string',
59
+ description: 'Goal/progress context injected via --append-system-prompt (optional)'
60
+ },
61
+ tools: {
62
+ type: 'array', items: { type: 'string' },
63
+ description: 'Tools to enable (default: Read,Edit,Write,Glob,Grep). ' \
64
+ 'Add "Bash" for shell access (requires risk_budget: medium + allowed_tools patterns).'
65
+ },
66
+ allowed_tools: {
67
+ type: 'array', items: { type: 'string' },
68
+ description: 'Fine-grained tool patterns for --allowedTools (e.g., "Bash(git:*) Bash(ruby:*)")'
69
+ },
70
+ timeout: {
71
+ type: 'integer',
72
+ description: "Timeout in seconds (default: #{DEFAULT_TIMEOUT}, max: #{MAX_TIMEOUT})"
73
+ },
74
+ max_budget_usd: {
75
+ type: 'number',
76
+ description: "Max API cost in USD (default: #{DEFAULT_BUDGET_USD})"
77
+ },
78
+ model: {
79
+ type: 'string',
80
+ description: 'Model override (default: sonnet for speed)'
81
+ }
82
+ },
83
+ required: ['task']
84
+ }
85
+ end
86
+
87
+ def call(arguments)
88
+ task = arguments['task']
89
+ return error_text('task is required') if task.nil? || task.strip.empty?
90
+
91
+ context = arguments['context']
92
+ tools = arguments['tools'] || agent_execute_config('default_tools', DEFAULT_TOOLS).dup
93
+ allowed_tools_patterns = arguments['allowed_tools']
94
+ cfg_max_timeout = agent_execute_config('max_timeout', MAX_TIMEOUT).to_i
95
+ cfg_default_timeout = agent_execute_config('default_timeout', DEFAULT_TIMEOUT).to_i
96
+ timeout = [[arguments['timeout'] || cfg_default_timeout, 1].max, cfg_max_timeout].min
97
+ model = arguments['model'] || agent_execute_config('default_model', 'sonnet')
98
+
99
+ # Clamp budget to configured max
100
+ config_max = agent_execute_config('max_budget_usd', 2.0).to_f
101
+ raw_budget = arguments['max_budget_usd'] || agent_execute_config('default_budget_usd', DEFAULT_BUDGET_USD).to_f
102
+ budget = [raw_budget, config_max].min
103
+
104
+ # Bash gating: require fine-grained patterns and risk_budget: medium
105
+ if tools.include?('Bash')
106
+ unless allowed_tools_patterns&.any? { |p| p.start_with?('Bash(') }
107
+ return error_text(
108
+ "Bash requires fine-grained patterns via allowed_tools (e.g., 'Bash(git:*)'). " \
109
+ "Unrestricted Bash is not permitted."
110
+ )
111
+ end
112
+ # Check mandate risk_budget if available via invocation context
113
+ if @safety&.respond_to?(:current_user)
114
+ # In autonomous mode, risk_budget is checked at the mandate level
115
+ # agent_execute trusts that the ACT phase has already passed Gate 5
116
+ end
117
+ end
118
+
119
+ safe_env = build_safe_env
120
+ args = build_args(tools, allowed_tools_patterns, budget, model, context)
121
+
122
+ # Verify claude CLI exists using the same scrubbed env
123
+ _out, _err, st = Open3.capture3(safe_env, 'which', 'claude', unsetenv_others: true)
124
+ unless st.success?
125
+ return error_text('Claude Code CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code')
126
+ end
127
+ root = project_root
128
+
129
+ result = execute_with_timeout(safe_env, args, task, timeout, root)
130
+ text_content(JSON.generate(result))
131
+ rescue SubprocessTimeout => e
132
+ text_content(JSON.generate({
133
+ 'status' => 'timeout', 'error' => e.message, 'timeout_seconds' => timeout
134
+ }))
135
+ rescue StandardError => e
136
+ error_text("#{e.class}: #{e.message}")
137
+ end
138
+
139
+ private
140
+
141
+ class SubprocessTimeout < StandardError; end
142
+
143
+ def build_args(tools, allowed_tools_patterns, budget, model, context)
144
+ args = ['claude', '-p',
145
+ '--output-format', 'stream-json',
146
+ '--permission-mode', 'acceptEdits',
147
+ '--tools', tools.join(','),
148
+ '--max-budget-usd', budget.to_s,
149
+ '--model', model]
150
+
151
+ if allowed_tools_patterns && !allowed_tools_patterns.empty?
152
+ args += ['--allowedTools', allowed_tools_patterns.join(',')]
153
+ end
154
+
155
+ if context && !context.strip.empty?
156
+ args += ['--append-system-prompt', context]
157
+ end
158
+
159
+ args
160
+ end
161
+
162
+ def build_safe_env
163
+ env = {}
164
+ SAFE_ENV_VARS.each { |k| env[k] = ENV[k] if ENV[k] }
165
+ env
166
+ end
167
+
168
+ def execute_with_timeout(env, args, task, timeout, root)
169
+ stdout_data = +''
170
+ stderr_data = +''
171
+ pid = nil
172
+
173
+ Open3.popen3(env, *args, unsetenv_others: true, chdir: root) do |stdin, stdout, stderr, wait_thr|
174
+ pid = wait_thr.pid
175
+ stdin.write(task)
176
+ stdin.close
177
+
178
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
179
+ readers = [stdout, stderr]
180
+
181
+ until readers.empty?
182
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
183
+ if remaining <= 0
184
+ kill_process(pid)
185
+ wait_thr.join(5)
186
+ raise SubprocessTimeout, "Timed out after #{timeout}s"
187
+ end
188
+
189
+ ready = IO.select(readers, nil, nil, [remaining, 5].min)
190
+ next unless ready
191
+
192
+ ready[0].each do |io|
193
+ begin
194
+ chunk = io.read_nonblock(65536)
195
+ if io == stdout
196
+ stdout_data << chunk
197
+ if stdout_data.bytesize > MAX_OUTPUT_BYTES
198
+ kill_process(pid)
199
+ wait_thr.join(5)
200
+ stdout_data = stdout_data.byteslice(0, MAX_OUTPUT_BYTES)
201
+ return parse_stream_output(stdout_data, truncated: true)
202
+ end
203
+ else
204
+ stderr_data << chunk
205
+ stderr_data = stderr_data.byteslice(0, MAX_STDERR_BYTES) if stderr_data.bytesize > MAX_STDERR_BYTES
206
+ end
207
+ rescue IO::WaitReadable, Errno::EAGAIN
208
+ # Spurious readability — retry on next select
209
+ rescue EOFError
210
+ readers.delete(io)
211
+ end
212
+ end
213
+ end
214
+
215
+ # IO done — wait for process with timeout guard
216
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ if remaining > 0
218
+ unless wait_thr.join([remaining, 30].min)
219
+ kill_process(pid)
220
+ wait_thr.join(5)
221
+ raise SubprocessTimeout, "Process did not exit after IO closed (#{timeout}s deadline)"
222
+ end
223
+ else
224
+ kill_process(pid)
225
+ wait_thr.join(5)
226
+ raise SubprocessTimeout, "Timed out after #{timeout}s"
227
+ end
228
+
229
+ result = parse_stream_output(stdout_data)
230
+ result['exit_status'] = wait_thr.value&.exitstatus
231
+ result['stderr'] = stderr_data[0..500] unless stderr_data.empty?
232
+ result
233
+ end
234
+ end
235
+
236
+ def kill_process(pid)
237
+ Process.kill('TERM', pid)
238
+ sleep 2
239
+ Process.kill('KILL', pid) rescue nil
240
+ rescue Errno::ESRCH
241
+ # Already dead
242
+ end
243
+
244
+ def parse_stream_output(raw, truncated: false)
245
+ lines = raw.split("\n").filter_map { |l| JSON.parse(l) rescue nil }
246
+
247
+ result_msg = lines.find { |l| l['type'] == 'result' }
248
+
249
+ tool_uses = lines.select { |l|
250
+ l['type'] == 'assistant' &&
251
+ l.dig('message', 'content')&.any? { |c| c['type'] == 'tool_use' }
252
+ }
253
+
254
+ files_modified = extract_modified_files(tool_uses)
255
+
256
+ {
257
+ 'status' => result_msg ? 'ok' : 'no_result',
258
+ 'result' => result_msg&.dig('result') || '',
259
+ 'files_modified' => files_modified,
260
+ 'tool_calls_count' => tool_uses.size,
261
+ 'truncated' => truncated,
262
+ 'is_error' => result_msg&.dig('is_error') || false
263
+ }
264
+ end
265
+
266
+ def extract_modified_files(tool_uses)
267
+ files = Set.new
268
+ tool_uses.each do |tu|
269
+ (tu.dig('message', 'content') || []).each do |c|
270
+ next unless c['type'] == 'tool_use'
271
+ input = c['input'] || {}
272
+ case c['name']
273
+ when 'Edit', 'Write'
274
+ files << input['file_path'] if input['file_path']
275
+ end
276
+ end
277
+ end
278
+ files.to_a
279
+ end
280
+
281
+ def project_root
282
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:data_dir)
283
+ root = File.dirname(KairosMcp.data_dir)
284
+ return root if File.directory?(root)
285
+ end
286
+ Dir.pwd
287
+ end
288
+
289
+ def agent_execute_config(key, default = nil)
290
+ @_agent_yml_cache ||= begin
291
+ config_path = File.join(__dir__, '..', 'config', 'agent.yml')
292
+ if File.exist?(config_path)
293
+ require 'yaml'
294
+ YAML.safe_load(File.read(config_path)) || {}
295
+ else
296
+ {}
297
+ end
298
+ rescue StandardError
299
+ {}
300
+ end
301
+ @_agent_yml_cache.dig('agent_execute', key) || default
302
+ end
303
+
304
+ def error_text(message)
305
+ text_content(JSON.generate({ 'status' => 'error', 'error' => message }))
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
@@ -553,11 +553,30 @@ module KairosMcp
553
553
  end
554
554
 
555
555
  def run_act(session, decision_payload)
556
+ task_json = decision_payload['task_json']
557
+
558
+ # Route: file operations → agent_execute; MCP tools → autoexec
559
+ if requires_file_operations?(task_json)
560
+ run_act_via_agent_execute(session, decision_payload)
561
+ else
562
+ run_act_via_autoexec(session, decision_payload)
563
+ end
564
+ rescue StandardError => e
565
+ { 'error' => "ACT failed: #{e.message}" }
566
+ end
567
+
568
+ FILE_TOOL_NAMES = %w[Edit Write Read Bash file_edit file_write file_read].freeze
569
+
570
+ def requires_file_operations?(task_json)
571
+ steps = task_json&.dig('steps') || []
572
+ steps.any? { |s| FILE_TOOL_NAMES.include?(s['tool_name']) }
573
+ end
574
+
575
+ def run_act_via_autoexec(session, decision_payload)
556
576
  act_ctx = session.invocation_context.derive(
557
577
  blacklist_remove: %w[autoexec_plan autoexec_run]
558
578
  )
559
579
 
560
- # Create plan
561
580
  plan_result = invoke_tool('autoexec_plan', {
562
581
  'task_json' => JSON.generate(decision_payload['task_json'])
563
582
  }, context: act_ctx)
@@ -568,7 +587,6 @@ module KairosMcp
568
587
  task_id = plan_parsed['task_id']
569
588
  plan_hash = plan_parsed['plan_hash']
570
589
 
571
- # Execute
572
590
  run_result = invoke_tool('autoexec_run', {
573
591
  'task_id' => task_id,
574
592
  'mode' => 'internal_execute',
@@ -583,8 +601,62 @@ module KairosMcp
583
601
  'execution' => run_parsed,
584
602
  'summary' => run_parsed['status'] == 'ok' ? 'completed' : 'failed'
585
603
  }
586
- rescue StandardError => e
587
- { 'error' => "ACT failed: #{e.message}" }
604
+ end
605
+
606
+ def run_act_via_agent_execute(session, decision_payload)
607
+ # Must remove both the exact entry AND the wildcard 'agent_*' to unblock agent_execute.
608
+ # Re-add other agent tools to keep them blocked.
609
+ act_ctx = session.invocation_context.derive(
610
+ blacklist_remove: %w[agent_execute agent_*],
611
+ blacklist_add: %w[agent_start agent_step agent_status agent_stop]
612
+ )
613
+
614
+ context = build_agent_execute_context(session)
615
+ task_summary = decision_payload['summary'] || ''
616
+ task_detail = format_steps_as_instructions(decision_payload['task_json'])
617
+
618
+ result = invoke_tool('agent_execute', {
619
+ 'task' => "#{task_summary}\n\n#{task_detail}",
620
+ 'context' => context
621
+ }, context: act_ctx)
622
+
623
+ parsed = JSON.parse(result.map { |b| b[:text] || b['text'] }.compact.join)
624
+
625
+ # Propagate subprocess failures as 'error' for ACT failure gates
626
+ error_msg = nil
627
+ unless parsed['status'] == 'ok'
628
+ error_msg = parsed['error'] || "agent_execute #{parsed['status']}: #{parsed['result'].to_s[0..200]}"
629
+ end
630
+
631
+ {
632
+ 'execution' => parsed,
633
+ 'files_modified' => parsed['files_modified'] || [],
634
+ 'tool_calls_count' => parsed['tool_calls_count'] || 0,
635
+ 'summary' => parsed['status'] == 'ok' ? 'completed' : 'failed',
636
+ 'error' => error_msg
637
+ }
638
+ end
639
+
640
+ def build_agent_execute_context(session)
641
+ parts = ["Goal: #{session.goal_name}",
642
+ "Cycle: #{session.cycle_number + 1}"]
643
+ progress = session.load_progress
644
+ unless progress.empty?
645
+ parts << "Previous cycles:"
646
+ progress.last(3).each { |p|
647
+ parts << " Cycle #{p['cycle']}: #{p['act_summary']} (confidence: #{p['confidence']})"
648
+ }
649
+ end
650
+ parts.join("\n")
651
+ end
652
+
653
+ def format_steps_as_instructions(task_json)
654
+ steps = task_json&.dig('steps') || []
655
+ return '(no steps)' if steps.empty?
656
+
657
+ steps.map.with_index(1) { |s, i|
658
+ "Step #{i}: #{s['action'] || s['tool_name']} — #{s.dig('tool_arguments', 'description') || s['tool_arguments'].to_json}"
659
+ }.join("\n")
588
660
  end
589
661
 
590
662
  # Parse REFLECT response, handling code fences and nested JSON
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.0
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -218,6 +218,7 @@ files:
218
218
  - templates/skillsets/agent/test/test_agent_m2.rb
219
219
  - templates/skillsets/agent/test/test_agent_m3.rb
220
220
  - templates/skillsets/agent/test/test_agent_m4.rb
221
+ - templates/skillsets/agent/tools/agent_execute.rb
221
222
  - templates/skillsets/agent/tools/agent_start.rb
222
223
  - templates/skillsets/agent/tools/agent_status.rb
223
224
  - templates/skillsets/agent/tools/agent_step.rb