kairos-chain 3.7.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: cef8ee3fd2052bb3d553e0c7ee0569d50f88fcfadea13dec6722122e2e71adb7
4
- data.tar.gz: de6425c1fa691e86ad4e3597f36733b9ca592cb017ad58b69d54aecb64b63dd6
3
+ metadata.gz: 4facdf3bcb070b304f05d2ba2edd1d115e2f743db24c47864d953280da4c80f0
4
+ data.tar.gz: debbc87b1676e18e7a3ff200132d956ac8531324aecbf957e625fc6e8cdab059
5
5
  SHA512:
6
- metadata.gz: bc9dec7fd29edf94dce3de1a18fc90eb006695cb22475195aeb4422f6aa843a0efc1b2fa2e5796891768934c7307fc0687d0420f2883a099b700a80077ae4b7a
7
- data.tar.gz: db1e322c0b191ab12d949c403b6b04a5c6d99fccd9dd2e6528754a066a4bf3db26e4fb3e0cba4898826da13887d52f91c3018377a47aa5dd1ee25316f850f7df
6
+ metadata.gz: af08ba6ddefba694090ca8f989b215ae80e2417e794ad3f3ece1bfb8b73e8aa428c1ea636e2ad9278a268b6f95435a180c043a96db15293e2f3351c2c2efc10b
7
+ data.tar.gz: c4d15cc38e425154e64cfd5aa52c80c939c3f00ce4ed8d0036c12e282816c0f320dae27bac46df968a8c74d1ac926037b0883244b7ed64827ff16dec18cc4c84
data/CHANGELOG.md CHANGED
@@ -4,6 +4,74 @@ 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
+
39
+ ## [3.8.0] - 2026-03-30
40
+
41
+ ### Added
42
+
43
+ - **Agent Autonomous Mode** — Multi-cycle OODA loop execution
44
+ - `agent_start(autonomous: true)`: Enable autonomous mode. Session starts at
45
+ `[observed]` as before; autonomous loop begins on `agent_step(approve)`.
46
+ - 8 safety gates: mandate termination, goal drift detection, wall-clock timeout
47
+ (300s), aggregate LLM budget (60 calls), risk budget, post-ACT termination,
48
+ confidence-based early exit, checkpoint pause.
49
+ - New session states: `autonomous_cycling`, `paused_risk`, `paused_error`
50
+ - Resume handlers: `approve` at `paused_risk` re-checks risk and resumes ACT;
51
+ `approve`/`skip` at `paused_error` skips failed cycle and continues.
52
+ - `agent.yml` autonomous config: `max_total_llm_calls`, `max_duration_seconds`,
53
+ `min_cycles_before_exit`, `confidence_exit_threshold`.
54
+ - Design: 2 rounds x 3 LLMs. Implementation: 1 round x 3 LLMs. All HIGH fixed.
55
+
56
+ - **Mandate locking** — `Mandate.with_lock` for single-writer batch execution
57
+ - File-based exclusive lock (`flock`), non-blocking with `LockError`
58
+ - Atomic save via tmp+rename pattern
59
+ - `Mandate.reload` helper for in-lock refresh
60
+
61
+ - **CognitiveLoop call tracking** — `total_calls` attribute for aggregate
62
+ LLM budget enforcement across autonomous cycles
63
+
64
+ - **Goal drift detection** — Content-based hash (not name-only) at mandate
65
+ creation; per-cycle drift check with fail-open semantics
66
+
67
+ ### Changed
68
+
69
+ - `run_orient_decide` / `run_act_reflect` refactored into `_internal` (Hash return)
70
+ + wrapper (text_content) pattern. Manual mode behavior unchanged.
71
+ - Manual risk pause now sets session to `paused_risk` (was `terminated`),
72
+ enabling resume via `agent_step(approve)`.
73
+ - `MandateAdapter.to_mandate_proposal` uses `dig` for nil safety.
74
+
7
75
  ## [3.7.0] - 2026-03-29
8
76
 
9
77
  ### 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.7.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"
@@ -45,5 +46,21 @@ tool_blacklist:
45
46
  orient_tools_extra: []
46
47
  # - document_status # uncomment to enable draft checking during ORIENT
