kairos-chain 3.89.0 → 3.90.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 +42 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/bin/agent_rule.rb +45 -8
- data/templates/skillsets/agent/lib/agent/answer_ruling.rb +481 -0
- data/templates/skillsets/agent/lib/agent/session.rb +24 -3
- data/templates/skillsets/agent/lib/agent.rb +1 -0
- data/templates/skillsets/agent/plugin/SKILL.md +31 -0
- data/templates/skillsets/agent/test/test_agent_answer_rulings.rb +949 -0
- data/templates/skillsets/agent/test/test_agent_guard_verdict_record.rb +3 -0
- data/templates/skillsets/agent/test/test_agent_interruption_resilience.rb +1 -1
- data/templates/skillsets/agent/tools/agent_start.rb +1 -1
- data/templates/skillsets/agent/tools/agent_status.rb +4 -1
- data/templates/skillsets/agent/tools/agent_step.rb +189 -30
- data/templates/skillsets/agent/tools/agent_stop.rb +54 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1f38307df012164fc2070ff3a5a7489f651c678e4bcb75f4d93ef2ba65dec6d
|
|
4
|
+
data.tar.gz: 11f40f3abe1a502600d451ffb4e6fe757f1cd351fa7f9af7c4c09fe0669f776c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5886c559503ae2223da85857a13e8fa2b365271f5988c3a24b77675ce56215ca481fb581d60f82f523dfe9a1cce4fb81e8b5cfc70f81fb68beec4142183c4450
|
|
7
|
+
data.tar.gz: de3e9624a91ef9bbc9eaf3712cf64e6367f8b13523c1f3ace774cd31d0c7be4aa5560d60d7f8bfda9197802165dea0ab8a5dd7c74cb2bf666f4c903b26603249
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ 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.90.0] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added — agent: every answer is a ruling on the chain, and an answer typed at a terminal binds
|
|
10
|
+
|
|
11
|
+
Until now the gate log kept which answer advanced a session, but not who gave
|
|
12
|
+
it, why, or what it was given to. **Every answer that advances an agent session
|
|
13
|
+
is now recorded on the chain as a ruling** (`agent_answer`) — through
|
|
14
|
+
`agent_step` (approve, revise, skip, stop, adjudicate) and `agent_stop` alike.
|
|
15
|
+
|
|
16
|
+
- **What a ruling holds**: the point it answered (state, cycle, and why the
|
|
17
|
+
session was waiting), the plan's SHA-256 and the driver's own signals when a
|
|
18
|
+
plan was the subject, the act-route table in force, the decision, and whether
|
|
19
|
+
it was attested. A replayed retry writes none. A ruling that fails to record
|
|
20
|
+
never blocks the answer; the response says so under `ruling`. Reasons
|
|
21
|
+
(`rationale`, new optional argument) and revise feedback stay off the chain;
|
|
22
|
+
the ruling carries their SHA-256.
|
|
23
|
+
- **Answer as yourself, at a terminal**:
|
|
24
|
+
|
|
25
|
+
ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
|
|
26
|
+
|
|
27
|
+
It shows why the session stopped and the whole plan (every argument, escaped,
|
|
28
|
+
and the file it is kept in), asks for your answer, an optional reason and a
|
|
29
|
+
nonce, and records the answer bound to that point and that plan. The answer
|
|
30
|
+
then sent through `agent_step` there must be the same one, or it is refused
|
|
31
|
+
(`answer_refused`); **a stop always goes through**. After a revise at the
|
|
32
|
+
terminal, send `revise` without feedback and your text is used. Pass
|
|
33
|
+
`--project-dir` when the MCP server runs in another directory than the
|
|
34
|
+
project holding its data dir. An answer sent only through MCP is recorded as
|
|
35
|
+
the caller's, unattested.
|
|
36
|
+
- **Every waiting session says why it stopped** (`stop` in every response and in
|
|
37
|
+
`agent_status`). `cycle_checkpoint` means only the scheduled end-of-cycle
|
|
38
|
+
stop (Gate 8, or a manual act that succeeded and left nothing over); failed
|
|
39
|
+
acts, set-aside steps, guard halts, adjudications, risk pauses and the rest
|
|
40
|
+
name themselves.
|
|
41
|
+
- **While the chain cannot be read, every answer but a stop is refused**,
|
|
42
|
+
because whether you answered at the terminal cannot be checked. On chain
|
|
43
|
+
stores other than the file ledger (sqlite, postgresql) the agent can only be
|
|
44
|
+
stopped.
|
|
45
|
+
|
|
46
|
+
No action is needed after upgrading. Approval delegation (the shadow judge) is
|
|
47
|
+
not part of this release; do not run the agent unattended.
|
|
48
|
+
|
|
7
49
|
## [3.89.0] - 2026-10-07
|
|
8
50
|
|
|
9
51
|
### Security — agent: the act route runs from an allow-list
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
#
|
|
4
|
-
# The operator's terminal
|
|
5
|
-
#
|
|
4
|
+
# The operator's terminal rulings for the agent (design v0.3, INV-A2 / INV-D8):
|
|
5
|
+
# the act-route allow-list, and answers at a stopped session.
|
|
6
6
|
#
|
|
7
7
|
# ruby .kairos/skillsets/agent/bin/agent_rule.rb activate # rule the current table into force
|
|
8
8
|
# ruby .kairos/skillsets/agent/bin/agent_rule.rb withdraw # take it out of force
|
|
9
9
|
# ruby .kairos/skillsets/agent/bin/agent_rule.rb status # show what is in force (records nothing)
|
|
10
|
+
# ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
|
|
11
|
+
# # answer a stopped session; the MCP answer there must then match
|
|
10
12
|
#
|
|
11
|
-
# Options: --data-dir DIR
|
|
12
|
-
#
|
|
13
|
+
# Options: --data-dir DIR the instance data dir whose chain is written
|
|
14
|
+
# (default: the .kairos this script is installed in)
|
|
15
|
+
# --project-dir DIR answer only: the directory the MCP server runs in,
|
|
16
|
+
# whose .kairos/storage holds its agent sessions
|
|
17
|
+
# (default: the directory holding the data dir).
|
|
18
|
+
# Needed when the server runs with --data-dir
|
|
19
|
+
# pointing at another project's .kairos.
|
|
13
20
|
#
|
|
14
|
-
# activate and
|
|
21
|
+
# activate, withdraw and answer read a nonce typed back at /dev/tty and refuse to run
|
|
15
22
|
# without a terminal. That keeps an MCP call from producing a ruling through
|
|
16
23
|
# this path. It does not prove a person typed: any process that can start
|
|
17
24
|
# programs can open a pseudo-terminal, read the nonce and type it back (shown
|
|
@@ -21,8 +28,10 @@
|
|
|
21
28
|
|
|
22
29
|
require 'json'
|
|
23
30
|
|
|
24
|
-
action = ARGV.find { |a| %w[activate withdraw status].include?(a) }
|
|
25
|
-
abort 'usage: agent_rule.rb activate|withdraw|status [--data-dir DIR]' unless action
|
|
31
|
+
action = ARGV.find { |a| %w[activate withdraw status answer].include?(a) }
|
|
32
|
+
abort 'usage: agent_rule.rb activate|withdraw|status|answer SESSION_ID [--data-dir DIR]' unless action
|
|
33
|
+
session_id = action == 'answer' ? ARGV[ARGV.index('answer') + 1].to_s : nil
|
|
34
|
+
abort 'usage: agent_rule.rb answer SESSION_ID [--data-dir DIR]' if session_id&.then { |s| s.empty? || s.start_with?('-') }
|
|
26
35
|
|
|
27
36
|
idx = ARGV.index('--data-dir')
|
|
28
37
|
data_dir = idx ? File.expand_path(ARGV[idx + 1].to_s) : File.expand_path('../../..', __dir__)
|
|
@@ -45,6 +54,27 @@ ac = KairosMcp::SkillSets::Agent::ActClassification
|
|
|
45
54
|
path = ac::BASE_PATH
|
|
46
55
|
|
|
47
56
|
puts "Data dir: #{data_dir}"
|
|
57
|
+
if action == 'answer'
|
|
58
|
+
require 'kairos_mcp/invocation_context'
|
|
59
|
+
require File.join(lib, 'agent', 'session')
|
|
60
|
+
require File.join(lib, 'agent', 'advance_gate')
|
|
61
|
+
require File.join(lib, 'agent', 'answer_ruling')
|
|
62
|
+
# The server keeps agent sessions under <its working directory>/.kairos/storage
|
|
63
|
+
# (Session.storage_path without Autonomos loaded), and its chain under its
|
|
64
|
+
# data dir. The two can differ; the answer is read from the first and
|
|
65
|
+
# recorded on the second, the chain the server reads.
|
|
66
|
+
pidx = ARGV.index('--project-dir')
|
|
67
|
+
abort 'usage: agent_rule.rb answer SESSION_ID --project-dir DIR' if pidx && ARGV[pidx + 1].to_s.then { |v| v.empty? || v.start_with?('-') }
|
|
68
|
+
project_dir = pidx ? File.expand_path(ARGV[pidx + 1].to_s) : File.dirname(data_dir)
|
|
69
|
+
abort "no project dir at #{project_dir}" unless File.directory?(project_dir)
|
|
70
|
+
Dir.chdir(project_dir)
|
|
71
|
+
session = KairosMcp::SkillSets::Agent::Session.load(session_id)
|
|
72
|
+
unless session
|
|
73
|
+
abort "no agent session #{session_id} under #{File.join(Dir.pwd, '.kairos', 'storage', 'agent_sessions')} " \
|
|
74
|
+
'(if the MCP server runs in another directory, pass --project-dir)'
|
|
75
|
+
end
|
|
76
|
+
puts "Sessions from: #{project_dir}"
|
|
77
|
+
end
|
|
48
78
|
if action == 'status'
|
|
49
79
|
eff = ac.load_effective(path: path)
|
|
50
80
|
puts "Table #{ac::TABLE_ID}: #{eff['status']}"
|
|
@@ -62,7 +92,14 @@ rescue StandardError => e
|
|
|
62
92
|
end
|
|
63
93
|
|
|
64
94
|
begin
|
|
65
|
-
result =
|
|
95
|
+
result = if action == 'answer'
|
|
96
|
+
KairosMcp::SkillSets::Agent::AnswerRuling.interactive_answer(
|
|
97
|
+
session: session, gate: KairosMcp::SkillSets::Agent::AdvanceGate.new(session.guard_dir),
|
|
98
|
+
tty_in: tty, tty_out: tty, reload: -> { KairosMcp::SkillSets::Agent::Session.load(session_id) }
|
|
99
|
+
)
|
|
100
|
+
else
|
|
101
|
+
ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
|
|
102
|
+
end
|
|
66
103
|
ensure
|
|
67
104
|
tty.close
|
|
68
105
|
end
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module KairosMcp
|
|
8
|
+
module SkillSets
|
|
9
|
+
module Agent
|
|
10
|
+
# Every answer at an approval point is a ruling on the chain
|
|
11
|
+
# (design v0.3, INV-D4 / INV-D8).
|
|
12
|
+
#
|
|
13
|
+
# Two record kinds:
|
|
14
|
+
#
|
|
15
|
+
# agent_answer one per committed advance, written by the
|
|
16
|
+
# driver when the advance commits. It is the
|
|
17
|
+
# ruling: it exists only for a decision that
|
|
18
|
+
# was carried out, and a replayed retry never
|
|
19
|
+
# writes a second one (it never commits).
|
|
20
|
+
# agent_answer_attestation the operator's answer typed at the terminal
|
|
21
|
+
# (bin/agent_rule.rb answer), bound to the
|
|
22
|
+
# anchor it was given at. While it is the
|
|
23
|
+
# latest one at that anchor, an MCP answer
|
|
24
|
+
# there proceeds only if it is the same
|
|
25
|
+
# answer; a stop always proceeds (operator
|
|
26
|
+
# ruling 2026-10-07: stopping is never
|
|
27
|
+
# refused) and is recorded as overriding it.
|
|
28
|
+
#
|
|
29
|
+
# Texts (revise feedback, rationale) stay off the chain in the session
|
|
30
|
+
# directory; records carry their sha256.
|
|
31
|
+
#
|
|
32
|
+
# Attestation boundary (INV-D8): 'terminal_nonce' records the path the
|
|
33
|
+
# answer came by. Any party that can write the chain could write a block
|
|
34
|
+
# of the same shape; the constrained party in phase 1 is the agent.
|
|
35
|
+
module AnswerRuling
|
|
36
|
+
KIND = 'agent_answer'
|
|
37
|
+
ATTESTATION_KIND = 'agent_answer_attestation'
|
|
38
|
+
TEXTS_FILE = 'answer_texts.jsonl'
|
|
39
|
+
|
|
40
|
+
# Why a session is waiting. Written with the state change that stops it
|
|
41
|
+
# (Session#update_state / #stop_at); a stop without one reads as
|
|
42
|
+
# 'unspecified'. cycle_checkpoint is the scheduled end-of-cycle stop
|
|
43
|
+
# (Gate 8 in autonomous mode, the end of an act with nothing left over
|
|
44
|
+
# in manual mode) and nothing else.
|
|
45
|
+
STOP_KINDS = %w[
|
|
46
|
+
session_started cycle_observed plan_proposed cycle_checkpoint cycle_skipped adjudicated
|
|
47
|
+
awaiting_operator guard_halt human_cognition_halt act_failed phase_error
|
|
48
|
+
risk_exceeded goal_drift timeout llm_budget_exceeded l0_escalation
|
|
49
|
+
review_rejected review_max_retries terminated
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
# States at which the persisted plan is the subject of the answer.
|
|
53
|
+
PLAN_STATES = %w[proposed checkpoint paused_risk].freeze
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# Test seam: a callable returning [records, error]. nil in production.
|
|
57
|
+
attr_accessor :records_source
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
def sha256(text)
|
|
63
|
+
text.nil? || text.to_s.empty? ? nil : Digest::SHA256.hexdigest(text.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def plan_sha256(decision)
|
|
67
|
+
task = decision.is_a?(Hash) ? decision['task_json'] : nil
|
|
68
|
+
task.nil? ? nil : Digest::SHA256.hexdigest(JSON.generate(task))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Answer records in chain order, each with the index of its block.
|
|
72
|
+
def records_from_blocks(blocks)
|
|
73
|
+
Array(blocks).flat_map do |block|
|
|
74
|
+
data = block.respond_to?(:data) ? block.data : (block['data'] || block[:data])
|
|
75
|
+
index = block.respond_to?(:index) ? block.index : (block['index'] || block[:index])
|
|
76
|
+
Array(data).filter_map do |entry|
|
|
77
|
+
rec = entry.is_a?(String) ? (JSON.parse(entry) rescue nil) : entry
|
|
78
|
+
next unless rec.is_a?(Hash) && [KIND, ATTESTATION_KIND].include?(rec['kind'])
|
|
79
|
+
|
|
80
|
+
rec.merge('block' => index)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [records, nil], or [[], reason] when the chain cannot be read. The
|
|
86
|
+
# ledger is rewritten in place on every append, so a read that races a
|
|
87
|
+
# writer can see a torn file (measured in review, about 1 in 10,000
|
|
88
|
+
# reads under load); a read is retried before it is believed.
|
|
89
|
+
READ_ATTEMPTS = 3
|
|
90
|
+
|
|
91
|
+
def chain_records
|
|
92
|
+
return AnswerRuling.records_source.call if AnswerRuling.records_source
|
|
93
|
+
|
|
94
|
+
require 'kairos_mcp/kairos_chain/chain'
|
|
95
|
+
reason = nil
|
|
96
|
+
READ_ATTEMPTS.times do |i|
|
|
97
|
+
sleep(0.05 * i) if i.positive?
|
|
98
|
+
begin
|
|
99
|
+
chain = ::KairosMcp::KairosChain::Chain.new
|
|
100
|
+
state = chain.load_state
|
|
101
|
+
return [records_from_blocks(chain.chain), nil] if %i[readable absent].include?(state)
|
|
102
|
+
|
|
103
|
+
reason = "chain #{state}"
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
reason = "chain unreadable: #{e.class}: #{e.message[0, 120]}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
[[], reason]
|
|
109
|
+
rescue StandardError, ScriptError => e
|
|
110
|
+
[[], "chain unreadable: #{e.class}: #{e.message[0, 120]}"]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# An earlier ruling at the same anchor of the same session: only a
|
|
114
|
+
# crash between the chain write and the gate commit leaves one. The
|
|
115
|
+
# later ruling names it, so a count takes one ruling per anchor.
|
|
116
|
+
def prior_ruling_at(records, session_id, anchor)
|
|
117
|
+
Array(records).select do |r|
|
|
118
|
+
r['kind'] == KIND && r['session_id'] == session_id && r['anchor'] == anchor
|
|
119
|
+
end.last
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The operator's latest attested answer at this anchor, or nil.
|
|
123
|
+
def attestation_at(records, session_id, anchor)
|
|
124
|
+
Array(records).select do |r|
|
|
125
|
+
r['kind'] == ATTESTATION_KIND && r['attested'] == true &&
|
|
126
|
+
r['session_id'] == session_id && r['anchor'] == anchor
|
|
127
|
+
end.last
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether an answer arriving through MCP may proceed where the operator
|
|
131
|
+
# answered at the terminal. Returns { 'ok' => true, 'feedback',
|
|
132
|
+
# 'attestation' | 'overrode' } or { 'ok' => false, 'reason',
|
|
133
|
+
# 'attestation' }. A revise without feedback takes the text typed at the
|
|
134
|
+
# terminal, if its sha256 is the one the attestation names.
|
|
135
|
+
# check_plan: false for a call that targets an anchor the session has
|
|
136
|
+
# already left; such a call can only replay, and the plan has moved on.
|
|
137
|
+
def reconcile(attestation, action:, resolution: nil, feedback: nil, plan_sha256: nil, texts: nil,
|
|
138
|
+
check_plan: true)
|
|
139
|
+
return { 'ok' => true, 'feedback' => feedback } unless attestation
|
|
140
|
+
|
|
141
|
+
if action == 'stop'
|
|
142
|
+
key = attestation['decision'] == 'stop' ? 'attestation' : 'overrode'
|
|
143
|
+
return { 'ok' => true, 'feedback' => feedback, key => attestation }
|
|
144
|
+
end
|
|
145
|
+
refuse = ->(why) { { 'ok' => false, 'reason' => why, 'attestation' => attestation } }
|
|
146
|
+
unless attestation['decision'] == action
|
|
147
|
+
return refuse.call("the operator answered #{attestation['decision']} at the terminal")
|
|
148
|
+
end
|
|
149
|
+
if action == 'adjudicate' && attestation['resolution'] != resolution
|
|
150
|
+
return refuse.call("the operator adjudicated #{attestation['resolution']} at the terminal")
|
|
151
|
+
end
|
|
152
|
+
if check_plan && attestation['plan_sha256'] && attestation['plan_sha256'] != plan_sha256
|
|
153
|
+
return refuse.call('the plan is not the one the operator answered at the terminal')
|
|
154
|
+
end
|
|
155
|
+
if action == 'revise'
|
|
156
|
+
want = attestation['feedback_sha256']
|
|
157
|
+
if feedback.to_s.empty?
|
|
158
|
+
text = Array(texts&.call).reverse.map { |t| t['feedback'] }.find { |f| want && sha256(f) == want }
|
|
159
|
+
return refuse.call('the feedback typed at the terminal is not available; send it with the answer') unless text
|
|
160
|
+
|
|
161
|
+
feedback = text
|
|
162
|
+
elsif sha256(feedback) != want
|
|
163
|
+
return refuse.call('the feedback differs from the one typed at the terminal')
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
{ 'ok' => true, 'feedback' => feedback, 'attestation' => attestation }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The ruling for a committed advance. point: { 'state', 'cycle', 'stop',
|
|
170
|
+
# and when a plan is the subject 'plan_sha256', 'signals', 'set_aside' }.
|
|
171
|
+
def ruling(session_id:, mandate_id:, anchor:, point:, action:, action_key:, tables:,
|
|
172
|
+
answer: {}, feedback: nil, rationale: nil)
|
|
173
|
+
att = answer['attestation']
|
|
174
|
+
rec = {
|
|
175
|
+
'kind' => KIND, 'session_id' => session_id, 'mandate_id' => mandate_id,
|
|
176
|
+
'anchor' => anchor, 'state' => point['state'], 'cycle' => point['cycle'],
|
|
177
|
+
'stop' => point['stop'], 'decision' => action, 'decision_key' => action_key,
|
|
178
|
+
'approver' => att ? 'operator' : 'caller',
|
|
179
|
+
'attestation' => att ? 'terminal_nonce' : 'caller_unattested',
|
|
180
|
+
'tables' => tables, 'answered_at' => Time.now.utc.iso8601
|
|
181
|
+
}
|
|
182
|
+
rec['attestation_block'] = att['block'] if att
|
|
183
|
+
rec['overrode_attestation_block'] = answer['overrode']['block'] if answer['overrode']
|
|
184
|
+
rec['attestation_check'] = answer['read_error'][0, 160] if answer['read_error']
|
|
185
|
+
rec['supersedes_block'] = answer['prior_ruling_block'] if answer['prior_ruling_block']
|
|
186
|
+
%w[plan_sha256 signals set_aside].each { |k| rec[k] = point[k] if point.key?(k) }
|
|
187
|
+
rec['feedback_sha256'] = sha256(feedback) if action == 'revise'
|
|
188
|
+
# An attested ruling carries the operator's reason, typed at the
|
|
189
|
+
# terminal; a caller's text is not attributed to the operator.
|
|
190
|
+
reason = att ? att['rationale_sha256'] : sha256(rationale)
|
|
191
|
+
rec['rationale_sha256'] = reason if reason
|
|
192
|
+
rec
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Appends a record through the tool's chain_record. Never raises: a
|
|
196
|
+
# human answer is never blocked by a recording failure (INV-D4).
|
|
197
|
+
def record(tool, rec)
|
|
198
|
+
out = tool.invoke_tool('chain_record', { 'logs' => [JSON.generate(rec)] })
|
|
199
|
+
text = Array(out).map { |b| b[:text] || b['text'] }.compact.join
|
|
200
|
+
m = text.match(/Block #(\d+) recorded successfully\./)
|
|
201
|
+
return { 'recorded' => true, 'block' => m[1].to_i, 'attestation' => rec['attestation'] } if m
|
|
202
|
+
|
|
203
|
+
{ 'recorded' => false, 'error' => "chain_record replied: #{text[0, 160]}",
|
|
204
|
+
'note' => 'the answer proceeded; this point does not count as evidence for delegation' }
|
|
205
|
+
rescue StandardError, ScriptError => e
|
|
206
|
+
{ 'recorded' => false, 'error' => "#{e.class}: #{e.message[0, 160]}",
|
|
207
|
+
'note' => 'the answer proceeded; this point does not count as evidence for delegation' }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def save_texts(session_dir, anchor:, source:, feedback: nil, rationale: nil)
|
|
211
|
+
return if feedback.to_s.empty? && rationale.to_s.empty?
|
|
212
|
+
|
|
213
|
+
entry = { 'anchor' => anchor, 'source' => source, 'at' => Time.now.utc.iso8601 }
|
|
214
|
+
entry['feedback'] = feedback unless feedback.to_s.empty?
|
|
215
|
+
entry['rationale'] = rationale unless rationale.to_s.empty?
|
|
216
|
+
File.open(File.join(session_dir, TEXTS_FILE), 'a') { |f| f.puts(JSON.generate(entry)) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def texts_at(session_dir, anchor, source: 'terminal')
|
|
220
|
+
path = File.join(session_dir, TEXTS_FILE)
|
|
221
|
+
return [] unless File.exist?(path)
|
|
222
|
+
|
|
223
|
+
File.foreach(path).filter_map do |line|
|
|
224
|
+
e = JSON.parse(line) rescue nil
|
|
225
|
+
e if e.is_a?(Hash) && e['anchor'] == anchor && e['source'] == source
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Agent-authored text as it may reach the operator's terminal: control
|
|
230
|
+
# and format characters (escape sequences, carriage returns, newlines,
|
|
231
|
+
# bidirectional overrides) are shown as escapes, so the text cannot
|
|
232
|
+
# move the cursor, erase lines, or reorder what the operator reads.
|
|
233
|
+
#
|
|
234
|
+
# Invalid bytes are replaced, so no string the agent writes can make
|
|
235
|
+
# the display raise. max caps text the answer does not bind (goal,
|
|
236
|
+
# stop detail); the plan is shown whole.
|
|
237
|
+
def printable(text, max = nil)
|
|
238
|
+
s = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub('?')
|
|
239
|
+
s = s.gsub(/[[:cntrl:]\p{Cf}\p{Zl}\p{Zp}]|[\p{Zs}&&[^ ]]/) { |c| format('\\u{%x}', c.ord) }
|
|
240
|
+
max && s.length > max ? "#{s[0, max]}…" : s
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def json_text(value)
|
|
244
|
+
JSON.generate(value)
|
|
245
|
+
rescue StandardError
|
|
246
|
+
value.inspect
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Argument names that say where a step acts; shown first.
|
|
250
|
+
LOCATION_NAME = /path|file|dir|root|source|destination|target|url|name/i
|
|
251
|
+
|
|
252
|
+
# The plan exactly as the attested hash covers it: every step with
|
|
253
|
+
# every argument, location arguments first, nothing cut; any other
|
|
254
|
+
# field of the task; and the file that holds it. The agent wrote all
|
|
255
|
+
# of it, so all of it is escaped, and the summary is marked as the
|
|
256
|
+
# plan's own claim rather than something the driver observed.
|
|
257
|
+
def show_plan(tty_out, decision, state, plan_sha, decision_path)
|
|
258
|
+
lines = []
|
|
259
|
+
out = Object.new
|
|
260
|
+
out.define_singleton_method(:puts) { |line = ''| lines << line.to_s }
|
|
261
|
+
plan_lines(out, decision, state, plan_sha, decision_path)
|
|
262
|
+
lines.each { |l| tty_out.puts l }
|
|
263
|
+
show_locations(tty_out, decision['task_json'], lines, decision_path)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Where the plan acts, repeated last, next to the prompt. The agent
|
|
267
|
+
# wrote the plan above and controls its length and order, so it can
|
|
268
|
+
# push any line of it out of sight; this block is short by
|
|
269
|
+
# construction, so what the answer most depends on stays in view.
|
|
270
|
+
def show_locations(tty_out, task, lines, decision_path)
|
|
271
|
+
steps = task['steps'].is_a?(Array) ? task['steps'] : []
|
|
272
|
+
tty_out.puts
|
|
273
|
+
tty_out.puts "Where this plan acts (#{steps.size} step(s); each step's tool and location arguments):"
|
|
274
|
+
steps.each_with_index do |st, i|
|
|
275
|
+
unless st.is_a?(Hash)
|
|
276
|
+
tty_out.puts " step #{i + 1}: (not a step; read the file)"
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
args = st['tool_arguments'].is_a?(Hash) ? st['tool_arguments'] : {}
|
|
280
|
+
locs = args.select { |k, _| k.to_s.match?(LOCATION_NAME) }
|
|
281
|
+
where = if locs.empty?
|
|
282
|
+
'(no location argument)'
|
|
283
|
+
else
|
|
284
|
+
locs.map { |k, v| "#{printable(k, 30)}=#{visible(json_text(v))}" }.join(' ')
|
|
285
|
+
end
|
|
286
|
+
tty_out.puts " step #{i + 1}: #{printable(st['tool_name'], 40)} #{where}"
|
|
287
|
+
end
|
|
288
|
+
tty_out.puts "The plan above took #{lines.size} lines (#{lines.sum(&:length)} characters). If it scrolled " \
|
|
289
|
+
"away, read it whole in #{File.expand_path(decision_path)}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A location value made legible: runs of spaces shown as a count, a
|
|
293
|
+
# long value cut with a marker that says so.
|
|
294
|
+
def visible(text, max = 120)
|
|
295
|
+
s = printable(text).gsub(/ {2,}/) { |m| "[#{m.size} spaces]" }
|
|
296
|
+
s.length > max ? "#{s[0, max]} (cut; read the file)" : s
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def plan_lines(tty_out, decision, state, plan_sha, decision_path)
|
|
300
|
+
heading = { 'proposed' => 'Plan proposed (this answer decides whether it runs):',
|
|
301
|
+
'paused_risk' => 'Plan stopped at the risk gate (this answer decides whether it runs):',
|
|
302
|
+
'checkpoint' => 'Plan that has already run this cycle (approve starts the next cycle):' }
|
|
303
|
+
tty_out.puts heading[state] || 'Plan:'
|
|
304
|
+
tty_out.puts " summary, as the plan states it: #{printable(decision['summary'])}"
|
|
305
|
+
task = decision['task_json']
|
|
306
|
+
steps = task['steps']
|
|
307
|
+
if steps.is_a?(Array)
|
|
308
|
+
steps.each_with_index do |st, i|
|
|
309
|
+
unless st.is_a?(Hash)
|
|
310
|
+
tty_out.puts " step #{i + 1}: #{printable(json_text(st))}"
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
tty_out.puts " step #{i + 1}: id #{printable(st['step_id'])}, tool #{printable(st['tool_name'])}"
|
|
314
|
+
args = st['tool_arguments']
|
|
315
|
+
if args.is_a?(Hash)
|
|
316
|
+
args.keys.sort_by { |k| k.to_s.match?(LOCATION_NAME) ? 0 : 1 }.each do |k|
|
|
317
|
+
tty_out.puts " #{printable(k)}: #{printable(json_text(args[k]))}"
|
|
318
|
+
end
|
|
319
|
+
elsif !args.nil?
|
|
320
|
+
tty_out.puts " arguments: #{printable(json_text(args))}"
|
|
321
|
+
end
|
|
322
|
+
extra = st.reject { |k, _| %w[step_id tool_name tool_arguments].include?(k) }
|
|
323
|
+
tty_out.puts " other fields: #{printable(json_text(extra))}" unless extra.empty?
|
|
324
|
+
end
|
|
325
|
+
else
|
|
326
|
+
tty_out.puts " steps: #{printable(json_text(steps))}"
|
|
327
|
+
end
|
|
328
|
+
rest = task.reject { |k, _| k == 'steps' }
|
|
329
|
+
tty_out.puts " other plan fields: #{printable(json_text(rest))}" unless rest.empty?
|
|
330
|
+
tty_out.puts " The whole plan is in #{File.expand_path(decision_path)}"
|
|
331
|
+
tty_out.puts " sha256 #{plan_sha}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# The answers the terminal offers at a state.
|
|
335
|
+
def answers_for(effective_state, intent)
|
|
336
|
+
return [] if effective_state == 'terminated'
|
|
337
|
+
return %w[adjudicate stop] if intent
|
|
338
|
+
|
|
339
|
+
case effective_state
|
|
340
|
+
when 'proposed' then %w[approve revise skip stop]
|
|
341
|
+
when 'paused_risk', 'paused_error' then %w[approve skip stop]
|
|
342
|
+
else %w[approve stop]
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# ---- the terminal answer (INV-D8) ----
|
|
347
|
+
|
|
348
|
+
# Shows the stop on tty_out, reads the answer, an optional reason and
|
|
349
|
+
# the nonce on tty_in, and records the attestation only when the nonce
|
|
350
|
+
# matches and the session is still at the anchor shown. reload: a
|
|
351
|
+
# callable returning the session as persisted now. Returns
|
|
352
|
+
# { 'recorded' => bool, ... }.
|
|
353
|
+
def interactive_answer(session:, gate:, tty_in:, tty_out:, reload:, chain: nil,
|
|
354
|
+
nonce_value: ActClassification.nonce)
|
|
355
|
+
anchor = gate.current_anchor(session)
|
|
356
|
+
state = gate.effective_state(session)
|
|
357
|
+
intent = gate.unresolved_intent
|
|
358
|
+
offered = answers_for(state, intent)
|
|
359
|
+
if offered.empty?
|
|
360
|
+
tty_out.puts "Session #{session.session_id} is #{session.state}; there is nothing to answer."
|
|
361
|
+
return { 'recorded' => false, 'reason' => 'nothing_to_answer' }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
decision = session.load_decision
|
|
365
|
+
plan_sha = PLAN_STATES.include?(state) ? plan_sha256(decision) : nil
|
|
366
|
+
stop = session.stop || {}
|
|
367
|
+
tty_out.puts "Session: #{printable(session.session_id)} (#{session.autonomous? ? 'autonomous' : 'manual'})"
|
|
368
|
+
tty_out.puts "Goal: #{printable(session.goal_name)}"
|
|
369
|
+
tty_out.puts "Stopped: #{printable(session.state)}, cycle #{session.cycle_number}, " \
|
|
370
|
+
"because #{printable(session.stop_kind)}"
|
|
371
|
+
tty_out.puts " #{printable(stop['detail'], 300)}" if stop['detail']
|
|
372
|
+
tty_out.puts "Anchor: #{anchor}"
|
|
373
|
+
if intent
|
|
374
|
+
tty_out.puts 'An act started and its outcome was never recorded. Answer adjudicate (reattempt or already_done) or stop.'
|
|
375
|
+
end
|
|
376
|
+
show_plan(tty_out, decision, state, plan_sha, session.send(:decision_path)) if plan_sha
|
|
377
|
+
tty_out.puts
|
|
378
|
+
tty_out.print "Your answer (#{offered.join(' / ')}): "
|
|
379
|
+
tty_out.flush
|
|
380
|
+
action = tty_in.gets.to_s.strip.downcase
|
|
381
|
+
unless offered.include?(action)
|
|
382
|
+
tty_out.puts 'Not one of the answers offered. Nothing was recorded.'
|
|
383
|
+
return { 'recorded' => false, 'reason' => 'not_offered' }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
resolution = nil
|
|
387
|
+
feedback = nil
|
|
388
|
+
if action == 'adjudicate'
|
|
389
|
+
tty_out.print 'Resolution (reattempt / already_done): '
|
|
390
|
+
tty_out.flush
|
|
391
|
+
resolution = tty_in.gets.to_s.strip
|
|
392
|
+
unless %w[reattempt already_done].include?(resolution)
|
|
393
|
+
tty_out.puts 'Not a resolution. Nothing was recorded.'
|
|
394
|
+
return { 'recorded' => false, 'reason' => 'not_offered' }
|
|
395
|
+
end
|
|
396
|
+
elsif action == 'revise'
|
|
397
|
+
tty_out.print 'Feedback for the revision (one line): '
|
|
398
|
+
tty_out.flush
|
|
399
|
+
feedback = tty_in.gets.to_s.strip
|
|
400
|
+
if feedback.empty?
|
|
401
|
+
tty_out.puts 'A revision needs feedback. Nothing was recorded.'
|
|
402
|
+
return { 'recorded' => false, 'reason' => 'empty_feedback' }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
tty_out.print 'Reason (optional, one line, kept off the chain): '
|
|
406
|
+
tty_out.flush
|
|
407
|
+
rationale = tty_in.gets.to_s.strip
|
|
408
|
+
|
|
409
|
+
tty_out.print "Type #{nonce_value} to record this answer (anything else cancels): "
|
|
410
|
+
tty_out.flush
|
|
411
|
+
unless tty_in.gets.to_s.strip == nonce_value
|
|
412
|
+
tty_out.puts 'Cancelled. Nothing was recorded.'
|
|
413
|
+
return { 'recorded' => false, 'reason' => 'nonce_mismatch' }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
record = {
|
|
417
|
+
'kind' => ATTESTATION_KIND, 'session_id' => session.session_id, 'anchor' => anchor,
|
|
418
|
+
'state' => session.state, 'stop' => session.stop_kind, 'decision' => action,
|
|
419
|
+
'attested' => true, 'attestation' => 'terminal_nonce', 'ruled_at' => Time.now.utc.iso8601
|
|
420
|
+
}
|
|
421
|
+
record['resolution'] = resolution if resolution
|
|
422
|
+
record['plan_sha256'] = plan_sha if plan_sha
|
|
423
|
+
record['feedback_sha256'] = sha256(feedback) if feedback
|
|
424
|
+
record['rationale_sha256'] = sha256(rationale) if sha256(rationale)
|
|
425
|
+
|
|
426
|
+
# Recorded under the advance lock, and only if no advance moved the
|
|
427
|
+
# session while the operator was typing.
|
|
428
|
+
result = gate.with_lock do
|
|
429
|
+
now = reload.call
|
|
430
|
+
if now.nil? || gate.current_anchor(now) != anchor
|
|
431
|
+
next { 'recorded' => false, 'reason' => 'moved_on',
|
|
432
|
+
'detail' => "the session moved to #{now ? gate.current_anchor(now) : 'nowhere'}" }
|
|
433
|
+
end
|
|
434
|
+
# What was shown must still be what is answered: an act can open
|
|
435
|
+
# its intent, or a plan be rewritten, without the anchor moving.
|
|
436
|
+
now_state = gate.effective_state(now)
|
|
437
|
+
unless answers_for(now_state, gate.unresolved_intent).include?(action)
|
|
438
|
+
next { 'recorded' => false, 'reason' => 'moved_on',
|
|
439
|
+
'detail' => "#{action} is no longer an answer this session can take" }
|
|
440
|
+
end
|
|
441
|
+
if (PLAN_STATES.include?(now_state) ? plan_sha256(now.load_decision) : nil) != plan_sha
|
|
442
|
+
next { 'recorded' => false, 'reason' => 'moved_on', 'detail' => 'the plan changed after it was shown' }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
record['state'] = now.state
|
|
446
|
+
record['stop'] = now.stop_kind
|
|
447
|
+
save_texts(session.guard_dir, anchor: anchor, source: 'terminal', feedback: feedback, rationale: rationale)
|
|
448
|
+
chain ||= begin
|
|
449
|
+
require 'kairos_mcp/kairos_chain/chain'
|
|
450
|
+
::KairosMcp::KairosChain::Chain.new
|
|
451
|
+
end
|
|
452
|
+
begin
|
|
453
|
+
block = chain.add_block([JSON.generate(record)])
|
|
454
|
+
rescue StandardError => e
|
|
455
|
+
next { 'recorded' => false, 'reason' => 'chain_refused', 'detail' => "#{e.class}: #{e.message[0, 160]}" }
|
|
456
|
+
end
|
|
457
|
+
{ 'recorded' => true, 'block_index' => block.index, 'record' => record }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if result['status'] == 'busy'
|
|
461
|
+
tty_out.puts 'An advance is in flight on this session. Nothing was recorded; try again when it settles.'
|
|
462
|
+
return { 'recorded' => false, 'reason' => 'busy' }
|
|
463
|
+
end
|
|
464
|
+
unless result['recorded']
|
|
465
|
+
if result['reason'] == 'chain_refused'
|
|
466
|
+
tty_out.puts "The chain did not take the answer (#{result['detail']}). Nothing was recorded."
|
|
467
|
+
else
|
|
468
|
+
tty_out.puts "The session moved on while you were answering (#{result['detail']}). Nothing was recorded."
|
|
469
|
+
end
|
|
470
|
+
return result
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
tty_out.puts "Recorded as block ##{result['block_index']}."
|
|
474
|
+
tty_out.puts "Now send #{action}#{resolution ? " (#{resolution})" : ''} at this anchor (agent_step). " \
|
|
475
|
+
'A different answer here is refused; a stop always goes through.'
|
|
476
|
+
result
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|