kairos-chain 3.96.0 → 3.96.1
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 +37 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +13 -4
- data/templates/skillsets/agent/lib/agent/hooks.rb +178 -4
- data/templates/skillsets/agent/lib/agent/review.rb +185 -25
- data/templates/skillsets/agent/lib/agent/run.rb +1 -1
- data/templates/skillsets/agent/lib/agent/seats.rb +634 -0
- data/templates/skillsets/agent/lib/agent/service.rb +37 -4
- data/templates/skillsets/agent/plugin/SKILL.md +11 -1
- data/templates/skillsets/agent/skillset.json +5 -2
- data/templates/skillsets/agent/test/test_agent1_hooks_service.rb +3 -1
- data/templates/skillsets/agent/test/test_agent1_persona_seats.rb +850 -0
- data/templates/skillsets/agent/tools/agent_advance.rb +1 -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: faa4327412fbbe72c412e3e4cccd41173a346e488366b0d4963023cb389ce97e
|
|
4
|
+
data.tar.gz: 13767304ee185463ac1a1de1293873762c3658f81e8a5c6d007023ca29970377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 657b3094d640bd4876964d3ffd88f47fd57fda12147c77d882293c68662d66a791c306d8eaae4fd4238041f641c318dc7825bb0ff6c314b2c963086146a7eb17
|
|
7
|
+
data.tar.gz: 2f765af6b6122a2a3627678f3481a410be0bc0f359bb7b479a6e726fe6f1891264c023d42007840d1fd75e03d175109b78a164647d6402f771322fff8a3783d6
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,43 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.96.1] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
### Fixed — agent runs convene the persona seat their reviews declared none for (agent 1.0.2)
|
|
12
|
+
|
|
13
|
+
A run review dispatched multi_llm_review with `persona_count_declared: 0` and collected with no persona reviews,
|
|
14
|
+
so every round counted four seats where the operator's roster has five, and the report said so in one line.
|
|
15
|
+
|
|
16
|
+
- Each review round declares three personas (design: architect, security, operability; implementation:
|
|
17
|
+
correctness, performance, api-design; document: ontologist, skeptic, integration) and stores their prompts —
|
|
18
|
+
multi_llm_review's own canonical prompt for the same bundle, under a persona header — in run storage.
|
|
19
|
+
- The actor launches each seat with a one-line marker that the PreToolUse hook replaces with the seat's prompt.
|
|
20
|
+
From then on the subagent is a seat for its whole life, recognised by the host's meta file naming that launch;
|
|
21
|
+
it may read only its prompt file and hand back. Messages and stops aimed at a seat are refused.
|
|
22
|
+
- Each seat is settled at its SubagentStop from the hand-back the hooks kept, before the hazard and question
|
|
23
|
+
readers; agent_advance settles launches the host never started or never stopped. A seat whose transcript
|
|
24
|
+
shows another prompt is not submitted.
|
|
25
|
+
- agent_advance waits about 540 s per call for running seats, submits each settled reply with the verdict and
|
|
26
|
+
findings multi_llm_review's own parser reads from it, one per line the seat wrote, so a finding's severity
|
|
27
|
+
and consequence come from the same line, and gives the stand-in each persona's own reply. A finding whose
|
|
28
|
+
consequence clause (or location) lies past the point where multi_llm_review cuts a persona finding is
|
|
29
|
+
rebuilt with the clause and its location after a shortened body, so the cut does not record a P0 as one
|
|
30
|
+
that stated no consequence. A seat never launched holds the round only until the first reminder; missing
|
|
31
|
+
seats become a shortfall named in the report, and seats launched late or never are named on the review's
|
|
32
|
+
closure item. Until the round collects or its token expires the Stop hook sends the actor back to
|
|
33
|
+
agent_advance (three times without an advance between, and once more after a turn in which a seat started or
|
|
34
|
+
ended, then the run rests), so the round collects before its token expires. From ten minutes before the
|
|
35
|
+
token's end no seat is launched or asked for. Collect closes the round's seats under the lock that admits
|
|
36
|
+
launches and settles its remaining launches, so no launch outlives the round and the seat refusals end with
|
|
37
|
+
it; agent_stop closes the seats of a round that never collected. A close that fails because the seat lock
|
|
38
|
+
cannot be taken is journaled as such (`seats_close_failed`) and the settled replies are still submitted. A settled seat may be stopped; it is
|
|
39
|
+
never messaged.
|
|
40
|
+
- The seats run on the session's own plan; no policy spend counter counts them.
|
|
41
|
+
- `depends_on` pins multi_llm_review >= 0.12.0. L1 multi_llm_review_workflow 3.17.1.
|
|
42
|
+
- Design: `log/agent_persona_seats_design_v0.3_20261022.md` (two design review rounds, four implementation
|
|
43
|
+
review rounds and one falsification check; the P1s of each fixed). Probed live on one host: three seats
|
|
44
|
+
launched through the rewrite, all three submitted, five seats counted.
|
|
45
|
+
|
|
9
46
|
## [3.96.0] - 2026-10-21
|
|
10
47
|
|
|
11
48
|
### Changed — multi_llm_review says what the roster was when a result is read (multi_llm_review 0.12.0, llm_client 0.4.0)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: multi_llm_review_workflow
|
|
3
3
|
description: "Multi-LLM review methodology and execution — workflow pattern, CLI tooling, consensus analysis, Persona Assembly. Applicable to design, implementation, documentation, or any artifact."
|
|
4
|
-
version: "3.17.
|
|
4
|
+
version: "3.17.1"
|
|
5
5
|
tags:
|
|
6
6
|
- workflow
|
|
7
7
|
- review
|
|
@@ -896,9 +896,11 @@ A counted seat's maker is observed (the seat reported its model), configured (th
|
|
|
896
896
|
roster pins the model and the seat ran on its own provider — this holds while the
|
|
897
897
|
CLI's own configuration does not route that name to another maker), or
|
|
898
898
|
caller-declared (a persona seat that was not observed). Only the first two
|
|
899
|
-
establish diversity.
|
|
900
|
-
whose caller names a slot (that slot does not count)
|
|
901
|
-
|
|
899
|
+
establish diversity. One kind of round always carries the line: an `exclude` round
|
|
900
|
+
whose caller names a slot (that slot does not count). An agent SkillSet round no
|
|
901
|
+
longer always carries it: agent 1.0.2 convenes three personas on the host and
|
|
902
|
+
submits the replies the host handed back, where 1.0.1 submitted none, so its line
|
|
903
|
+
follows the same rule as any other round. None of this changes the verdict or the quorum
|
|
902
904
|
(`min_quorum`, 2 shipped). An INSUFFICIENT result also says how many seats counted
|
|
903
905
|
and how many were needed, that a seat can be added in config, and the restoring
|
|
904
906
|
action for each reason in the table above.
|
|
@@ -2117,6 +2119,13 @@ Compression ratio: parallel agent raw → Assembly ≈ 2:1
|
|
|
2117
2119
|
multi_llm_review 0.12.0 and llm_client 0.4.0. Design
|
|
2118
2120
|
`log/mlr_non_claude_host_design_v0.3.3_20261021.md`, frozen after four rounds.
|
|
2119
2121
|
|
|
2122
|
+
- Agent runs convene persona seats (v3.17.1, 2026-10-22, operator request:
|
|
2123
|
+
「Agent SkillSet1.0.1でreview時にpersona数が0に決め打ちされてるそうですが、MLRの設定と異なるので
|
|
2124
|
+
修正して下さい」). § Reduced rosters no longer says an agent SkillSet round always
|
|
2125
|
+
carries the roster line: agent 1.0.2 declares and convenes three personas, launched
|
|
2126
|
+
by the run's actor through a marker the SkillSet's hook rewrites, and submits the
|
|
2127
|
+
replies the host handed back. Design `log/agent_persona_seats_design_v0.3_20261022.md`.
|
|
2128
|
+
|
|
2120
2129
|
**Key insight**: Design reviews and implementation reviews find
|
|
2121
2130
|
**categorically different bugs**. Both phases are necessary. The corollary that
|
|
2122
2131
|
cost eight rounds to learn: **which of the two you are in is readable from the
|
|
@@ -10,6 +10,7 @@ require_relative 'stand_in'
|
|
|
10
10
|
require_relative 'precedents'
|
|
11
11
|
require_relative 'policies'
|
|
12
12
|
require_relative 'transcript'
|
|
13
|
+
require_relative 'seats'
|
|
13
14
|
|
|
14
15
|
module KairosMcp
|
|
15
16
|
module SkillSets
|
|
@@ -32,6 +33,13 @@ module KairosMcp
|
|
|
32
33
|
"(#{run.target_repo}) as a new file — each version is a new file. Then call " \
|
|
33
34
|
"agent_advance(run_id: '#{id}', phase: '#{phase.name}')."
|
|
34
35
|
when :review
|
|
36
|
+
seats = Seats.instruction(run, phase.name.to_s)
|
|
37
|
+
return seats if seats
|
|
38
|
+
if run.in_flight_review
|
|
39
|
+
return "Phase #{phase.name}: a review round is running. Call agent_advance(run_id: '#{id}', " \
|
|
40
|
+
"phase: '#{phase.name}') to collect it."
|
|
41
|
+
end
|
|
42
|
+
|
|
35
43
|
answer = plan.phase(phase.args[:of])&.kind == :produce ? 'writing the next version as a new file' : 'new commits on the run branch'
|
|
36
44
|
"Phase #{phase.name}: call agent_advance(run_id: '#{id}', phase: '#{phase.name}'). It dispatches the next " \
|
|
37
45
|
"review round of the latest #{phase.args[:of]} artifact and, on the next call, returns the seats' findings " \
|
|
@@ -136,6 +144,11 @@ module KairosMcp
|
|
|
136
144
|
sid = input['session_id']
|
|
137
145
|
touch_guard_seen(sid)
|
|
138
146
|
tool = input['tool_name'].to_s
|
|
147
|
+
# A review seat's own calls are placed before anything else, the
|
|
148
|
+
# agent-tool shortcut included (design v0.3 §3.5).
|
|
149
|
+
if input['agent_id'] && (out = seat_tool_gate(input, tool))
|
|
150
|
+
return out
|
|
151
|
+
end
|
|
139
152
|
if Hooks.agent_tool?(tool)
|
|
140
153
|
# The operator's words after an ending release the guard before the
|
|
141
154
|
# agent tool runs (agent_start right after them is not refused).
|
|
@@ -150,6 +163,9 @@ module KairosMcp
|
|
|
150
163
|
return nil unless run
|
|
151
164
|
return nil if release_if_typed(run, sid)
|
|
152
165
|
|
|
166
|
+
seat = seat_launch_gate(run, input, tool)
|
|
167
|
+
return seat if seat
|
|
168
|
+
|
|
153
169
|
policy = policy_for(run)
|
|
154
170
|
unless policy
|
|
155
171
|
return deny("the policy pinned by run #{run.id} changed or is missing; the guard cannot decide. " \
|
|
@@ -379,6 +395,9 @@ module KairosMcp
|
|
|
379
395
|
def stop(input, subagent:)
|
|
380
396
|
run = Run.bound_to(@paths, input['session_id'])
|
|
381
397
|
return nil unless run
|
|
398
|
+
# A review seat's end settles it, before any other Stop-hook path:
|
|
399
|
+
# its reply is a review, not a hazard report or a question.
|
|
400
|
+
return nil if subagent && seat_stop(run, input) == :seat
|
|
382
401
|
return nil if release_if_typed(run, input['session_id'])
|
|
383
402
|
return nil if run.status == 'finished'
|
|
384
403
|
|
|
@@ -401,7 +420,10 @@ module KairosMcp
|
|
|
401
420
|
end
|
|
402
421
|
return out if out
|
|
403
422
|
end
|
|
404
|
-
|
|
423
|
+
# Background tasks let the actor rest, except while persona seats of
|
|
424
|
+
# a dispatch are out: then the round must reach agent_advance before
|
|
425
|
+
# its token expires.
|
|
426
|
+
return nil if !subagent && Array(input['background_tasks']).any? && !Seats.holding_dispatch(run.journal.entries)
|
|
405
427
|
if live && QUESTION_END.match?(tail.strip)
|
|
406
428
|
answer = prose_question(run, policy, tail)
|
|
407
429
|
return block("Stand-in answer (provisional, journaled): #{answer}. Continue the run.") if answer
|
|
@@ -430,11 +452,28 @@ module KairosMcp
|
|
|
430
452
|
progressed = run.last_block_seq.nil? || run.last_progress_seq.to_i > run.last_block_seq.to_i ||
|
|
431
453
|
(last_block && last_block['fingerprint'] && last_block['fingerprint'] != fingerprint)
|
|
432
454
|
if run.in_flight_review
|
|
455
|
+
# While persona seats are out, each agent_advance call (which waits
|
|
456
|
+
# for them) earns one more block, until the round collects.
|
|
457
|
+
entries = run.journal.entries
|
|
458
|
+
if Seats.holding_dispatch(entries)
|
|
459
|
+
# Blocked every time until the round collects, so the call that
|
|
460
|
+
# collects comes before the token expires; three blocks with no
|
|
461
|
+
# agent_advance between them is an actor not following, and the
|
|
462
|
+
# run rests rather than spin. Only what an agent_advance call
|
|
463
|
+
# journals counts as one between them: a seat launching or
|
|
464
|
+
# settling is not the actor calling it.
|
|
465
|
+
last_wait = entries.reverse.find { |e| %w[seats_prompted seats_waited review_dispatched].include?(e['kind']) }
|
|
466
|
+
since = entries.count { |e| e['kind'] == 'stop_blocked' && e['for'] == 'persona_seats' && e['seq'] > last_wait.to_h['seq'].to_i }
|
|
467
|
+
if since < Seats::HOLD_BLOCKS
|
|
468
|
+
run.append(:stop_blocked, for: 'persona_seats', fingerprint: fingerprint)
|
|
469
|
+
return block("Run #{run.id}: #{Guidance.next_step(run)}")
|
|
470
|
+
end
|
|
471
|
+
end
|
|
433
472
|
# Once per progress: a round that never comes back rests the run.
|
|
434
|
-
last =
|
|
435
|
-
unless last && last['for']
|
|
473
|
+
last = entries.reverse.find { |e| e['kind'] == 'stop_blocked' }
|
|
474
|
+
unless last && %w[review_in_flight persona_seats].include?(last['for']) && !progressed
|
|
436
475
|
run.append(:stop_blocked, for: 'review_in_flight', fingerprint: fingerprint)
|
|
437
|
-
return block("Run #{run.id}:
|
|
476
|
+
return block("Run #{run.id}: #{Guidance.next_step(run)}")
|
|
438
477
|
end
|
|
439
478
|
end
|
|
440
479
|
if progressed
|
|
@@ -523,6 +562,141 @@ module KairosMcp
|
|
|
523
562
|
File.exist?(f) && (Time.now - File.mtime(f)) <= within
|
|
524
563
|
end
|
|
525
564
|
|
|
565
|
+
# --- persona seats (design log/agent_persona_seats_design_v0.3) ------------
|
|
566
|
+
|
|
567
|
+
# A subagent's own tool call while the run has seat launches: a seat
|
|
568
|
+
# may hand back and read its own prompt file, nothing else, and a
|
|
569
|
+
# subagent whose launch cannot be read is held to that while a seat is
|
|
570
|
+
# out. nil when the call is not a seat's.
|
|
571
|
+
def seat_tool_gate(input, tool)
|
|
572
|
+
run = Run.bound_to(@paths, input['session_id'])
|
|
573
|
+
return nil unless run
|
|
574
|
+
|
|
575
|
+
entries = run.journal.entries
|
|
576
|
+
return nil unless Seats.any_launch?(entries)
|
|
577
|
+
|
|
578
|
+
kind, launch = Seats.identify(entries, input)
|
|
579
|
+
return nil if kind == :other
|
|
580
|
+
return nil if kind == :unknown && Seats.unsettled_launches(entries).empty?
|
|
581
|
+
|
|
582
|
+
if tool == Seats::HANDBACK
|
|
583
|
+
Seats.keep_handback(run, input['agent_id'], input.dig('tool_input', 'message')) if kind == :seat
|
|
584
|
+
return allow
|
|
585
|
+
end
|
|
586
|
+
return allow if kind == :seat && tool == 'Read' && seat_reads_own_prompt?(run, launch, input['tool_input'], input['cwd'] || Dir.pwd)
|
|
587
|
+
|
|
588
|
+
if kind == :seat
|
|
589
|
+
deny("A review seat reads only its prompt file and answers with its hand-back; #{tool} is refused to it " \
|
|
590
|
+
'(INV-2). Answer from the prompt.')
|
|
591
|
+
else
|
|
592
|
+
deny('The run cannot tell whether this subagent is a review seat (its host meta file names no launch) while ' \
|
|
593
|
+
"a seat is out, so it is held to a seat's tools (INV-2).")
|
|
594
|
+
end
|
|
595
|
+
rescue StandardError, ScriptError => e
|
|
596
|
+
deny("agent seat gate error (#{e.class}): #{e.message[0, 200]}")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def seat_reads_own_prompt?(run, launch, tool_input, cwd = Dir.pwd)
|
|
600
|
+
given = tool_input.is_a?(Hash) ? tool_input['file_path'].to_s : ''
|
|
601
|
+
!given.empty? && File.expand_path(given, cwd) == File.expand_path(Seats.prompt_path(run, launch['full_sha256'].to_s))
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# A launch of a seat by its marker, from any caller, and messages or
|
|
605
|
+
# stops aimed at a seat. nil when neither applies.
|
|
606
|
+
def seat_launch_gate(run, input, tool)
|
|
607
|
+
if Seats::LAUNCH_TOOLS.include?(tool)
|
|
608
|
+
prompt = input.dig('tool_input', 'prompt').to_s.strip
|
|
609
|
+
return nil unless prompt.start_with?(Seats::MARKER_PREFIX)
|
|
610
|
+
|
|
611
|
+
return Seats.locked(run) { launch_seat(run, input, prompt) }
|
|
612
|
+
end
|
|
613
|
+
return nil unless Seats::TARGET_TOOLS.include?(tool)
|
|
614
|
+
|
|
615
|
+
entries = run.journal.entries
|
|
616
|
+
return nil unless Seats.any_launch?(entries)
|
|
617
|
+
|
|
618
|
+
ti = input['tool_input'].is_a?(Hash) ? input['tool_input'] : {}
|
|
619
|
+
targets = Seats::TARGET_KEYS.map { |k| ti[k] }.select { |v| v.is_a?(String) && !v.empty? }
|
|
620
|
+
out = Seats.unsettled_launches(entries).any?
|
|
621
|
+
targets.each do |t|
|
|
622
|
+
kind, launch = Seats.identify(entries, input, t)
|
|
623
|
+
if kind == :seat
|
|
624
|
+
# A settled seat's reply is frozen, so stopping it changes no
|
|
625
|
+
# record; a message still could reach a reply, so it stays refused.
|
|
626
|
+
next if tool == 'TaskStop' && Seats.settlement_for(entries, launch)
|
|
627
|
+
|
|
628
|
+
return deny("#{tool} to a review seat is refused: a seat is neither messaged nor stopped by the run (INV-3).")
|
|
629
|
+
end
|
|
630
|
+
if kind == :unknown && out
|
|
631
|
+
return deny("#{tool} to #{t} is refused while a review seat is out: the run cannot tell whether it is a seat (INV-3).")
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
nil
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Admits one launch of a seat (under the run's seat lock) and replaces
|
|
638
|
+
# the marker with the seat's short prompt; every other key the caller
|
|
639
|
+
# gave is dropped.
|
|
640
|
+
def launch_seat(run, input, prompt)
|
|
641
|
+
if input['tool_use_id'].to_s.empty?
|
|
642
|
+
return deny('A review seat cannot be launched from a call the host gave no tool_use_id: the seat could not be bound to its launch.')
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
entries = run.journal.entries
|
|
646
|
+
live = Seats.live_markers(run.id, entries)
|
|
647
|
+
seat = live[prompt]
|
|
648
|
+
listing = live.empty? ? 'There is no seat to launch now.' : "Live markers:\n#{live.keys.join("\n")}"
|
|
649
|
+
unless seat
|
|
650
|
+
spent = Seats.spent_reason(run.id, entries, prompt)
|
|
651
|
+
return deny("This seat is no longer launched: #{spent}. #{listing}") if spent
|
|
652
|
+
|
|
653
|
+
return deny("Not a live review-seat marker. #{listing}")
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
why = Seats.launch_refusal(entries, seat)
|
|
657
|
+
return deny("Refused: #{why}. #{listing}") if why
|
|
658
|
+
|
|
659
|
+
path = Seats.prompt_path(run, seat['sha256'])
|
|
660
|
+
unless File.file?(path) && Digest::SHA256.file(path).hexdigest == seat['sha256']
|
|
661
|
+
return deny("The stored prompt of seat #{seat['persona']} no longer matches its dispatch; the seat cannot be launched.")
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
short = Seats.short_prompt(run, seat, Seats.dispatch_for(entries, seat['token']))
|
|
665
|
+
run.append(:seat_launched, token: seat['token'], persona: seat['persona'], phase: seat['phase'], round: seat['round'],
|
|
666
|
+
tool_use_id: input['tool_use_id'], full_sha256: seat['sha256'],
|
|
667
|
+
prompt_sha256: Digest::SHA256.hexdigest(short), session_id: input['session_id'],
|
|
668
|
+
subagents_dir: Seats.subagents_dir(input))
|
|
669
|
+
updated = { 'description' => "review seat #{seat['persona']}", 'prompt' => short, 'subagent_type' => 'general-purpose' }
|
|
670
|
+
effort = input['effort'].is_a?(Hash) ? input['effort']['level'] : input['effort']
|
|
671
|
+
updated['effort'] = effort if Seats::EFFORTS.include?(effort)
|
|
672
|
+
allow(updated_input: updated)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Settles a seat at its first stop, and journals a subagent the run
|
|
676
|
+
# cannot place while a seat is out. :seat when the stop was a seat's.
|
|
677
|
+
def seat_stop(run, input)
|
|
678
|
+
entries = run.journal.entries
|
|
679
|
+
return nil unless Seats.any_launch?(entries)
|
|
680
|
+
|
|
681
|
+
kind, launch = Seats.identify(entries, input)
|
|
682
|
+
if kind == :seat
|
|
683
|
+
begin
|
|
684
|
+
reply = Seats.kept_handback(run, input['agent_id']) || input['last_assistant_message']
|
|
685
|
+
cause = Seats.first_prompt_sha256(Seats.host_files(input)) == launch['prompt_sha256'] ? nil : 'prompt_mismatch'
|
|
686
|
+
Seats.settle(run, launch, agent_id: input['agent_id'], reply: reply, cause: cause)
|
|
687
|
+
rescue StandardError, ScriptError => e
|
|
688
|
+
run.append(:seat_settle_failed, token: launch['token'], persona: launch['persona'], tool_use_id: launch['tool_use_id'],
|
|
689
|
+
reason: "#{e.class}: #{e.message[0, 200]}")
|
|
690
|
+
end
|
|
691
|
+
return :seat
|
|
692
|
+
end
|
|
693
|
+
if kind == :unknown && Seats.unsettled_launches(entries).any?
|
|
694
|
+
run.append(:subagent_unidentified, agent_id: input['agent_id'], agent_type: input['agent_type'],
|
|
695
|
+
cause: 'its host meta file names no launch')
|
|
696
|
+
end
|
|
697
|
+
nil
|
|
698
|
+
end
|
|
699
|
+
|
|
526
700
|
# A session whose run another session took over: its guard refuses
|
|
527
701
|
# what would act, until the operator speaks here again or the session
|
|
528
702
|
# ends, so its turn does not continue unguarded.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'digest'
|
|
5
5
|
require 'fileutils'
|
|
6
|
+
require_relative 'seats'
|
|
6
7
|
|
|
7
8
|
module KairosMcp
|
|
8
9
|
module SkillSets
|
|
@@ -43,7 +44,8 @@ module KairosMcp
|
|
|
43
44
|
# revision_diff: callable(previous review_round entry, this one) -> the
|
|
44
45
|
# actor's change between the two rounds' artifacts, as text.
|
|
45
46
|
def initialize(run:, policy:, stand_in:, invoke:, project_root:, words: -> { [] }, precedents: ->(_t) { [] },
|
|
46
|
-
head: nil, type: nil, revision_diff: ->(_a, _b) { '' }
|
|
47
|
+
head: nil, type: nil, revision_diff: ->(_a, _b) { '' }, clock: -> { Time.now },
|
|
48
|
+
sleep: ->(s) { Kernel.sleep(s) })
|
|
47
49
|
@run = run
|
|
48
50
|
@policy = policy
|
|
49
51
|
@stand_in = stand_in
|
|
@@ -54,6 +56,8 @@ module KairosMcp
|
|
|
54
56
|
@head = head
|
|
55
57
|
@type = type&.to_s
|
|
56
58
|
@revision_diff = revision_diff
|
|
59
|
+
@clock = clock
|
|
60
|
+
@sleep = sleep
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def credentials?(text)
|
|
@@ -124,58 +128,200 @@ module KairosMcp
|
|
|
124
128
|
end
|
|
125
129
|
clear_blocking(name)
|
|
126
130
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
prior = carry.map { |f| { 'id' => f['id'], 'severity' => 'P0', 'issue' => f['text'][0, 400] } }
|
|
132
|
+
seats = compose_seats(name, round, review_type, artifact_name, bundle, prior)
|
|
133
|
+
request = {
|
|
134
|
+
'artifact_name' => artifact_name, 'review_type' => review_type,
|
|
135
|
+
'artifact_content' => bundle, 'review_round' => round,
|
|
136
|
+
'orchestrator_strategy' => 'delegate', 'orchestrator_model' => 'claude-opus-5-5',
|
|
137
|
+
'persona_count_declared' => seats.size, 'escalate' => true, 'complexity' => 'high',
|
|
138
|
+
'prior_findings' => prior
|
|
139
|
+
}
|
|
140
|
+
request['collect_deadline_seconds_override'] = Seats::DEADLINE_SECONDS unless seats.empty?
|
|
141
|
+
payload = @invoke.call('multi_llm_review', request)
|
|
134
142
|
token = payload['collect_token']
|
|
135
143
|
return { 'status' => 'error', 'reason' => payload['error'] || 'no collect token' } unless token
|
|
136
144
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
entry = { phase: name, round: round, token: token,
|
|
146
|
+
bundle_sha256: Digest::SHA256.hexdigest(bundle), artifact_digest: artifact_digest,
|
|
147
|
+
bundle_file: store_bundle(name, round, bundle) }.merge(extra)
|
|
148
|
+
# Persona seats exist in the delegated protocol only; the seats, the
|
|
149
|
+
# token and its deadline are journaled in the one dispatch entry.
|
|
150
|
+
if !seats.empty? && payload['status'] == 'delegation_pending'
|
|
151
|
+
entry[:seats] = seats
|
|
152
|
+
entry[:must_collect_by] = payload['must_collect_by']
|
|
153
|
+
end
|
|
154
|
+
@run.append(:review_dispatched, entry)
|
|
140
155
|
{ 'status' => 'dispatched', 'round' => round,
|
|
141
156
|
'next' => "Round #{round} is running (about 5-10 minutes). Call agent_advance again to collect it." }
|
|
142
157
|
end
|
|
143
158
|
|
|
159
|
+
# The persona seats' full prompts, stored under their digests; none
|
|
160
|
+
# (the round runs as 1.0.1 did) when they cannot be composed.
|
|
161
|
+
def compose_seats(phase, round, review_type, artifact_name, bundle, prior)
|
|
162
|
+
composed = Seats.compose(review_type: review_type, artifact_name: artifact_name, bundle: bundle, round: round,
|
|
163
|
+
prior: prior, worktree: @run.worktree, branch: @run.branch)
|
|
164
|
+
Seats.store_prompts(@run, composed)
|
|
165
|
+
rescue StandardError, ScriptError => e
|
|
166
|
+
@run.append(:seats_not_composed, phase: phase, round: round, reason: "#{e.class}: #{e.message[0, 300]}")
|
|
167
|
+
[]
|
|
168
|
+
end
|
|
169
|
+
|
|
144
170
|
# Collects whatever the round produced, also after a worker crash: a
|
|
145
171
|
# round with quorum is a round.
|
|
146
172
|
def collect(phase, pending)
|
|
147
173
|
name = phase.name.to_s
|
|
148
|
-
|
|
174
|
+
budget_end = @clock.call + Seats::WAIT_SECONDS
|
|
175
|
+
with_seats = pending['seats'].is_a?(Array) && !pending['seats'].empty?
|
|
176
|
+
if with_seats
|
|
177
|
+
held = seat_gate(pending, budget_end)
|
|
178
|
+
return held if held
|
|
179
|
+
end
|
|
180
|
+
left = (budget_end - @clock.call).to_i.clamp(30, Seats::WAIT_SECONDS)
|
|
181
|
+
wait = @invoke.call('multi_llm_review_wait', { 'collect_token' => pending['token'], 'max_wait_seconds' => left })
|
|
149
182
|
st = wait['status'].to_s
|
|
150
183
|
if WAITING.include?(st)
|
|
184
|
+
# Each call that waited earns the Stop hook's next block (§3.7).
|
|
185
|
+
@run.append(:seats_waited, phase: name, round: pending['round'], token: pending['token'], running: 0) if with_seats
|
|
151
186
|
return { 'status' => 'pending', 'detail' => st, 'next' => 'The round is still running. Call agent_advance again.' }
|
|
152
187
|
end
|
|
153
188
|
|
|
154
|
-
|
|
155
|
-
|
|
189
|
+
reviews, persona_rows, outcomes = with_seats ? seat_submission(pending) : [[], [], nil]
|
|
190
|
+
result = collect_call(pending['token'], reviews)
|
|
191
|
+
if result['status'] == 'error' && !reviews.empty? && result['error'].to_s.start_with?('invalid orchestrator_reviews')
|
|
192
|
+
# MLR's refusal of a persona submission leaves the token
|
|
193
|
+
# collectable: the external seats' replies are kept.
|
|
194
|
+
@run.append(:seat_submission_refused, phase: name, round: pending['round'], token: pending['token'],
|
|
195
|
+
detail: result['error'].to_s[0, 600])
|
|
196
|
+
outcomes.each { |o| o.merge!('submitted' => false, 'cause' => 'refused_by_review_tool') if o['submitted'] }
|
|
197
|
+
result = collect_call(pending['token'], [])
|
|
198
|
+
end
|
|
156
199
|
if result['status'] != 'ok'
|
|
157
200
|
detail = "#{st}: #{result['error'] || result['status']}"
|
|
158
|
-
@run.append(:review_dispatch_failed, phase: name, round: pending['round'], detail: detail)
|
|
201
|
+
@run.append(:review_dispatch_failed, phase: name, round: pending['round'], token: pending['token'], detail: detail)
|
|
159
202
|
return { 'status' => 'dispatch_failed', 'detail' => detail, 'next' => 'Call agent_advance again to dispatch the round anew.' }
|
|
160
203
|
end
|
|
161
204
|
seats = Array(result['reviews']).map do |r|
|
|
162
205
|
{ 'seat' => r['role_label'].to_s, 'verdict' => r['verdict'].to_s, 'reply' => (r['raw_text'] || r['raw_text_excerpt']).to_s }
|
|
163
206
|
end
|
|
207
|
+
# What the stand-in reads: each persona's own reply in place of the
|
|
208
|
+
# review tool's assembled persona row (INV-3).
|
|
209
|
+
team = Array(result['reviews']).find { |r| r['synthetic'] || r.key?('persona_rows') }
|
|
210
|
+
team_label = team ? team['role_label'].to_s : 'claude_team'
|
|
211
|
+
read = if with_seats
|
|
212
|
+
seats.reject { |s| s['seat'] == team_label } +
|
|
213
|
+
persona_rows.map { |r| { 'seat' => "#{team_label}/#{r['persona']}", 'verdict' => r['verdict'], 'reply' => r['reply'] } }
|
|
214
|
+
else
|
|
215
|
+
seats
|
|
216
|
+
end
|
|
164
217
|
p0_lines = Array(result['aggregated_findings']).select { |f| f.is_a?(Hash) && f['severity'].to_s.upcase == 'P0' }
|
|
165
218
|
.map { |f| (f['issue'] || f['text']).to_s[0, 600] }
|
|
166
219
|
counted = result.dig('vote_tally', 'successful_count').to_i
|
|
167
220
|
quorum = result.dig('vote_tally', 'min_quorum').to_i.nonzero? || 2
|
|
168
|
-
file = store_replies(name, pending['round'],
|
|
169
|
-
@run.append(:review_round, phase: name, round: pending['round'],
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
221
|
+
file = store_replies(name, pending['round'], read, p0_lines)
|
|
222
|
+
@run.append(:review_round, { phase: name, round: pending['round'], token: pending['token'], counted: counted,
|
|
223
|
+
quorum: quorum, wait_status: st, commit: pending['commit'], replies_file: file,
|
|
224
|
+
bundle_sha256: pending['bundle_sha256'], artifact: pending['artifact'],
|
|
225
|
+
bundle_file: pending['bundle_file'], artifact_digest: pending['artifact_digest'],
|
|
226
|
+
seats: seats.map { |s| { 'seat' => s['seat'], 'verdict' => s['verdict'],
|
|
227
|
+
'reply_sha256' => Digest::SHA256.hexdigest(s['reply']),
|
|
228
|
+
'reply' => s['reply'][0, 4000] } } }
|
|
229
|
+
.merge(with_seats ? { personas: outcomes, persona_count_declared: pending['seats'].size } : {}))
|
|
176
230
|
label_round(phase, @run.journal.entries.reverse.find { |e| e['kind'] == 'review_round' && e['phase'] == name })
|
|
177
231
|
end
|
|
178
232
|
|
|
233
|
+
def collect_call(token, reviews)
|
|
234
|
+
@invoke.call('multi_llm_review_collect', { 'collect_token' => token, 'orchestrator_reviews' => reviews,
|
|
235
|
+
'include_raw_text' => true })
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Holds the round while persona seats are reviewing, within this
|
|
239
|
+
# call's budget and never past the cutoff. Seats never launched get
|
|
240
|
+
# the launch instruction once; after that, and at the cutoff, what is
|
|
241
|
+
# missing becomes a shortfall. Returns a reply, or nil to collect.
|
|
242
|
+
def seat_gate(pending, budget_end)
|
|
243
|
+
token = pending['token']
|
|
244
|
+
loop do
|
|
245
|
+
at = @clock.call
|
|
246
|
+
Seats.settle_orphans(@run, at)
|
|
247
|
+
entries = @run.journal.entries
|
|
248
|
+
return nil if Seats.closed?(entries, token)
|
|
249
|
+
|
|
250
|
+
d = Seats.dispatch_for(entries, token) || pending
|
|
251
|
+
cutoff = Seats.cutoff_of(d)
|
|
252
|
+
return nil if cutoff && at >= cutoff
|
|
253
|
+
|
|
254
|
+
states = Seats.seat_states(entries, d)
|
|
255
|
+
launch = Seats.to_launch(states)
|
|
256
|
+
running = states.select { |s| s['state'] == 'running' }
|
|
257
|
+
prompted = entries.any? { |e| e['kind'] == 'seats_prompted' && e['token'] == token }
|
|
258
|
+
if !launch.empty? && !prompted
|
|
259
|
+
@run.append(:seats_prompted, phase: d['phase'], round: d['round'], token: token,
|
|
260
|
+
personas: launch.map { |s| s['persona'] })
|
|
261
|
+
return { 'status' => 'pending', 'detail' => 'persona seats to launch',
|
|
262
|
+
'markers' => launch.map { |s| Seats.marker(@run.id, token, s['persona']) } }
|
|
263
|
+
end
|
|
264
|
+
return nil if running.empty?
|
|
265
|
+
|
|
266
|
+
if at >= budget_end
|
|
267
|
+
@run.append(:seats_waited, phase: d['phase'], round: d['round'], token: token, running: running.size)
|
|
268
|
+
return { 'status' => 'pending', 'detail' => 'persona seats reviewing', 'running' => running.map { |s| s['persona'] } }
|
|
269
|
+
end
|
|
270
|
+
@sleep.call([Seats::POLL_SECONDS, budget_end - at, cutoff ? cutoff - at : Seats::POLL_SECONDS].min.clamp(0.05, Seats::POLL_SECONDS))
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The seats' replies as settled: submitted to the review tool when a
|
|
275
|
+
# verdict reads from them, given to the stand-in either way, never
|
|
276
|
+
# inferred or transcribed.
|
|
277
|
+
def seat_submission(pending)
|
|
278
|
+
close_seats(pending)
|
|
279
|
+
entries = @run.journal.entries
|
|
280
|
+
d = Seats.dispatch_for(entries, pending['token']) || pending
|
|
281
|
+
reviews = []
|
|
282
|
+
rows = []
|
|
283
|
+
outcomes = Seats.seat_states(entries, d).map do |s|
|
|
284
|
+
o = { 'persona' => s['persona'], 'launches' => s['launches'], 'late' => s['late'] }
|
|
285
|
+
a = s['answer']
|
|
286
|
+
unless a
|
|
287
|
+
cause = case s['state']
|
|
288
|
+
when 'not_launched' then 'not_launched'
|
|
289
|
+
when 'running' then 'not_settled'
|
|
290
|
+
else s['last_cause'] || 'no_reply'
|
|
291
|
+
end
|
|
292
|
+
next o.merge('submitted' => false, 'cause' => cause)
|
|
293
|
+
end
|
|
294
|
+
text = Seats.settled_reply(a)
|
|
295
|
+
next o.merge('submitted' => false, 'cause' => 'reply_changed') unless text
|
|
296
|
+
|
|
297
|
+
v = Seats.verdict(text)
|
|
298
|
+
rows << { 'persona' => s['persona'], 'verdict' => v.to_s, 'reply' => text }
|
|
299
|
+
next o.merge('submitted' => false, 'cause' => 'no_verdict', 'agent_id' => a['agent_id']) unless v
|
|
300
|
+
|
|
301
|
+
reviews << { 'persona' => s['persona'], 'verdict' => v, 'findings' => Seats.findings(text, v),
|
|
302
|
+
'reasoning' => text, 'agent_id' => a['agent_id'] }.compact
|
|
303
|
+
o.merge('submitted' => true, 'verdict' => v, 'agent_id' => a['agent_id'])
|
|
304
|
+
end
|
|
305
|
+
[reviews, rows, outcomes]
|
|
306
|
+
rescue StandardError, ScriptError => e
|
|
307
|
+
# The review tool's parsers are its internals: if they cannot be
|
|
308
|
+
# used, the round is collected without personas, and says why.
|
|
309
|
+
@run.append(:seat_submission_failed, phase: pending['phase'], round: pending['round'], token: pending['token'],
|
|
310
|
+
reason: "#{e.class}: #{e.message[0, 300]}")
|
|
311
|
+
[[], [], Array(pending['seats']).map { |x| { 'persona' => x['persona'], 'submitted' => false, 'cause' => 'parse_failed' } }]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Closes the round's seats. A close that fails (the seat lock cannot be
|
|
315
|
+
# taken) is journaled as itself and the replies already settled are
|
|
316
|
+
# still submitted; launches left unsettled count as not_settled. A
|
|
317
|
+
# journal that cannot be written fails the round whatever this does.
|
|
318
|
+
def close_seats(pending)
|
|
319
|
+
Seats.settle_remaining(@run, pending['token'])
|
|
320
|
+
rescue StandardError => e
|
|
321
|
+
@run.append(:seats_close_failed, phase: pending['phase'], round: pending['round'], token: pending['token'],
|
|
322
|
+
reason: "#{e.class}: #{e.message[0, 300]}")
|
|
323
|
+
end
|
|
324
|
+
|
|
179
325
|
# Labels a collected round. A failed labelling keeps the round: the
|
|
180
326
|
# next advance labels it again without paying for another dispatch.
|
|
181
327
|
def label_round(phase, entry)
|
|
@@ -234,12 +380,12 @@ module KairosMcp
|
|
|
234
380
|
if quorate && open.empty?
|
|
235
381
|
@run.append(:phase_ended, phase: name, by: 'closure', provisional: true, round: round)
|
|
236
382
|
item = @run.open_item('closure', "#{name} closed provisionally at round #{round}; the operator confirms it, or sends " \
|
|
237
|
-
|
|
383
|
+
"it back (decision send_back)#{seats_note(entry)}", ref: name)
|
|
238
384
|
return { 'status' => 'closed_provisionally', 'round' => round, 'labels' => labels, 'item' => item['id'] }
|
|
239
385
|
end
|
|
240
386
|
if quorate && bound && round >= bound
|
|
241
387
|
return refuse_with_item(name, 'review_open', "#{name} is open (#{open.size} P0) and has had its #{bound} rounds; it " \
|
|
242
|
-
|
|
388
|
+
"waits for the operator (decision more_rounds, or freeze)#{seats_note(entry)}",
|
|
243
389
|
"#{name} is at its round cap; it waits for the operator", status: 'waiting',
|
|
244
390
|
extra: { 'labels' => labels },
|
|
245
391
|
any_of_kind: true)
|
|
@@ -272,6 +418,20 @@ module KairosMcp
|
|
|
272
418
|
steps.join(' ')
|
|
273
419
|
end
|
|
274
420
|
|
|
421
|
+
# Persona seats the round went without because of how they were
|
|
422
|
+
# launched — never, or only after another seat had answered — named
|
|
423
|
+
# where the operator rules on the round (design v0.3 §3.8).
|
|
424
|
+
def seats_note(entry)
|
|
425
|
+
missed = Array(entry['personas']).select { |o| o['cause'] == 'not_launched' || o['late'] }
|
|
426
|
+
return '' if missed.empty?
|
|
427
|
+
|
|
428
|
+
'. Persona seats launched late or never in this round: ' +
|
|
429
|
+
missed.map do |o|
|
|
430
|
+
how = o['cause'] == 'not_launched' ? 'never launched' : 'first launched after another seat had answered'
|
|
431
|
+
"#{o['persona']} (#{how}#{o['submitted'] ? ', then submitted' : (o['cause'] == 'not_launched' ? '' : ", then #{o['cause']}")})"
|
|
432
|
+
end.join(', ')
|
|
433
|
+
end
|
|
434
|
+
|
|
275
435
|
def tally_of(labels)
|
|
276
436
|
n = ->(label, rev = nil) { labels.count { |l| l['label'] == label && (rev.nil? || (l['from_revision'] == true) == rev) } }
|
|
277
437
|
{ 'carried_open' => n.call('carried_over'), 'fixed' => n.call('fixed'), 'refuted' => n.call('refuted'),
|
|
@@ -396,7 +396,7 @@ module KairosMcp
|
|
|
396
396
|
# passed to the operator is not progress: a turn made only of those
|
|
397
397
|
# rests instead of being pushed on.
|
|
398
398
|
PROGRESS = %w[item_resolved created pinned check review_dispatched review_round review_labelled phase_ended
|
|
399
|
-
report_built plan_ratified].freeze
|
|
399
|
+
report_built plan_ratified seat_launched seat_settled].freeze
|
|
400
400
|
|
|
401
401
|
FILE_TOOLS = %w[Edit Write MultiEdit NotebookEdit].freeze
|
|
402
402
|
|