47
48
 
49
+ # Autonomous mode limits
50
+ autonomous:
51
+ max_total_llm_calls: 60 # across all cycles in one batch
52
+ max_duration_seconds: 300 # wall-clock timeout per batch (5 min)
53
+ min_cycles_before_exit: 2 # confidence exit disabled for first N cycles
54
+ confidence_exit_threshold: 0.9 # minimum confidence for early exit
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
+
48
65
  # Audit
49
66
  audit_level: summary
@@ -7,11 +7,17 @@ module KairosMcp
7
7
  module SkillSets
8
8
  module Agent
9
9
  class CognitiveLoop
10
+ FALLBACK_PROVIDERS = %w[claude_code].freeze
11
+
12
+ attr_reader :total_calls
13
+
10
14
  # @param caller_tool [BaseTool] the agent_step tool instance (has invoke_tool)
11
15
  # @param session [Session] current agent session
12
16
  def initialize(caller_tool, session)
13
17
  @caller = caller_tool
14
18
  @session = session
19
+ @fallback_attempted = false
20
+ @total_calls = 0
15
21
  end
16
22
 
17
23
  # Generic phase runner for ORIENT, REFLECT, and DECIDE_PREP.
@@ -29,14 +35,13 @@ module KairosMcp
29
35
  'stop_reason' => 'budget' }
30
36
  end
31
37
 
32
- llm_result = @caller.invoke_tool('llm_call', {
38
+ @total_calls += 1
39
+ parsed = call_llm_with_fallback(
33
40
  'messages' => messages,
34
41
  'system' => system_prompt,
35
42
  'tools' => available_tools,
36
43
  'invocation_context_json' => @session.invocation_context.to_json
37
- }, context: @session.invocation_context)
38
-
39
- parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
44
+ )
40
45
  return { 'error' => parsed['error'] } if parsed['status'] == 'error'
41
46
 
42
47
  response = parsed['response']
@@ -79,14 +84,14 @@ module KairosMcp
79
84
  return { 'error' => 'Budget exceeded for DECIDE phase' }
80
85
  end
81
86
 
82
- llm_result = @caller.invoke_tool('llm_call', {
87
+ @total_calls += 1
88
+
89
+ parsed = call_llm_with_fallback(
83
90
  'messages' => messages,
84
91
  'system' => system_prompt,
85
92
  'tools' => [],
86
93
  'invocation_context_json' => @session.invocation_context.to_json
87
- }, context: @session.invocation_context)
88
-
89
- parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
94
+ )
90
95
  return { 'error' => parsed['error'] } if parsed['status'] == 'error'
91
96
 
92
97
  response = parsed['response']
@@ -110,7 +115,7 @@ module KairosMcp
110
115
  begin
111
116
  decision = JSON.parse(json_str)
112
117
  task_json_str = JSON.generate(decision['task_json'])
113
- Autoexec::TaskDsl.from_json(task_json_str)
118
+ ::Autoexec::TaskDsl.from_json(task_json_str)
114
119
  return { 'decision_payload' => decision }
115
120
  rescue => e
116
121
  if attempts >= max_repair
@@ -127,6 +132,61 @@ module KairosMcp
127
132
 
128
133
  private
129
134
 
