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 +4 -4
- data/CHANGELOG.md +32 -0
- data/lib/kairos_mcp/skillset_manager.rb +79 -16
- data/lib/kairos_mcp/tools/system_upgrade.rb +100 -5
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/config/agent.yml +10 -0
- data/templates/skillsets/agent/tools/agent_execute.rb +311 -0
- data/templates/skillsets/agent/tools/agent_step.rb +76 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4facdf3bcb070b304f05d2ba2edd1d115e2f743db24c47864d953280da4c80f0
|
|
4
|
+
data.tar.gz: debbc87b1676e18e7a3ff200132d956ac8531324aecbf957e625fc6e8cdab059
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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[:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 "
|
|
61
|
-
|
|
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
|
|
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'
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
-
|
|
587
|
-
|
|
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.
|
|
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
|