kairos-chain 3.7.0 → 3.8.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 +36 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/config/agent.yml +7 -0
- data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +69 -9
- data/templates/skillsets/agent/lib/agent/mandate_adapter.rb +1 -1
- data/templates/skillsets/agent/lib/agent/session.rb +11 -4
- data/templates/skillsets/agent/tools/agent_start.rb +60 -9
- data/templates/skillsets/agent/tools/agent_step.rb +418 -68
- data/templates/skillsets/agent/tools/agent_stop.rb +1 -1
- data/templates/skillsets/autonomos/lib/autonomos/mandate.rb +36 -7
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7d045865d5f35b2ceba8abf858cc33219f88690feaf5591183b20154cda36e4
|
|
4
|
+
data.tar.gz: f333e43070e2268da45be99949d3c85217bfcf2a5d7d691e707fbe26f5b96796
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4951cb6216a2f85212c12882c02337fc86e2a8abd906bcfd1322ca7d23760226692608a92626ea807c7a67b44c3f94b6eadd96ef88a6125a551dcca30a951954
|
|
7
|
+
data.tar.gz: ccf714bf15d9b54219373f9a83560943fcff476cb700a459cf5d62b5c02c02ce87c0986bf81d8b5b3d7c9708b1eb58c94c5e1ecb96c5c5909ec9bf6f562c1d78
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ 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.8.0] - 2026-03-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Agent Autonomous Mode** — Multi-cycle OODA loop execution
|
|
12
|
+
- `agent_start(autonomous: true)`: Enable autonomous mode. Session starts at
|
|
13
|
+
`[observed]` as before; autonomous loop begins on `agent_step(approve)`.
|
|
14
|
+
- 8 safety gates: mandate termination, goal drift detection, wall-clock timeout
|
|
15
|
+
(300s), aggregate LLM budget (60 calls), risk budget, post-ACT termination,
|
|
16
|
+
confidence-based early exit, checkpoint pause.
|
|
17
|
+
- New session states: `autonomous_cycling`, `paused_risk`, `paused_error`
|
|
18
|
+
- Resume handlers: `approve` at `paused_risk` re-checks risk and resumes ACT;
|
|
19
|
+
`approve`/`skip` at `paused_error` skips failed cycle and continues.
|
|
20
|
+
- `agent.yml` autonomous config: `max_total_llm_calls`, `max_duration_seconds`,
|
|
21
|
+
`min_cycles_before_exit`, `confidence_exit_threshold`.
|
|
22
|
+
- Design: 2 rounds x 3 LLMs. Implementation: 1 round x 3 LLMs. All HIGH fixed.
|
|
23
|
+
|
|
24
|
+
- **Mandate locking** — `Mandate.with_lock` for single-writer batch execution
|
|
25
|
+
- File-based exclusive lock (`flock`), non-blocking with `LockError`
|
|
26
|
+
- Atomic save via tmp+rename pattern
|
|
27
|
+
- `Mandate.reload` helper for in-lock refresh
|
|
28
|
+
|
|
29
|
+
- **CognitiveLoop call tracking** — `total_calls` attribute for aggregate
|
|
30
|
+
LLM budget enforcement across autonomous cycles
|
|
31
|
+
|
|
32
|
+
- **Goal drift detection** — Content-based hash (not name-only) at mandate
|
|
33
|
+
creation; per-cycle drift check with fail-open semantics
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `run_orient_decide` / `run_act_reflect` refactored into `_internal` (Hash return)
|
|
38
|
+
+ wrapper (text_content) pattern. Manual mode behavior unchanged.
|
|
39
|
+
- Manual risk pause now sets session to `paused_risk` (was `terminated`),
|
|
40
|
+
enabling resume via `agent_step(approve)`.
|
|
41
|
+
- `MandateAdapter.to_mandate_proposal` uses `dig` for nil safety.
|
|
42
|
+
|
|
7
43
|
## [3.7.0] - 2026-03-29
|
|
8
44
|
|
|
9
45
|
### Added
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -45,5 +45,12 @@ tool_blacklist:
|
|
|
45
45
|
orient_tools_extra: []
|
|
46
46
|
# - document_status # uncomment to enable draft checking during ORIENT
|
|
47
47
|
|
|
48
|
+
# Autonomous mode limits
|
|
49
|
+
autonomous:
|
|
50
|
+
max_total_llm_calls: 60 # across all cycles in one batch
|
|
51
|
+
max_duration_seconds: 300 # wall-clock timeout per batch (5 min)
|
|
52
|
+
min_cycles_before_exit: 2 # confidence exit disabled for first N cycles
|
|
53
|
+
confidence_exit_threshold: 0.9 # minimum confidence for early exit
|
|
54
|
+
|
|
48
55
|
# Audit
|
|
49
56
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -52,6 +52,12 @@ module KairosMcp
|
|
|
52
52
|
risk_budget: {
|
|
53
53
|
type: 'string',
|
|
54
54
|
description: 'Maximum risk level: "low" or "medium" (default: "low")'
|
|
55
|
+
},
|
|
56
|
+
autonomous: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
description: 'Enable autonomous mode (default: false). ' \
|
|
59
|
+
'Session starts at [observed] regardless. ' \
|
|
60
|
+
'Autonomous loop begins on first agent_step(approve).'
|
|
55
61
|
}
|
|
56
62
|
},
|
|
57
63
|
required: ['goal_name']
|
|
@@ -63,10 +69,14 @@ module KairosMcp
|
|
|
63
69
|
max_cycles = arguments['max_cycles'] || 3
|
|
64
70
|
checkpoint_every = arguments['checkpoint_every'] || 1
|
|
65
71
|
risk_budget = arguments['risk_budget'] || 'low'
|
|
72
|
+
autonomous = arguments['autonomous'] == true
|
|
73
|
+
|
|
74
|
+
# Pre-resolve goal content for content-based drift detection hash
|
|
75
|
+
pre_obs = run_observe(goal_name)
|
|
76
|
+
goal_content_for_hash = pre_obs['goal_content'] || goal_name
|
|
77
|
+
goal_hash = Digest::SHA256.hexdigest(goal_content_for_hash)[0..15]
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
goal_hash = Digest::SHA256.hexdigest(goal_name)[0..15]
|
|
69
|
-
mandate = Autonomos::Mandate.create(
|
|
79
|
+
mandate = ::Autonomos::Mandate.create(
|
|
70
80
|
goal_name: goal_name,
|
|
71
81
|
goal_hash: goal_hash,
|
|
72
82
|
max_cycles: max_cycles,
|
|
@@ -85,11 +95,12 @@ module KairosMcp
|
|
|
85
95
|
mandate_id: mandate[:mandate_id],
|
|
86
96
|
goal_name: goal_name,
|
|
87
97
|
invocation_context: ctx,
|
|
88
|
-
config: config
|
|
98
|
+
config: config,
|
|
99
|
+
autonomous: autonomous
|
|
89
100
|
)
|
|
90
101
|
|
|
91
|
-
# OBSERVE (
|
|
92
|
-
observation =
|
|
102
|
+
# OBSERVE: reuse pre-resolved observation (avoids duplicate L2/L1 lookups)
|
|
103
|
+
observation = pre_obs
|
|
93
104
|
|
|
94
105
|
session.save_observation(observation)
|
|
95
106
|
session.update_state('observed')
|
|
@@ -100,6 +111,7 @@ module KairosMcp
|
|
|
100
111
|
'session_id' => session_id,
|
|
101
112
|
'mandate_id' => mandate[:mandate_id],
|
|
102
113
|
'state' => 'observed',
|
|
114
|
+
'autonomous' => autonomous,
|
|
103
115
|
'observation' => observation
|
|
104
116
|
}
|
|
105
117
|
|
|
@@ -151,10 +163,10 @@ module KairosMcp
|
|
|
151
163
|
# Gather observation data without LLM
|
|
152
164
|
observation = { 'goal_name' => goal_name, 'timestamp' => Time.now.iso8601 }
|
|
153
165
|
|
|
154
|
-
#
|
|
155
|
-
if defined?(Autonomos::Ooda)
|
|
166
|
+
# Load environment data via Autonomos::Ooda
|
|
167
|
+
if defined?(::Autonomos::Ooda)
|
|
156
168
|
begin
|
|
157
|
-
helper = Class.new { include Autonomos::Ooda }.new
|
|
169
|
+
helper = Class.new { include ::Autonomos::Ooda }.new
|
|
158
170
|
ooda_obs = helper.observe(goal_name)
|
|
159
171
|
observation.merge!(ooda_obs.transform_keys(&:to_s)) if ooda_obs.is_a?(Hash)
|
|
160
172
|
rescue StandardError => e
|
|
@@ -162,8 +174,47 @@ module KairosMcp
|
|
|
162
174
|
end
|
|
163
175
|
end
|
|
164
176
|
|
|
177
|
+
# Load goal content from L2/L1 so Orient has context to analyze
|
|
178
|
+
begin
|
|
179
|
+
if defined?(::Autonomos::Ooda)
|
|
180
|
+
helper = Class.new { include ::Autonomos::Ooda }.new
|
|
181
|
+
goal = helper.load_goal(goal_name)
|
|
182
|
+
else
|
|
183
|
+
goal = load_goal_fallback(goal_name)
|
|
184
|
+
end
|
|
185
|
+
if goal && goal[:found]
|
|
186
|
+
observation['goal_content'] = goal[:content]
|
|
187
|
+
observation['goal_source'] = goal[:source].to_s
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError => e
|
|
190
|
+
observation['goal_load_error'] = e.message
|
|
191
|
+
end
|
|
192
|
+
|
|
165
193
|
observation
|
|
166
194
|
end
|
|
195
|
+
|
|
196
|
+
def load_goal_fallback(goal_name)
|
|
197
|
+
# Direct L2/L1 lookup when Autonomos::Ooda is unavailable
|
|
198
|
+
if defined?(KairosMcp::ContextManager)
|
|
199
|
+
ctx_mgr = KairosMcp::ContextManager.new
|
|
200
|
+
ctx_mgr.list_sessions.each do |session|
|
|
201
|
+
entry = ctx_mgr.get_context(session[:session_id], goal_name)
|
|
202
|
+
if entry && entry.respond_to?(:content) && entry.content && !entry.content.strip.empty?
|
|
203
|
+
return { content: entry.content, found: true, source: :l2 }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
if defined?(KairosMcp::KnowledgeProvider)
|
|
208
|
+
provider = KairosMcp::KnowledgeProvider.new(nil)
|
|
209
|
+
result = provider.get(goal_name)
|
|
210
|
+
if result && result[:content] && !result[:content].strip.empty?
|
|
211
|
+
return { content: result[:content], found: true, source: :l1 }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
{ content: nil, found: false }
|
|
215
|
+
rescue StandardError
|
|
216
|
+
{ content: nil, found: false }
|
|
217
|
+
end
|
|
167
218
|
end
|
|
168
219
|
end
|
|
169
220
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'digest'
|
|
4
5
|
require_relative '../lib/agent'
|
|
5
6
|
|
|
6
7
|
module KairosMcp
|
|
@@ -96,12 +97,16 @@ module KairosMcp
|
|
|
96
97
|
|
|
97
98
|
def handle_approve(session)
|
|
98
99
|
case session.state
|
|
99
|
-
when 'observed'
|
|
100
|
-
run_orient_decide(session)
|
|
100
|
+
when 'observed', 'autonomous_cycling'
|
|
101
|
+
session.autonomous? ? run_autonomous_loop(session) : run_orient_decide(session)
|
|
101
102
|
when 'proposed'
|
|
102
103
|
run_act_reflect(session)
|
|
103
104
|
when 'checkpoint'
|
|
104
|
-
run_next_cycle(session)
|
|
105
|
+
session.autonomous? ? run_autonomous_loop(session) : run_next_cycle(session)
|
|
106
|
+
when 'paused_risk'
|
|
107
|
+
handle_resume_from_risk(session)
|
|
108
|
+
when 'paused_error'
|
|
109
|
+
handle_resume_from_error(session)
|
|
105
110
|
else
|
|
106
111
|
error_result("Cannot approve in state: #{session.state}")
|
|
107
112
|
end
|
|
@@ -113,11 +118,15 @@ module KairosMcp
|
|
|
113
118
|
end
|
|
114
119
|
|
|
115
120
|
def handle_skip(session)
|
|
116
|
-
return error_result("skip
|
|
121
|
+
return error_result("skip not valid in state: #{session.state}") unless %w[proposed paused_error].include?(session.state)
|
|
122
|
+
return handle_resume_from_error(session) if session.state == 'paused_error'
|
|
117
123
|
# Skip ACT, go directly to REFLECT with "skipped"
|
|
118
124
|
session.update_state('reflecting')
|
|
119
125
|
act_result = { 'skipped' => true, 'summary' => 'skipped' }
|
|
120
|
-
|
|
126
|
+
reflect_loop = CognitiveLoop.new(self, session)
|
|
127
|
+
messages = [{ 'role' => 'user', 'content' => build_reflect_prompt(session, act_result) }]
|
|
128
|
+
reflect_raw = reflect_loop.run_phase('reflect', reflect_system_prompt, messages, [])
|
|
129
|
+
reflect_result = reflect_raw['content'] ? parse_reflect_json(reflect_raw['content']) : { 'confidence' => 0.0 }
|
|
121
130
|
|
|
122
131
|
# Chain recording + progress (same as run_act_reflect)
|
|
123
132
|
decision_payload = session.load_decision || {}
|
|
@@ -138,98 +147,411 @@ module KairosMcp
|
|
|
138
147
|
|
|
139
148
|
# ---- ORIENT + DECIDE ----
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
# Internal version: returns Hash, never calls text_content.
|
|
151
|
+
# Used by both manual wrapper and autonomous loop.
|
|
152
|
+
# mandate_override: pass in-memory mandate from autonomous loop to avoid stale reads.
|
|
153
|
+
def run_orient_decide_internal(session, mandate_override: nil)
|
|
142
154
|
loop_inst = CognitiveLoop.new(self, session)
|
|
143
155
|
|
|
144
|
-
# Load observation (Fix #6: pass to ORIENT)
|
|
145
156
|
observation = session.load_observation
|
|
146
157
|
observation_text = observation ? JSON.generate(observation) : '(no observation data)'
|
|
147
158
|
|
|
148
|
-
# ORIENT
|
|
149
159
|
session.update_state('orienting')
|
|
150
160
|
orient_prompt = build_orient_prompt(session, observation_text)
|
|
151
161
|
messages = [{ 'role' => 'user', 'content' => orient_prompt }]
|
|
152
162
|
|
|
153
163
|
orient_result = loop_inst.run_phase('orient', orient_system_prompt, messages, orient_tools(session))
|
|
154
|
-
|
|
164
|
+
if orient_result['error']
|
|
165
|
+
session.update_state('observed')
|
|
166
|
+
session.save
|
|
167
|
+
return { error: orient_result['error'], llm_calls: loop_inst.total_calls }
|
|
168
|
+
end
|
|
155
169
|
|
|
156
|
-
# DECIDE (single-stage; see design v0.4 sec 3.3 for future extension)
|
|
157
170
|
session.update_state('deciding')
|
|
158
171
|
decide_messages = [{ 'role' => 'user', 'content' => build_decide_prompt(session, orient_result) }]
|
|
159
172
|
|
|
160
173
|
decide_result = loop_inst.run_decide(decide_system_prompt, decide_messages)
|
|
161
|
-
|
|
174
|
+
if decide_result['error']
|
|
175
|
+
session.update_state('observed')
|
|
176
|
+
session.save
|
|
177
|
+
return { error: decide_result['error'], llm_calls: loop_inst.total_calls }
|
|
178
|
+
end
|
|
162
179
|
|
|
163
|
-
# M4: Loop detection (after DECIDE, before presenting to user)
|
|
164
180
|
decision_payload = decide_result['decision_payload']
|
|
165
|
-
loop_term = check_loop_detection(session, orient_result, decision_payload
|
|
181
|
+
loop_term = check_loop_detection(session, orient_result, decision_payload,
|
|
182
|
+
mandate_override: mandate_override)
|
|
166
183
|
if loop_term
|
|
184
|
+
return { loop_detected: true, llm_calls: loop_inst.total_calls }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
session.save_decision(decision_payload)
|
|
188
|
+
{ orient: orient_result, decision_payload: decision_payload,
|
|
189
|
+
loop_detected: false, error: nil, llm_calls: loop_inst.total_calls }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Manual mode wrapper: pure format converter
|
|
193
|
+
def run_orient_decide(session)
|
|
194
|
+
result = run_orient_decide_internal(session)
|
|
195
|
+
if result[:error]
|
|
196
|
+
return error_with_state(session, 'observed', { 'error' => result[:error] })
|
|
197
|
+
end
|
|
198
|
+
if result[:loop_detected]
|
|
167
199
|
return text_content(JSON.generate({
|
|
168
200
|
'status' => 'terminated', 'reason' => 'loop_detected',
|
|
169
201
|
'session_id' => session.session_id
|
|
170
202
|
}))
|
|
171
203
|
end
|
|
172
204
|
|
|
173
|
-
# Fix #1: persist decision for proposed→ACT transition
|
|
174
|
-
session.save_decision(decision_payload)
|
|
175
205
|
session.update_state('proposed')
|
|
176
206
|
session.save
|
|
177
|
-
|
|
178
207
|
text_content(JSON.generate({
|
|
179
208
|
'status' => 'ok', 'session_id' => session.session_id,
|
|
180
209
|
'state' => 'proposed',
|
|
181
|
-
'orient' => summarize_orient(
|
|
182
|
-
'decision_payload' =>
|
|
210
|
+
'orient' => summarize_orient(result[:orient]),
|
|
211
|
+
'decision_payload' => result[:decision_payload]
|
|
183
212
|
}))
|
|
184
213
|
end
|
|
185
214
|
|
|
186
215
|
# ---- ACT + REFLECT ----
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
proposal = MandateAdapter.to_mandate_proposal(decision_payload)
|
|
194
|
-
mandate = Autonomos::Mandate.load(session.mandate_id)
|
|
195
|
-
if Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
|
|
196
|
-
Autonomos::Mandate.update_status(session.mandate_id, 'paused_risk_exceeded')
|
|
197
|
-
session.update_state('terminated')
|
|
198
|
-
session.save
|
|
199
|
-
return text_content(JSON.generate({
|
|
200
|
-
'status' => 'paused', 'reason' => 'risk_exceeded',
|
|
201
|
-
'session_id' => session.session_id, 'state' => 'terminated'
|
|
202
|
-
}))
|
|
203
|
-
end
|
|
217
|
+
# Internal version: returns Hash, never calls text_content.
|
|
218
|
+
# Increments cycle and saves session. Does NOT set final state.
|
|
219
|
+
def run_act_reflect_internal(session)
|
|
220
|
+
decision_payload = session.load_decision
|
|
221
|
+
return { act_error: 'No decision payload found', llm_calls: 0 } unless decision_payload
|
|
204
222
|
|
|
205
|
-
# ACT via autoexec with derived context
|
|
206
223
|
session.update_state('acting')
|
|
207
224
|
act_result = run_act(session, decision_payload)
|
|
208
225
|
|
|
209
|
-
# REFLECT
|
|
210
226
|
session.update_state('reflecting')
|
|
211
|
-
|
|
227
|
+
reflect_loop = CognitiveLoop.new(self, session)
|
|
228
|
+
messages = [{ 'role' => 'user', 'content' => build_reflect_prompt(session, act_result) }]
|
|
229
|
+
reflect_raw = reflect_loop.run_phase('reflect', reflect_system_prompt, messages, [])
|
|
230
|
+
reflect_result = if reflect_raw['content']
|
|
231
|
+
parse_reflect_json(reflect_raw['content'])
|
|
232
|
+
else
|
|
233
|
+
{ 'confidence' => 0.0, 'error' => reflect_raw['error'] || 'no content' }
|
|
234
|
+
end
|
|
212
235
|
|
|
213
|
-
# Record cycle
|
|
214
236
|
record_agent_cycle(session, decision_payload, act_result, reflect_result)
|
|
215
237
|
|
|
216
|
-
# M5: Save cumulative progress after REFLECT (1-based cycle numbering)
|
|
217
238
|
act_summary = act_result['summary'] || act_result['error'] || 'completed'
|
|
218
239
|
decision_summary = decision_payload['summary'] || ''
|
|
219
240
|
session.save_progress(reflect_result, session.cycle_number + 1, act_summary, decision_summary)
|
|
220
241
|
|
|
221
242
|
session.increment_cycle
|
|
222
|
-
session.update_state('checkpoint')
|
|
223
243
|
session.save
|
|
224
244
|
|
|
245
|
+
act_succeeded = !act_result['error'] && act_result['summary'] != 'failed'
|
|
246
|
+
|
|
247
|
+
{ act: act_result, reflect: reflect_result, cycle: session.cycle_number,
|
|
248
|
+
act_error: act_result['error'], act_succeeded: act_succeeded,
|
|
249
|
+
llm_calls: reflect_loop.total_calls }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Manual mode wrapper: pure format converter
|
|
253
|
+
def run_act_reflect(session)
|
|
254
|
+
decision_payload = load_last_decision(session)
|
|
255
|
+
return error_result("No decision payload found") unless decision_payload
|
|
256
|
+
|
|
257
|
+
proposal = MandateAdapter.to_mandate_proposal(decision_payload)
|
|
258
|
+
mandate = ::Autonomos::Mandate.load(session.mandate_id)
|
|
259
|
+
if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
|
|
260
|
+
::Autonomos::Mandate.update_status(session.mandate_id, 'paused_risk_exceeded')
|
|
261
|
+
session.update_state('paused_risk')
|
|
262
|
+
session.save
|
|
263
|
+
return text_content(JSON.generate({
|
|
264
|
+
'status' => 'paused', 'reason' => 'risk_exceeded',
|
|
265
|
+
'session_id' => session.session_id, 'state' => 'paused_risk'
|
|
266
|
+
}))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
result = run_act_reflect_internal(session)
|
|
270
|
+
session.update_state('checkpoint')
|
|
271
|
+
session.save
|
|
225
272
|
text_content(JSON.generate({
|
|
226
273
|
'status' => 'ok', 'session_id' => session.session_id,
|
|
227
274
|
'state' => 'checkpoint',
|
|
228
|
-
'act_summary' =>
|
|
229
|
-
'reflect' =>
|
|
275
|
+
'act_summary' => result.dig(:act, 'summary') || 'completed',
|
|
276
|
+
'reflect' => result[:reflect]
|
|
230
277
|
}))
|
|
231
278
|
end
|
|
232
279
|
|
|
280
|
+
# ---- AUTONOMOUS LOOP ----
|
|
281
|
+
|
|
282
|
+
def run_autonomous_loop(session)
|
|
283
|
+
auto_cfg = session.config['autonomous'] || {}
|
|
284
|
+
max_total_llm = auto_cfg['max_total_llm_calls'] || 60
|
|
285
|
+
max_duration = auto_cfg['max_duration_seconds'] || 300
|
|
286
|
+
min_exit_cycles = auto_cfg['min_cycles_before_exit'] || 2
|
|
287
|
+
confidence_threshold = auto_cfg['confidence_exit_threshold'] || 0.9
|
|
288
|
+
|
|
289
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
290
|
+
total_llm_calls = 0
|
|
291
|
+
results = []
|
|
292
|
+
|
|
293
|
+
::Autonomos::Mandate.with_lock(session.mandate_id) do |mandate|
|
|
294
|
+
while session.cycle_number < (mandate[:max_cycles] || 3)
|
|
295
|
+
session.update_state('autonomous_cycling')
|
|
296
|
+
session.save
|
|
297
|
+
|
|
298
|
+
# Gate 1: Mandate termination
|
|
299
|
+
term_reason = ::Autonomos::Mandate.check_termination(mandate)
|
|
300
|
+
if term_reason
|
|
301
|
+
mandate[:status] = 'terminated'
|
|
302
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
303
|
+
session.update_state('terminated')
|
|
304
|
+
return finalize_autonomous(session, results, terminated: term_reason)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Gate 2: Goal drift
|
|
308
|
+
if goal_drifted?(session, mandate)
|
|
309
|
+
mandate[:status] = 'paused_goal_drift'
|
|
310
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
311
|
+
session.update_state('checkpoint')
|
|
312
|
+
session.save
|
|
313
|
+
return finalize_autonomous(session, results, checkpoint: true,
|
|
314
|
+
warning: 'goal_content_changed')
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Gate 3: Wall-clock timeout
|
|
318
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
319
|
+
if elapsed > max_duration
|
|
320
|
+
session.update_state('checkpoint')
|
|
321
|
+
session.save
|
|
322
|
+
return finalize_autonomous(session, results, checkpoint: true,
|
|
323
|
+
paused: 'timeout')
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Gate 4: Aggregate LLM budget
|
|
327
|
+
if total_llm_calls >= max_total_llm
|
|
328
|
+
session.update_state('checkpoint')
|
|
329
|
+
session.save
|
|
330
|
+
return finalize_autonomous(session, results, checkpoint: true,
|
|
331
|
+
paused: 'llm_budget_exceeded')
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# OBSERVE (cycle 2+; cycle 1 already observed by agent_start)
|
|
335
|
+
if session.cycle_number > 0
|
|
336
|
+
observation = run_observe_for_next_cycle(session)
|
|
337
|
+
session.save_observation(observation)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ORIENT + DECIDE (pass in-memory mandate to avoid stale reads)
|
|
341
|
+
od_result = run_orient_decide_internal(session, mandate_override: mandate)
|
|
342
|
+
total_llm_calls += od_result[:llm_calls] || 0
|
|
343
|
+
if od_result[:error]
|
|
344
|
+
session.update_state('paused_error')
|
|
345
|
+
session.save
|
|
346
|
+
return finalize_autonomous(session, results, error: od_result[:error])
|
|
347
|
+
end
|
|
348
|
+
if od_result[:loop_detected]
|
|
349
|
+
session.update_state('terminated')
|
|
350
|
+
return finalize_autonomous(session, results, terminated: 'loop_detected')
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Gate 5: Risk budget (after loop detection, existing order)
|
|
354
|
+
decision_payload = session.load_decision
|
|
355
|
+
proposal = MandateAdapter.to_mandate_proposal(decision_payload)
|
|
356
|
+
if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
|
|
357
|
+
mandate[:status] = 'paused_risk_exceeded'
|
|
358
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
359
|
+
session.update_state('paused_risk')
|
|
360
|
+
session.save
|
|
361
|
+
return finalize_autonomous(session, results, paused: 'risk_exceeded')
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# ACT + REFLECT
|
|
365
|
+
ar_result = run_act_reflect_internal(session)
|
|
366
|
+
total_llm_calls += ar_result[:llm_calls] || 0
|
|
367
|
+
results << ar_result
|
|
368
|
+
if ar_result[:act_error]
|
|
369
|
+
session.update_state('paused_error')
|
|
370
|
+
session.save
|
|
371
|
+
return finalize_autonomous(session, results, paused: 'act_failed',
|
|
372
|
+
error: ar_result[:act_error])
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Gate 6: Post-ACT termination (record_cycle may have incremented errors)
|
|
376
|
+
mandate = ::Autonomos::Mandate.reload(session.mandate_id)
|
|
377
|
+
term_reason = ::Autonomos::Mandate.check_termination(mandate)
|
|
378
|
+
if term_reason
|
|
379
|
+
session.update_state('terminated')
|
|
380
|
+
return finalize_autonomous(session, results, terminated: term_reason)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Gate 7: Confidence-based early exit
|
|
384
|
+
if session.cycle_number >= min_exit_cycles
|
|
385
|
+
confidence = clamp_confidence(ar_result.dig(:reflect, 'confidence'))
|
|
386
|
+
remaining = ar_result.dig(:reflect, 'remaining')
|
|
387
|
+
if confidence >= confidence_threshold &&
|
|
388
|
+
remaining.is_a?(Array) && remaining.empty? &&
|
|
389
|
+
ar_result[:act_succeeded]
|
|
390
|
+
session.update_state('terminated')
|
|
391
|
+
return finalize_autonomous(session, results, terminated: 'goal_achieved')
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Gate 8: Checkpoint pause
|
|
396
|
+
checkpoint_every = mandate[:checkpoint_every] || 1
|
|
397
|
+
if session.cycle_number > 0 &&
|
|
398
|
+
(session.cycle_number % checkpoint_every).zero?
|
|
399
|
+
mandate[:status] = 'paused_at_checkpoint'
|
|
400
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
401
|
+
session.update_state('checkpoint')
|
|
402
|
+
session.save
|
|
403
|
+
return finalize_autonomous(session, results, checkpoint: true)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# All cycles exhausted (inside lock)
|
|
408
|
+
session.update_state('terminated')
|
|
409
|
+
session.save
|
|
410
|
+
finalize_autonomous(session, results, terminated: 'max_cycles_reached')
|
|
411
|
+
end
|
|
412
|
+
rescue ::Autonomos::Mandate::LockError => e
|
|
413
|
+
error_result("Session locked: #{e.message}")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def finalize_autonomous(session, cycle_results, terminated: nil, paused: nil,
|
|
417
|
+
checkpoint: nil, error: nil, warning: nil)
|
|
418
|
+
session.save
|
|
419
|
+
|
|
420
|
+
status = if checkpoint then 'checkpoint'
|
|
421
|
+
elsif paused then 'paused'
|
|
422
|
+
elsif error then 'error'
|
|
423
|
+
else 'completed'
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
text_content(JSON.generate({
|
|
427
|
+
'status' => status,
|
|
428
|
+
'session_id' => session.session_id,
|
|
429
|
+
'state' => session.state,
|
|
430
|
+
'cycles_completed' => session.cycle_number,
|
|
431
|
+
'terminated_reason' => terminated,
|
|
432
|
+
'paused_reason' => paused,
|
|
433
|
+
'error' => error,
|
|
434
|
+
'warning' => warning,
|
|
435
|
+
'cycle_results' => cycle_results.map { |r|
|
|
436
|
+
{ 'cycle' => r[:cycle],
|
|
437
|
+
'act_summary' => r.dig(:act, 'summary') || 'completed',
|
|
438
|
+
'confidence' => clamp_confidence(r.dig(:reflect, 'confidence')),
|
|
439
|
+
'remaining_count' => Array(r.dig(:reflect, 'remaining')).size }
|
|
440
|
+
}
|
|
441
|
+
}))
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def clamp_confidence(raw)
|
|
445
|
+
val = raw.to_f
|
|
446
|
+
[[val, 0.0].max, 1.0].min
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def goal_drifted?(session, mandate)
|
|
450
|
+
current_goal = load_goal_content(session.goal_name)
|
|
451
|
+
current_hash = Digest::SHA256.hexdigest(current_goal || session.goal_name)[0..15]
|
|
452
|
+
current_hash != mandate[:goal_hash].to_s
|
|
453
|
+
rescue StandardError
|
|
454
|
+
false
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def load_goal_content(goal_name)
|
|
458
|
+
# Use Ooda (same path as agent_start's run_observe)
|
|
459
|
+
if defined?(::Autonomos::Ooda)
|
|
460
|
+
helper = Class.new { include ::Autonomos::Ooda }.new
|
|
461
|
+
goal = helper.load_goal(goal_name)
|
|
462
|
+
return goal[:content] if goal && goal[:found]
|
|
463
|
+
end
|
|
464
|
+
# Fallback: direct L1 lookup (matches agent_start's load_goal_fallback)
|
|
465
|
+
if defined?(KairosMcp::KnowledgeProvider)
|
|
466
|
+
provider = KairosMcp::KnowledgeProvider.new(nil)
|
|
467
|
+
result = provider.get(goal_name)
|
|
468
|
+
return result[:content] if result && result[:content] && !result[:content].strip.empty?
|
|
469
|
+
end
|
|
470
|
+
nil
|
|
471
|
+
rescue StandardError
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# ---- RESUME HANDLERS ----
|
|
476
|
+
|
|
477
|
+
def handle_resume_from_risk(session)
|
|
478
|
+
mandate = ::Autonomos::Mandate.load(session.mandate_id)
|
|
479
|
+
decision_payload = session.load_decision
|
|
480
|
+
return error_result("No decision to re-check") unless decision_payload
|
|
481
|
+
|
|
482
|
+
proposal = MandateAdapter.to_mandate_proposal(decision_payload)
|
|
483
|
+
if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
|
|
484
|
+
return text_content(JSON.generate({
|
|
485
|
+
'status' => 'still_paused', 'reason' => 'risk_still_exceeded',
|
|
486
|
+
'session_id' => session.session_id,
|
|
487
|
+
'hint' => 'Update mandate risk_budget or call stop'
|
|
488
|
+
}))
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Resume: risk now within budget. Execute the paused proposal (ACT+REFLECT),
|
|
492
|
+
# not a new ORIENT+DECIDE cycle. decision_payload is already saved.
|
|
493
|
+
mandate[:status] = 'active'
|
|
494
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
495
|
+
|
|
496
|
+
if session.autonomous?
|
|
497
|
+
# In autonomous mode, run ACT+REFLECT for the paused proposal,
|
|
498
|
+
# then continue the autonomous loop from next cycle.
|
|
499
|
+
ar_result = run_act_reflect_internal(session)
|
|
500
|
+
if ar_result[:act_error]
|
|
501
|
+
session.update_state('paused_error')
|
|
502
|
+
session.save
|
|
503
|
+
return finalize_autonomous(session, [ar_result], paused: 'act_failed',
|
|
504
|
+
error: ar_result[:act_error])
|
|
505
|
+
end
|
|
506
|
+
# Continue to next cycle in autonomous loop
|
|
507
|
+
session.update_state('observed')
|
|
508
|
+
session.save
|
|
509
|
+
run_autonomous_loop(session)
|
|
510
|
+
else
|
|
511
|
+
# Manual mode: resume at ACT+REFLECT for the existing proposal
|
|
512
|
+
result = run_act_reflect_internal(session)
|
|
513
|
+
session.update_state('checkpoint')
|
|
514
|
+
session.save
|
|
515
|
+
text_content(JSON.generate({
|
|
516
|
+
'status' => 'ok', 'session_id' => session.session_id,
|
|
517
|
+
'state' => 'checkpoint',
|
|
518
|
+
'act_summary' => result.dig(:act, 'summary') || 'completed',
|
|
519
|
+
'reflect' => result[:reflect]
|
|
520
|
+
}))
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def handle_resume_from_error(session)
|
|
525
|
+
# Record skipped cycle in mandate before advancing
|
|
526
|
+
begin
|
|
527
|
+
::Autonomos::Mandate.record_cycle(
|
|
528
|
+
session.mandate_id,
|
|
529
|
+
cycle_id: "#{session.session_id}_cycle#{session.cycle_number}_skipped",
|
|
530
|
+
evaluation: 'failed'
|
|
531
|
+
)
|
|
532
|
+
rescue StandardError => e
|
|
533
|
+
warn "[agent] Failed to record skipped cycle: #{e.message}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Do NOT increment session.cycle_number here — the next
|
|
537
|
+
# run_act_reflect_internal will do it after a successful cycle.
|
|
538
|
+
# We only record the skip in mandate.
|
|
539
|
+
observation = run_observe_for_next_cycle(session)
|
|
540
|
+
session.save_observation(observation)
|
|
541
|
+
session.update_state('observed')
|
|
542
|
+
session.save
|
|
543
|
+
|
|
544
|
+
if session.autonomous?
|
|
545
|
+
run_autonomous_loop(session)
|
|
546
|
+
else
|
|
547
|
+
text_content(JSON.generate({
|
|
548
|
+
'status' => 'ok', 'session_id' => session.session_id,
|
|
549
|
+
'state' => 'observed', 'cycle' => session.cycle_number + 1,
|
|
550
|
+
'observation' => observation
|
|
551
|
+
}))
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
233
555
|
def run_act(session, decision_payload)
|
|
234
556
|
act_ctx = session.invocation_context.derive(
|
|
235
557
|
blacklist_remove: %w[autoexec_plan autoexec_run]
|
|
@@ -265,20 +587,21 @@ module KairosMcp
|
|
|
265
587
|
{ 'error' => "ACT failed: #{e.message}" }
|
|
266
588
|
end
|
|
267
589
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
590
|
+
# Parse REFLECT response, handling code fences and nested JSON
|
|
591
|
+
def parse_reflect_json(content)
|
|
592
|
+
# Try direct parse first
|
|
593
|
+
JSON.parse(content)
|
|
594
|
+
rescue JSON::ParserError
|
|
595
|
+
# Try extracting from code fences
|
|
596
|
+
if content =~ /```(?:json)?\s*\n?(.*?)\n?\s*```/m
|
|
274
597
|
begin
|
|
275
|
-
JSON.parse(
|
|
598
|
+
return JSON.parse($1)
|
|
276
599
|
rescue JSON::ParserError
|
|
277
|
-
|
|
600
|
+
# fall through
|
|
278
601
|
end
|
|
279
|
-
else
|
|
280
|
-
{ 'confidence' => 0.0, 'error' => result['error'] || 'no content' }
|
|
281
602
|
end
|
|
603
|
+
# Last resort: confidence 0.0 with raw content preserved
|
|
604
|
+
{ 'confidence' => 0.0, 'raw' => content }
|
|
282
605
|
end
|
|
283
606
|
|
|
284
607
|
# ---- NEXT CYCLE ----
|
|
@@ -287,12 +610,12 @@ module KairosMcp
|
|
|
287
610
|
# checkpoint_due? is checked BEFORE reaching [checkpoint] (in run_act_reflect).
|
|
288
611
|
# When the user approves at [checkpoint], we always proceed to the next cycle.
|
|
289
612
|
def run_next_cycle(session)
|
|
290
|
-
mandate = Autonomos::Mandate.load(session.mandate_id)
|
|
613
|
+
mandate = ::Autonomos::Mandate.load(session.mandate_id)
|
|
291
614
|
|
|
292
615
|
# Check termination conditions
|
|
293
|
-
term_reason = Autonomos::Mandate.check_termination(mandate)
|
|
616
|
+
term_reason = ::Autonomos::Mandate.check_termination(mandate)
|
|
294
617
|
if term_reason
|
|
295
|
-
Autonomos::Mandate.update_status(session.mandate_id, 'terminated')
|
|
618
|
+
::Autonomos::Mandate.update_status(session.mandate_id, 'terminated')
|
|
296
619
|
session.update_state('terminated')
|
|
297
620
|
session.save
|
|
298
621
|
return text_content(JSON.generate({
|
|
@@ -355,31 +678,29 @@ module KairosMcp
|
|
|
355
678
|
|
|
356
679
|
# M4: Loop detection using decision_payload['summary'] as canonical gap
|
|
357
680
|
# description (matches autonomos_loop's approach).
|
|
358
|
-
#
|
|
359
|
-
def check_loop_detection(session, _orient_result, decision_payload)
|
|
360
|
-
mandate = Autonomos::Mandate.load(session.mandate_id)
|
|
681
|
+
# Accepts optional in-memory mandate to avoid stale reads in autonomous mode.
|
|
682
|
+
def check_loop_detection(session, _orient_result, decision_payload, mandate_override: nil)
|
|
683
|
+
mandate = mandate_override || ::Autonomos::Mandate.load(session.mandate_id)
|
|
361
684
|
return nil unless mandate
|
|
362
685
|
|
|
363
|
-
# Use decision summary as gap description (same source as proposal)
|
|
364
686
|
gap_desc = decision_payload['summary'] || 'unknown'
|
|
365
687
|
recent_gaps = Array(mandate[:recent_gap_descriptions])
|
|
366
688
|
recent_gaps_updated = (recent_gaps + [gap_desc]).last(3)
|
|
367
689
|
|
|
368
690
|
proposal = MandateAdapter.to_mandate_proposal(decision_payload)
|
|
369
691
|
|
|
370
|
-
if Autonomos::Mandate.loop_detected?(proposal, recent_gaps)
|
|
371
|
-
# Single save: update both status and gap history atomically
|
|
692
|
+
if ::Autonomos::Mandate.loop_detected?(proposal, recent_gaps)
|
|
372
693
|
mandate[:status] = 'terminated'
|
|
373
694
|
mandate[:recent_gap_descriptions] = recent_gaps_updated
|
|
374
|
-
Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
695
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
375
696
|
session.update_state('terminated')
|
|
376
697
|
session.save
|
|
377
698
|
return true
|
|
378
699
|
end
|
|
379
700
|
|
|
380
|
-
# Update gap history
|
|
701
|
+
# Update gap history in-memory and on disk
|
|
381
702
|
mandate[:recent_gap_descriptions] = recent_gaps_updated
|
|
382
|
-
Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
703
|
+
::Autonomos::Mandate.save(session.mandate_id, mandate)
|
|
383
704
|
nil
|
|
384
705
|
rescue StandardError => e
|
|
385
706
|
warn "[agent] Loop detection failed: #{e.message}"
|
|
@@ -390,7 +711,7 @@ module KairosMcp
|
|
|
390
711
|
|
|
391
712
|
def record_agent_cycle(session, decision_payload, act_result, reflect_result)
|
|
392
713
|
evaluation = MandateAdapter.reflect_to_evaluation(reflect_result)
|
|
393
|
-
Autonomos::Mandate.record_cycle(
|
|
714
|
+
::Autonomos::Mandate.record_cycle(
|
|
394
715
|
session.mandate_id,
|
|
395
716
|
cycle_id: "#{session.session_id}_cycle#{session.cycle_number}",
|
|
396
717
|
evaluation: evaluation
|
|
@@ -472,6 +793,7 @@ module KairosMcp
|
|
|
472
793
|
# Build a filtered tool catalog for DECIDE prompt.
|
|
473
794
|
# Uses session's InvocationContext.allowed? for blacklist/whitelist
|
|
474
795
|
# consistency with the ACT phase execution policy.
|
|
796
|
+
# Includes parameter schemas so DECIDE LLM generates correct tool_arguments.
|
|
475
797
|
def build_tool_catalog(session)
|
|
476
798
|
return "(no registry available)" unless @registry
|
|
477
799
|
|
|
@@ -480,12 +802,40 @@ module KairosMcp
|
|
|
480
802
|
tools = tools.reject { |t| ctx && !ctx.allowed?(t[:name]) } if ctx
|
|
481
803
|
|
|
482
804
|
tools.map { |t|
|
|
483
|
-
|
|
484
|
-
params_str = required.empty? ? '' : " (params: #{required.join(', ')})"
|
|
485
|
-
"- **#{t[:name]}**#{params_str}: #{t[:description]}"
|
|
805
|
+
format_tool_entry(t)
|
|
486
806
|
}.join("\n")
|
|
487
807
|
end
|
|
488
808
|
|
|
809
|
+
# Format a single tool entry with parameter details for DECIDE.
|
|
810
|
+
def format_tool_entry(tool)
|
|
811
|
+
schema = tool[:inputSchema] || {}
|
|
812
|
+
required_names = extract_required_params(schema)
|
|
813
|
+
properties = schema[:properties] || schema['properties'] || {}
|
|
814
|
+
|
|
815
|
+
lines = ["- **#{tool[:name]}**: #{tool[:description]}"]
|
|
816
|
+
|
|
817
|
+
unless properties.empty?
|
|
818
|
+
req_parts = []
|
|
819
|
+
opt_parts = []
|
|
820
|
+
properties.each do |param_name, param_def|
|
|
821
|
+
pname = param_name.to_s
|
|
822
|
+
ptype = param_def['type'] || param_def[:type] || '?'
|
|
823
|
+
pdesc = param_def['description'] || param_def[:description]
|
|
824
|
+
short_desc = pdesc ? pdesc.to_s[0..60] : nil
|
|
825
|
+
entry = short_desc ? "#{pname} (#{ptype}: #{short_desc})" : "#{pname} (#{ptype})"
|
|
826
|
+
if required_names.include?(pname)
|
|
827
|
+
req_parts << entry
|
|
828
|
+
else
|
|
829
|
+
opt_parts << entry
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
lines << " Required: #{req_parts.join(', ')}" unless req_parts.empty?
|
|
833
|
+
lines << " Optional: #{opt_parts.join(', ')}" unless opt_parts.empty?
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
lines.join("\n")
|
|
837
|
+
end
|
|
838
|
+
|
|
489
839
|
# Extract required parameter names from an inputSchema hash.
|
|
490
840
|
def extract_required_params(schema)
|
|
491
841
|
return [] unless schema.is_a?(Hash)
|
|
@@ -19,6 +19,8 @@ module Autonomos
|
|
|
19
19
|
|
|
20
20
|
RISK_BUDGETS = %w[low medium].freeze
|
|
21
21
|
|
|
22
|
+
class LockError < StandardError; end
|
|
23
|
+
|
|
22
24
|
class << self
|
|
23
25
|
def create(goal_name:, goal_hash:, max_cycles:, checkpoint_every:, risk_budget:)
|
|
24
26
|
validate_params!(max_cycles, checkpoint_every, risk_budget)
|
|
@@ -48,21 +50,43 @@ module Autonomos
|
|
|
48
50
|
|
|
49
51
|
def load(mandate_id)
|
|
50
52
|
validate_id!(mandate_id)
|
|
51
|
-
|
|
52
|
-
path = File.join(mandates_dir, "#{mandate_id}.json")
|
|
53
|
+
path = mandate_path(mandate_id)
|
|
53
54
|
return nil unless File.exist?(path)
|
|
54
55
|
|
|
55
56
|
JSON.parse(File.read(path), symbolize_names: true)
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Alias for clarity when reloading inside with_lock
|
|
60
|
+
def reload(mandate_id)
|
|
61
|
+
load(mandate_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
58
64
|
def save(mandate_id, mandate)
|
|
59
65
|
validate_id!(mandate_id)
|
|
60
|
-
|
|
66
|
+
path = mandate_path(mandate_id)
|
|
61
67
|
mandate[:updated_at] = Time.now.iso8601
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
68
|
+
# Atomic write via tmp+rename
|
|
69
|
+
tmp = "#{path}.tmp.#{$$}.#{Thread.current.object_id}"
|
|
70
|
+
File.write(tmp, JSON.pretty_generate(mandate))
|
|
71
|
+
File.rename(tmp, path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Single-writer lock for autonomous batch execution.
|
|
75
|
+
# Yields the loaded mandate; caller must use reload() to refresh after internal saves.
|
|
76
|
+
def with_lock(mandate_id)
|
|
77
|
+
validate_id!(mandate_id)
|
|
78
|
+
lock_path = mandate_path(mandate_id) + '.lock'
|
|
79
|
+
File.open(lock_path, File::CREAT | File::RDWR) do |f|
|
|
80
|
+
unless f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
81
|
+
raise LockError, "Mandate #{mandate_id} is locked by another process"
|
|
82
|
+
end
|
|
83
|
+
begin
|
|
84
|
+
mandate = load(mandate_id)
|
|
85
|
+
yield mandate
|
|
86
|
+
ensure
|
|
87
|
+
f.flock(File::LOCK_UN)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
66
90
|
end
|
|
67
91
|
|
|
68
92
|
def update_status(mandate_id, new_status)
|
|
@@ -179,6 +203,11 @@ module Autonomos
|
|
|
179
203
|
"mnd_#{Time.now.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(3)}"
|
|
180
204
|
end
|
|
181
205
|
|
|
206
|
+
def mandate_path(mandate_id)
|
|
207
|
+
mandates_dir = Autonomos.storage_path('mandates')
|
|
208
|
+
File.join(mandates_dir, "#{mandate_id}.json")
|
|
209
|
+
end
|
|
210
|
+
|
|
182
211
|
def validate_id!(mandate_id)
|
|
183
212
|
unless mandate_id.to_s.match?(/\A[\w\-]+\z/)
|
|
184
213
|
raise ArgumentError, 'Invalid mandate_id: must contain only word characters and hyphens'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kairos-chain
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Masaomi Hatakeyama
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -450,7 +450,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
450
450
|
- !ruby/object:Gem::Version
|
|
451
451
|
version: '0'
|
|
452
452
|
requirements: []
|
|
453
|
-
rubygems_version: 3.
|
|
453
|
+
rubygems_version: 3.5.22
|
|
454
454
|
signing_key:
|
|
455
455
|
specification_version: 4
|
|
456
456
|
summary: KairosChain - Self-referential MCP server for auditable skill self-management
|