135
+ # Call llm_call with automatic provider fallback on auth errors.
136
+ # Tries the configured provider first. On auth_error, switches to
137
+ # fallback providers (claude_code) via llm_configure, then retries once.
138
+ def call_llm_with_fallback(arguments)
139
+ llm_result = @caller.invoke_tool('llm_call', arguments,
140
+ context: @session.invocation_context)
141
+ parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
142
+
143
+ # If not an auth error, or already tried fallback, return as-is
144
+ error_info = parsed['error']
145
+ if !error_info || !error_info.is_a?(Hash) || error_info['type'] != 'auth_error' || @fallback_attempted
146
+ return parsed
147
+ end
148
+
149
+ # Attempt provider fallback
150
+ original_provider = error_info['provider'] || 'configured'
151
+ warn "[agent] Auth error from #{original_provider}, attempting provider fallback"
152
+
153
+ FALLBACK_PROVIDERS.each do |fallback|
154
+ @fallback_attempted = true
155
+ configure_result = try_configure_provider(fallback)
156
+ next unless configure_result
157
+
158
+ warn "[agent] Switched to provider: #{fallback}"
159
+ retry_result = @caller.invoke_tool('llm_call', arguments,
160
+ context: @session.invocation_context)
161
+ retry_parsed = JSON.parse(retry_result.map { |b| b[:text] || b['text'] }.compact.join)
162
+
163
+ # If this provider also fails with auth_error, try next
164
+ retry_error = retry_parsed['error']
165
+ if retry_error.is_a?(Hash) && retry_error['type'] == 'auth_error'
166
+ warn "[agent] Fallback provider #{fallback} also failed: #{retry_error['message']}"
167
+ next
168
+ end
169
+
170
+ return retry_parsed
171
+ end
172
+
173
+ # All fallbacks exhausted — return original error with fallback info
174
+ parsed['error']['fallback_attempted'] = true
175
+ parsed['error']['fallback_exhausted'] = true
176
+ parsed
177
+ end
178
+
179
+ def try_configure_provider(provider)
180
+ args = { 'provider' => provider }
181
+ result = @caller.invoke_tool('llm_configure', args,
182
+ context: @session.invocation_context)
183
+ parsed = JSON.parse(result.map { |b| b[:text] || b['text'] }.compact.join)
184
+ parsed['status'] == 'ok'
185
+ rescue StandardError => e
186
+ warn "[agent] Failed to configure provider #{provider}: #{e.message}"
187
+ false
188
+ end
189
+
130
190
  def extract_json(content)
131
191
  JSON.parse(content)
132
192
  content
@@ -11,7 +11,7 @@ module KairosMcp
11
11
  def self.to_mandate_proposal(decision_payload)
12
12
  {
13
13
  autoexec_task: {
14
- steps: (decision_payload['task_json']['steps'] || []).map { |s|
14
+ steps: (decision_payload.dig('task_json', 'steps') || []).map { |s|
15
15
  { risk: s['risk'] || 'low', tool_name: s['tool_name'] }
16
16
  }
17
17
  },
@@ -8,19 +8,25 @@ module KairosMcp
8
8
  module Agent
9
9
  class Session
10
10
  attr_reader :session_id, :mandate_id, :goal_name, :invocation_context,
11
- :state, :cycle_number, :config
11
+ :state, :cycle_number, :config, :autonomous
12
12
 
13
- def initialize(session_id:, mandate_id:, goal_name:, invocation_context:, config:)
13
+ def initialize(session_id:, mandate_id:, goal_name:, invocation_context:, config:,
14
+ autonomous: false)
14
15
  @session_id = session_id
15
16
  @mandate_id = mandate_id
16
17
  @goal_name = goal_name
17
18
  @invocation_context = invocation_context
18
19
  @config = config
20
+ @autonomous = autonomous
19
21
  @state = 'created'
20
22
  @cycle_number = 0
21
23
  @snapshots = []
22
24
  end
23
25
 
26
+ def autonomous?
27
+ @autonomous == true
28
+ end
29
+
24
30
  # Per-phase budget configuration.
25
31
  # Returns defaults if the phase is not configured.
26
32
  def phase_config(phase_name)
@@ -109,7 +115,7 @@ module KairosMcp
109
115
  data = {
110
116
  session_id: @session_id, mandate_id: @mandate_id,
111
117
  goal_name: @goal_name, state: @state, cycle_number: @cycle_number,
112
- config: @config,
118
+ config: @config, autonomous: @autonomous,
113
119
  invocation_context: @invocation_context.to_h
114
120
  }
115
121
  File.write(state_path, JSON.pretty_generate(data))
@@ -133,7 +139,8 @@ module KairosMcp
133
139
  mandate_id: data['mandate_id'],
134
140
  goal_name: data['goal_name'],
135
141
  invocation_context: ctx,
136
- config: data['config']
142
+ config: data['config'],
143
+ autonomous: data['autonomous'] || false
137
144
  )
138
145
  session.instance_variable_set(:@state, data['state'])
139
146
  session.instance_variable_set(:@cycle_number, data['cycle_number'] || 0)