kairos-chain 3.41.1 → 3.42.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 +30 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/test/test_decide_prompt_contract.rb +21 -0
- data/templates/skillsets/agent/tools/agent_step.rb +11 -1
- data/templates/skillsets/autonomos/lib/autonomos/mandate.rb +32 -1
- data/templates/skillsets/autonomos/test/test_autonomos.rb +31 -0
- data/templates/skillsets/document_authoring/lib/document_authoring/section_writer.rb +160 -42
- data/templates/skillsets/document_authoring/test/test_document_authoring.rb +89 -0
- data/templates/skillsets/document_authoring/tools/write_section.rb +8 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07f782a3ef998bf91435a2e35cde051338c6f1c2fa8f2e037fbba4427c400ccf
|
|
4
|
+
data.tar.gz: 8cd549860f69e91fa94bf50a41121cb3b7625686f2438c2c665c83192f081f27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1ec23c12c08cff2ce324b34715d8eedd0277863a4e561b439c7161bd6e1b08eb10936c36db603ba621544e424953fe24247fe553917d3d4fcb6e844867d7f42
|
|
7
|
+
data.tar.gz: 738519ef9bc54544c058f59661d08288b22d60904527dbfb7f99f501f89b35d1a80c095b2b5090750f9aff69d592acc46357fe4069064302db344659b68b5642
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ 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.42.0] - 2026-07-14
|
|
8
|
+
|
|
9
|
+
### Fixed / Improved — agent SkillSet self-drive on long document sections
|
|
10
|
+
|
|
11
|
+
Surfaced while running the governed agent OODA loop on a long Japanese
|
|
12
|
+
naturalisation task. Four independent limitations:
|
|
13
|
+
|
|
14
|
+
- **`document_authoring` context truncation (root cause).** `write_section`
|
|
15
|
+
handed the assembler a 4000-char `max_chars_per_source` cap, so long
|
|
16
|
+
`context_sources` were silently cut before generation. Raised the default to
|
|
17
|
+
500 000 (total 1 000 000); `SectionWriter` now bounds output size itself.
|
|
18
|
+
- **`SectionWriter` output completeness.** `llm_call` was invoked without
|
|
19
|
+
`max_tokens`, so API providers truncated long sections at the ~4096 default —
|
|
20
|
+
now sized from context length. Providers without an output-length knob (e.g.
|
|
21
|
+
the `claude_code` CLI) are handled by **auto-chunking** the context at
|
|
22
|
+
paragraph boundaries (`section_chunk_target_chars`, default 3000) with a
|
|
23
|
+
bounded per-chunk instruction. `word_count` is now language-aware (character
|
|
24
|
+
count for JA/ZH/KO); a `char_count` field was added.
|
|
25
|
+
- **Non-deterministic risk gate.** `Autonomos::Mandate.risk_exceeds_budget?`
|
|
26
|
+
used the DECIDE model's self-assigned per-step `risk`, so identical read-only
|
|
27
|
+
work gated inconsistently. Added a deterministic tool→risk map (`TOOL_RISK`),
|
|
28
|
+
authoritative for known tools (read-only ⇒ low, shell/destructive ⇒ high).
|
|
29
|
+
- **DECIDE instruction drift & file reads.** The DECIDE prompt now (1) steers
|
|
30
|
+
disk reads to `safe_file_read` instead of `resource_read` with a `file://`
|
|
31
|
+
URI, and (2) requires verbatim pass-through of caller-supplied instructions
|
|
32
|
+
to sub-tools (no paraphrasing / re-scoping).
|
|
33
|
+
|
|
34
|
+
Existing installs: run `system_upgrade` to pull the `external_tools` SkillSet
|
|
35
|
+
(ships `safe_file_read`) into the runtime.
|
|
36
|
+
|
|
7
37
|
## [3.41.1] - 2026-07-13
|
|
8
38
|
|
|
9
39
|
### Fixed — Meeting Place Caddy healthcheck false-negative (deployment)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -53,6 +53,27 @@ module KairosMcp
|
|
|
53
53
|
'DECIDE prompt should explain hint is advisory/additive')
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Read-gap fix: DECIDE must be told to use safe_file_read for disk files and
|
|
57
|
+
# NOT to use resource_read with a file:// URI (which only accepts in-system URIs).
|
|
58
|
+
def test_prompt_steers_file_reads_to_safe_file_read
|
|
59
|
+
prompt = @step.send(:decide_system_prompt)
|
|
60
|
+
assert_match(/safe_file_read/, prompt,
|
|
61
|
+
'DECIDE prompt should point disk reads at safe_file_read')
|
|
62
|
+
assert_match(/resource_read/, prompt)
|
|
63
|
+
assert_match(%r{file://}, prompt,
|
|
64
|
+
'DECIDE prompt should warn against resource_read with file:// URIs')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Instructions-drift fix: DECIDE must pass verbatim sub-tool instructions through
|
|
68
|
+
# unchanged rather than paraphrasing / re-scoping the task.
|
|
69
|
+
def test_prompt_requires_verbatim_instruction_passthrough
|
|
70
|
+
prompt = @step.send(:decide_system_prompt)
|
|
71
|
+
assert_match(/UNCHANGED|verbatim/i, prompt,
|
|
72
|
+
'DECIDE prompt should require verbatim passthrough of instructions')
|
|
73
|
+
assert_match(/paraphrase|re-scope|re-target/i, prompt,
|
|
74
|
+
'DECIDE prompt should forbid paraphrasing/re-scoping the task')
|
|
75
|
+
end
|
|
76
|
+
|
|
56
77
|
# Simulate well-formed LLM output and verify it parses through ReviewHint
|
|
57
78
|
def test_well_formed_llm_output_parses
|
|
58
79
|
llm_output = {
|
|
@@ -1784,7 +1784,17 @@ module KairosMcp
|
|
|
1784
1784
|
"reason: <string|null>, urgency: \"low\"|\"medium\"|\"high\"|null }. " \
|
|
1785
1785
|
"Set needed:true to REQUEST multi-LLM review for subtle high-impact " \
|
|
1786
1786
|
"decisions; set needed:false for routine plans. The hint is advisory " \
|
|
1787
|
-
"and additive — structural rules may still fire review independently
|
|
1787
|
+
"and additive — structural rules may still fire review independently.\n" \
|
|
1788
|
+
"Tool-use rules:\n" \
|
|
1789
|
+
" (1) To read a project file from disk, use `safe_file_read` with a " \
|
|
1790
|
+
"workspace-relative `path`. Do NOT use `resource_read` with a `file://` URI " \
|
|
1791
|
+
"or a bare filesystem path — `resource_read` only accepts `l0://`, " \
|
|
1792
|
+
"`knowledge://`, or `context://` URIs and will fail on anything else.\n" \
|
|
1793
|
+
" (2) When the goal, orientation, or a prior step supplies a verbatim " \
|
|
1794
|
+
"instructions string, term list, or content to hand to a sub-tool (e.g. the " \
|
|
1795
|
+
"`instructions` for `write_section`), copy it through UNCHANGED into that " \
|
|
1796
|
+
"tool's arguments. Do not paraphrase, summarise, re-scope, or re-target the " \
|
|
1797
|
+
"task (e.g. do not invent a new audience or output format the goal did not ask for)."
|
|
1788
1798
|
end
|
|
1789
1799
|
|
|
1790
1800
|
def reflect_system_prompt
|
|
@@ -19,6 +19,29 @@ module Autonomos
|
|
|
19
19
|
|
|
20
20
|
RISK_BUDGETS = %w[low medium].freeze
|
|
21
21
|
|
|
22
|
+
# Deterministic tool -> risk map. The per-step `risk` in a proposal is assigned by
|
|
23
|
+
# the DECIDE model and is therefore non-deterministic: identical read-only work was
|
|
24
|
+
# observed to be labelled `medium` in one run and `low` in another, making the risk
|
|
25
|
+
# gate fire inconsistently. For KNOWN tools this map is authoritative (it both floors
|
|
26
|
+
# AND caps the model's label), so read-only/drafting tools are always `low` and
|
|
27
|
+
# destructive/shell tools are always `high` regardless of how the model labelled them.
|
|
28
|
+
# Unknown tools fall back to the model-assigned risk (then `low`).
|
|
29
|
+
TOOL_RISK = {
|
|
30
|
+
# read-only / in-place drafting into working files → low
|
|
31
|
+
'resource_read' => 'low', 'resource_list' => 'low',
|
|
32
|
+
'knowledge_get' => 'low', 'knowledge_list' => 'low', 'skills_list' => 'low',
|
|
33
|
+
'skills_get' => 'low', 'context_save' => 'low', 'document_status' => 'low',
|
|
34
|
+
'write_section' => 'low', 'llm_call' => 'low', 'safe_file_read' => 'low',
|
|
35
|
+
'safe_file_list' => 'low', 'chain_status' => 'low', 'chain_verify' => 'low',
|
|
36
|
+
# durable / L0-L1 / external-effect writes → medium
|
|
37
|
+
'chain_record' => 'medium', 'safe_file_write' => 'medium', 'safe_file_edit' => 'medium',
|
|
38
|
+
'safe_file_copy' => 'medium', 'knowledge_update' => 'medium',
|
|
39
|
+
'instructions_update' => 'medium', 'skills_promote' => 'medium',
|
|
40
|
+
'safe_http_get' => 'medium', 'safe_http_post' => 'medium', 'safe_git_commit' => 'medium',
|
|
41
|
+
# destructive / shell / irreversible-external → high
|
|
42
|
+
'Bash' => 'high', 'safe_file_delete' => 'high', 'safe_git_push' => 'high'
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
22
45
|
class LockError < StandardError; end
|
|
23
46
|
|
|
24
47
|
class << self
|
|
@@ -174,7 +197,7 @@ module Autonomos
|
|
|
174
197
|
|
|
175
198
|
steps = proposal[:autoexec_task][:steps] || []
|
|
176
199
|
steps.any? do |step|
|
|
177
|
-
risk = step
|
|
200
|
+
risk = effective_risk(step)
|
|
178
201
|
case risk_budget
|
|
179
202
|
when 'low'
|
|
180
203
|
%w[medium high].include?(risk)
|
|
@@ -186,6 +209,14 @@ module Autonomos
|
|
|
186
209
|
end
|
|
187
210
|
end
|
|
188
211
|
|
|
212
|
+
# Resolve a step's risk deterministically: the tool map wins for known tools
|
|
213
|
+
# (both raising and lowering the model's label), otherwise the model-assigned
|
|
214
|
+
# risk is used, defaulting to 'low'.
|
|
215
|
+
def effective_risk(step)
|
|
216
|
+
tool = step[:tool_name] || step['tool_name']
|
|
217
|
+
TOOL_RISK[tool] || step[:risk] || 'low'
|
|
218
|
+
end
|
|
219
|
+
|
|
189
220
|
def list_active
|
|
190
221
|
mandates_dir = Autonomos.storage_path('mandates')
|
|
191
222
|
files = Dir.glob(File.join(mandates_dir, '*.json'))
|
|
@@ -469,6 +469,37 @@ class TestAutonomosMandate < Minitest::Test
|
|
|
469
469
|
assert_equal 0, mandate[:cycles_completed]
|
|
470
470
|
end
|
|
471
471
|
|
|
472
|
+
# ---- deterministic tool -> risk map (risk_exceeds_budget?) ----
|
|
473
|
+
|
|
474
|
+
def test_risk_map_read_only_tools_forced_low
|
|
475
|
+
# model over-labelled read-only tools; the map must force them to low
|
|
476
|
+
proposal = { autoexec_task: { steps: [
|
|
477
|
+
{ tool_name: 'write_section', risk: 'medium' },
|
|
478
|
+
{ tool_name: 'resource_read', risk: 'high' }
|
|
479
|
+
] } }
|
|
480
|
+
refute Autonomos::Mandate.risk_exceeds_budget?(proposal, 'low')
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def test_risk_map_shell_forced_high_regardless_of_label
|
|
484
|
+
# model under-labelled Bash as low; the map must force it high
|
|
485
|
+
proposal = { autoexec_task: { steps: [{ tool_name: 'Bash', risk: 'low' }] } }
|
|
486
|
+
assert Autonomos::Mandate.risk_exceeds_budget?(proposal, 'low')
|
|
487
|
+
assert Autonomos::Mandate.risk_exceeds_budget?(proposal, 'medium')
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def test_risk_map_durable_write_is_medium
|
|
491
|
+
# chain_record is medium in the map even if the model labelled it low
|
|
492
|
+
proposal = { autoexec_task: { steps: [{ tool_name: 'chain_record', risk: 'low' }] } }
|
|
493
|
+
assert Autonomos::Mandate.risk_exceeds_budget?(proposal, 'low')
|
|
494
|
+
refute Autonomos::Mandate.risk_exceeds_budget?(proposal, 'medium')
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def test_risk_unknown_tool_falls_back_to_model_label
|
|
498
|
+
proposal = { autoexec_task: { steps: [{ tool_name: 'some_new_tool', risk: 'medium' }] } }
|
|
499
|
+
assert Autonomos::Mandate.risk_exceeds_budget?(proposal, 'low')
|
|
500
|
+
refute Autonomos::Mandate.risk_exceeds_budget?(proposal, 'medium')
|
|
501
|
+
end
|
|
502
|
+
|
|
472
503
|
def test_load_mandate
|
|
473
504
|
mandate = Autonomos::Mandate.create(
|
|
474
505
|
goal_name: 'load_test',
|
|
@@ -17,56 +17,51 @@ module KairosMcp
|
|
|
17
17
|
def write(section_name:, instructions:, context_text:, output_file:,
|
|
18
18
|
max_words: 500, language: 'en', append_mode: false,
|
|
19
19
|
invocation_context: nil)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'messages' => messages,
|
|
27
|
-
'system' => system_prompt
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
# Forward InvocationContext via dispatch-level context: keyword only
|
|
31
|
-
# (not duplicated in arguments — llm_call uses dispatch context)
|
|
32
|
-
result = @caller.invoke_tool('llm_call', llm_args, context: invocation_context)
|
|
20
|
+
# Provider-independent completeness: some LLM providers (e.g. the claude_code
|
|
21
|
+
# CLI subprocess) have no output-length control, so a single pass over a long
|
|
22
|
+
# reference context stops early and drops the tail. Split the context at
|
|
23
|
+
# paragraph boundaries and generate each chunk in its own pass, appending the
|
|
24
|
+
# results. Short contexts stay a single pass (chunks == [context_text]).
|
|
25
|
+
chunks = split_context(context_text, chunk_target)
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
27
|
+
total_units = 0
|
|
28
|
+
total_chars = 0
|
|
29
|
+
chunks.each_with_index do |chunk, idx|
|
|
30
|
+
first = idx.zero?
|
|
31
|
+
chunk_instructions =
|
|
32
|
+
if chunks.length > 1
|
|
33
|
+
# Bound EVERY chunk to its own paragraphs. Without this, providers that
|
|
34
|
+
# "finish the section" (e.g. claude_code) regenerate the whole section from
|
|
35
|
+
# the first chunk, so later chunks duplicate content. Continuation chunks
|
|
36
|
+
# also must not repeat the heading.
|
|
37
|
+
notes = [bounded_note(language)]
|
|
38
|
+
notes << continuation_note(language) unless first
|
|
39
|
+
"#{instructions}\n\n#{notes.join("\n")}"
|
|
40
|
+
else
|
|
41
|
+
instructions
|
|
42
|
+
end
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
error_msg = if error.is_a?(Hash)
|
|
43
|
-
"#{error['type']}: #{error['message']}"
|
|
44
|
-
else
|
|
45
|
-
error.to_s
|
|
46
|
-
end
|
|
47
|
-
return { 'error' => "LLM call failed: #{error_msg}" }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
generated_text = parsed.dig('response', 'content')
|
|
51
|
-
if generated_text.nil? || generated_text.strip.empty?
|
|
52
|
-
return { 'error' => 'LLM returned empty content' }
|
|
53
|
-
end
|
|
44
|
+
gen = generate_one(section_name, chunk_instructions, chunk, max_words, language, invocation_context)
|
|
45
|
+
return gen if gen['error']
|
|
54
46
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
# First chunk honours the caller's append_mode; later chunks always append
|
|
48
|
+
# so the section is assembled in order.
|
|
49
|
+
write_chunk(output_file, gen['content'], first ? append_mode : true)
|
|
50
|
+
total_units += count_units(gen['content'], language)
|
|
51
|
+
total_chars += gen['content'].gsub(/\s+/, '').length
|
|
60
52
|
end
|
|
61
53
|
|
|
62
54
|
{
|
|
63
55
|
'status' => 'ok',
|
|
64
56
|
'section_name' => section_name,
|
|
65
57
|
'output_file' => output_file,
|
|
66
|
-
|
|
58
|
+
# `word_count` (whitespace split) is meaningless for space-less scripts (JA/ZH/KO)
|
|
59
|
+
# and misled progress judgement. Report a language-aware unit count plus a raw
|
|
60
|
+
# non-whitespace char count so callers never size output from a bogus metric.
|
|
61
|
+
'word_count' => total_units,
|
|
62
|
+
'char_count' => total_chars,
|
|
63
|
+
'chunks' => chunks.length
|
|
67
64
|
}
|
|
68
|
-
rescue JSON::ParserError => e
|
|
69
|
-
{ 'error' => "Failed to parse LLM response: #{e.message}" }
|
|
70
65
|
end
|
|
71
66
|
|
|
72
67
|
private
|
|
@@ -74,20 +69,143 @@ module KairosMcp
|
|
|
74
69
|
def system_prompt
|
|
75
70
|
"You are a professional document writer. Write the requested section " \
|
|
76
71
|
"following the instructions precisely. Use the provided context for accuracy. " \
|
|
77
|
-
"Output ONLY the section content. You may use markdown formatting within the section."
|
|
72
|
+
"Output ONLY the section content. You may use markdown formatting within the section. " \
|
|
73
|
+
"When the instructions ask you to revise, translate, or rewrite the reference " \
|
|
74
|
+
"context, reproduce EVERY paragraph of that context — never summarise, merge, " \
|
|
75
|
+
"shorten, or omit a paragraph, and do not truncate. Preserve heading, list, and " \
|
|
76
|
+
"block-quote structure exactly."
|
|
78
77
|
end
|
|
79
78
|
|
|
80
79
|
def build_user_prompt(section_name, instructions, context_text, max_words, language)
|
|
81
80
|
parts = [
|
|
82
81
|
"## Section: #{section_name}",
|
|
83
82
|
"## Instructions\n#{instructions}",
|
|
84
|
-
|
|
83
|
+
# `max_words` is a soft target only; the hard cap is enforced via max_tokens.
|
|
84
|
+
# Fidelity to the reference context takes precedence over any word target.
|
|
85
|
+
"## Length: aim for about #{max_words} words, but completeness wins — " \
|
|
86
|
+
"reproduce all reference paragraphs even if that exceeds the target.",
|
|
85
87
|
"## Language: #{language}"
|
|
86
88
|
]
|
|
87
89
|
parts << "## Reference Context\n#{context_text}" if context_text && !context_text.empty?
|
|
88
90
|
parts << "\nWrite the section now."
|
|
89
91
|
parts.join("\n\n")
|
|
90
92
|
end
|
|
93
|
+
|
|
94
|
+
# Derive a hard output token budget so faithful, full-length rewrites are not
|
|
95
|
+
# truncated. Sized from the reference context (rewrite output ≈ input length),
|
|
96
|
+
# clamped between a floor and a ceiling; the adapter clamps further to the
|
|
97
|
+
# model's own limit. Overridable via config for non-default models.
|
|
98
|
+
def resolve_max_tokens(max_words, context_text)
|
|
99
|
+
floor = (@config['section_max_tokens_floor'] || 4096).to_i
|
|
100
|
+
ceiling = (@config['section_max_tokens_ceiling'] || 8192).to_i
|
|
101
|
+
# ~1 token/char is a safe-high estimate for CJK; +20% margin for markup/expansion.
|
|
102
|
+
est_context = context_text ? (context_text.length * 1.2).ceil : 0
|
|
103
|
+
est_words = max_words.to_i * 3 # generous; CJK "words" undercount severely
|
|
104
|
+
[[est_context, est_words, floor].max, ceiling].min
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Language-aware unit count. Space-less scripts count non-whitespace characters;
|
|
108
|
+
# others fall back to whitespace-delimited words.
|
|
109
|
+
def count_units(text, language)
|
|
110
|
+
if %w[ja zh ko].include?(language.to_s)
|
|
111
|
+
text.gsub(/\s+/, '').length
|
|
112
|
+
else
|
|
113
|
+
text.split.size
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Single LLM pass over one chunk. Returns { 'content' => text } or { 'error' => msg }.
|
|
118
|
+
def generate_one(section_name, instructions, context_text, max_words, language, invocation_context)
|
|
119
|
+
messages = [{
|
|
120
|
+
'role' => 'user',
|
|
121
|
+
'content' => build_user_prompt(section_name, instructions, context_text, max_words, language)
|
|
122
|
+
}]
|
|
123
|
+
llm_args = {
|
|
124
|
+
'messages' => messages,
|
|
125
|
+
'system' => system_prompt,
|
|
126
|
+
'max_tokens' => resolve_max_tokens(max_words, context_text)
|
|
127
|
+
}
|
|
128
|
+
# Forward InvocationContext via dispatch-level context: keyword only.
|
|
129
|
+
result = @caller.invoke_tool('llm_call', llm_args, context: invocation_context)
|
|
130
|
+
|
|
131
|
+
raw = result.map { |b| b[:text] || b['text'] }.compact.join
|
|
132
|
+
parsed = JSON.parse(raw)
|
|
133
|
+
if parsed['status'] == 'error'
|
|
134
|
+
error = parsed['error'] || {}
|
|
135
|
+
error_msg = error.is_a?(Hash) ? "#{error['type']}: #{error['message']}" : error.to_s
|
|
136
|
+
return { 'error' => "LLM call failed: #{error_msg}" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
generated_text = parsed.dig('response', 'content')
|
|
140
|
+
if generated_text.nil? || generated_text.strip.empty?
|
|
141
|
+
return { 'error' => 'LLM returned empty content' }
|
|
142
|
+
end
|
|
143
|
+
{ 'content' => generated_text }
|
|
144
|
+
rescue JSON::ParserError => e
|
|
145
|
+
{ 'error' => "Failed to parse LLM response: #{e.message}" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Group the reference context into chunks no larger than `target` characters,
|
|
149
|
+
# splitting ONLY at blank-line paragraph boundaries (never mid-paragraph). A short
|
|
150
|
+
# or empty context returns a single element so behaviour is unchanged. A single
|
|
151
|
+
# paragraph larger than `target` stays whole (correctness over chunk size).
|
|
152
|
+
def split_context(text, target)
|
|
153
|
+
return [text] if text.nil? || text.strip.empty? || text.length <= target
|
|
154
|
+
|
|
155
|
+
chunks = []
|
|
156
|
+
current = +''
|
|
157
|
+
text.split(/\n{2,}/).each do |para|
|
|
158
|
+
if !current.empty? && (current.length + para.length + 2) > target
|
|
159
|
+
chunks << current
|
|
160
|
+
current = +''
|
|
161
|
+
end
|
|
162
|
+
current << "\n\n" unless current.empty?
|
|
163
|
+
current << para
|
|
164
|
+
end
|
|
165
|
+
chunks << current unless current.empty?
|
|
166
|
+
chunks
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Auto-chunk threshold. Sized so each pass is small enough that output-uncapped
|
|
170
|
+
# providers (e.g. claude_code CLI) emit the whole chunk without stopping early —
|
|
171
|
+
# empirically a larger single pass truncates, while ~3000-char passes complete.
|
|
172
|
+
# The bounded_note keeps chunks from overlapping. Overridable via config.
|
|
173
|
+
def chunk_target
|
|
174
|
+
(@config['section_chunk_target_chars'] || 3000).to_i
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Appended to EVERY chunk (when chunking) so the model rewrites only the paragraphs
|
|
178
|
+
# it was given and does not "finish the section" — which would make chunks overlap.
|
|
179
|
+
def bounded_note(language)
|
|
180
|
+
if %w[ja zh ko].include?(language.to_s)
|
|
181
|
+
'重要: 以下に与えた段落だけを推敲せよ。節はいくつかに分けて処理される。' \
|
|
182
|
+
'与えられていない段落を書き足したり、節の続きをここで完成させたりしてはならない。'
|
|
183
|
+
else
|
|
184
|
+
'IMPORTANT: Rewrite ONLY the paragraphs given below. The section is processed ' \
|
|
185
|
+
'in several passes; do NOT add paragraphs that are not in this input or ' \
|
|
186
|
+
'finish the rest of the section here.'
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Appended to a continuation chunk's instructions so the model does not repeat the
|
|
191
|
+
# section heading and continues the body under the same directives.
|
|
192
|
+
def continuation_note(language)
|
|
193
|
+
if %w[ja zh ko].include?(language.to_s)
|
|
194
|
+
'これは直前のチャンクの続きである。見出し(## …)を繰り返さず、' \
|
|
195
|
+
'本文の続きの段落だけを、同じ方針で自然化して書け。'
|
|
196
|
+
else
|
|
197
|
+
'This is a continuation of the previous chunk. Do NOT repeat the section ' \
|
|
198
|
+
'heading; continue with the next body paragraphs under the same instructions.'
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def write_chunk(output_file, text, append)
|
|
203
|
+
if append
|
|
204
|
+
File.open(output_file, 'a') { |f| f.write("\n\n#{text}") }
|
|
205
|
+
else
|
|
206
|
+
File.write(output_file, text)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
91
209
|
end
|
|
92
210
|
end
|
|
93
211
|
end
|
|
@@ -392,6 +392,95 @@ assert("T18: parses llm_call error response correctly") do
|
|
|
392
392
|
!File.exist?(File.join(TMPDIR, 'should_not_exist.md'))
|
|
393
393
|
end
|
|
394
394
|
|
|
395
|
+
assert("T18b: sizes max_tokens from context so long sections are not truncated") do
|
|
396
|
+
caller = MockCallerTool.new
|
|
397
|
+
captured = {}
|
|
398
|
+
caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
|
|
399
|
+
captured[tool_name] = arguments
|
|
400
|
+
[{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'x' }, 'snapshot' => {} }) }]
|
|
401
|
+
end
|
|
402
|
+
writer = SW.new(caller, {})
|
|
403
|
+
long_ctx = 'あ' * 6000
|
|
404
|
+
writer.write(section_name: 's', instructions: 'rewrite faithfully', context_text: long_ctx,
|
|
405
|
+
output_file: File.join(TMPDIR, 'mt.md'), max_words: 500, language: 'ja')
|
|
406
|
+
mt = captured['llm_call'] && captured['llm_call']['max_tokens']
|
|
407
|
+
# 6000 chars * 1.2 = 7200, clamped to the 8192 ceiling — well above the 4096 default
|
|
408
|
+
!mt.nil? && mt > 4096 && mt <= 8192
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
assert("T18c: word_count/char_count count characters for space-less scripts (JA)") do
|
|
412
|
+
caller = MockCallerTool.new
|
|
413
|
+
caller.invoke_results['llm_call'] = [{
|
|
414
|
+
text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'あいう えお' }, 'snapshot' => {} })
|
|
415
|
+
}]
|
|
416
|
+
writer = SW.new(caller, {})
|
|
417
|
+
r = writer.write(section_name: 's', instructions: 'i', context_text: '',
|
|
418
|
+
output_file: File.join(TMPDIR, 'cu.md'), max_words: 100, language: 'ja')
|
|
419
|
+
r['word_count'] == 5 && r['char_count'] == 5
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
assert("T18e: long multi-paragraph context is auto-chunked into multiple passes and concatenated") do
|
|
423
|
+
caller = MockCallerTool.new
|
|
424
|
+
calls = 0
|
|
425
|
+
caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
|
|
426
|
+
calls += 1
|
|
427
|
+
[{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => "chunk#{calls}" }, 'snapshot' => {} }) }]
|
|
428
|
+
end
|
|
429
|
+
writer = SW.new(caller, { 'section_chunk_target_chars' => 100 })
|
|
430
|
+
ctx = (1..5).map { ('あ' * 80) }.join("\n\n") # 5 paragraphs, each > half the target
|
|
431
|
+
out = File.join(TMPDIR, 'chunked.md')
|
|
432
|
+
r = writer.write(section_name: 's', instructions: 'i', context_text: ctx,
|
|
433
|
+
output_file: out, max_words: 100, language: 'ja', append_mode: false)
|
|
434
|
+
content = File.read(out)
|
|
435
|
+
r['status'] == 'ok' && r['chunks'] > 1 && calls > 1 &&
|
|
436
|
+
content.start_with?('chunk1') && content.include?("chunk#{calls}") &&
|
|
437
|
+
content.include?("chunk1\n\nchunk2")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
assert("T18f: short context stays a single pass (no chunking)") do
|
|
441
|
+
caller = MockCallerTool.new
|
|
442
|
+
calls = 0
|
|
443
|
+
caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
|
|
444
|
+
calls += 1
|
|
445
|
+
[{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'once' }, 'snapshot' => {} }) }]
|
|
446
|
+
end
|
|
447
|
+
writer = SW.new(caller, { 'section_chunk_target_chars' => 100000 })
|
|
448
|
+
r = writer.write(section_name: 's', instructions: 'i', context_text: 'あ' * 500,
|
|
449
|
+
output_file: File.join(TMPDIR, 'single.md'), max_words: 100, language: 'ja')
|
|
450
|
+
r['chunks'] == 1 && calls == 1
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
assert("T18g: append_mode applies only to the first chunk; later chunks append") do
|
|
454
|
+
caller = MockCallerTool.new
|
|
455
|
+
calls = 0
|
|
456
|
+
caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
|
|
457
|
+
calls += 1
|
|
458
|
+
[{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => "c#{calls}" }, 'snapshot' => {} }) }]
|
|
459
|
+
end
|
|
460
|
+
out = File.join(TMPDIR, 'append_first.md')
|
|
461
|
+
File.write(out, 'PRE')
|
|
462
|
+
writer = SW.new(caller, { 'section_chunk_target_chars' => 100 })
|
|
463
|
+
ctx = (1..3).map { ('あ' * 80) }.join("\n\n")
|
|
464
|
+
writer.write(section_name: 's', instructions: 'i', context_text: ctx,
|
|
465
|
+
output_file: out, max_words: 100, language: 'ja', append_mode: true)
|
|
466
|
+
# existing PRE preserved, first chunk appended (not overwriting), all chunks present in order
|
|
467
|
+
content = File.read(out)
|
|
468
|
+
content.start_with?('PRE') && content.include?("c1\n\nc2\n\nc3")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
assert("T18d: max_tokens respects config ceiling override") do
|
|
472
|
+
caller = MockCallerTool.new
|
|
473
|
+
captured = {}
|
|
474
|
+
caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
|
|
475
|
+
captured[tool_name] = arguments
|
|
476
|
+
[{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'x' }, 'snapshot' => {} }) }]
|
|
477
|
+
end
|
|
478
|
+
writer = SW.new(caller, { 'section_max_tokens_ceiling' => 5000 })
|
|
479
|
+
writer.write(section_name: 's', instructions: 'rewrite', context_text: 'あ' * 9000,
|
|
480
|
+
output_file: File.join(TMPDIR, 'mt2.md'), max_words: 500, language: 'ja')
|
|
481
|
+
captured['llm_call']['max_tokens'] == 5000
|
|
482
|
+
end
|
|
483
|
+
|
|
395
484
|
assert("T19: handles empty LLM content") do
|
|
396
485
|
caller = MockCallerTool.new
|
|
397
486
|
caller.invoke_results['llm_call'] = [{
|
|
@@ -120,11 +120,16 @@ module KairosMcp
|
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
-
# Assemble context
|
|
123
|
+
# Assemble context.
|
|
124
|
+
# Naturalisation/translation/rewrite tasks need the WHOLE source; SectionWriter
|
|
125
|
+
# chunks long context internally (per-chunk generation), so the assembler must
|
|
126
|
+
# NOT pre-truncate the source — a low per-source cap here silently dropped the
|
|
127
|
+
# tail of long sections before generation. Keep a high (still finite) bound and
|
|
128
|
+
# allow config override for constrained setups.
|
|
124
129
|
assembler = ContextAssembler.new(
|
|
125
130
|
self,
|
|
126
|
-
max_chars_per_source: config['max_context_chars'] ||
|
|
127
|
-
max_total_chars: config['max_total_context_chars'] ||
|
|
131
|
+
max_chars_per_source: config['max_context_chars'] || 500_000,
|
|
132
|
+
max_total_chars: config['max_total_context_chars'] || 1_000_000,
|
|
128
133
|
max_sources: config['max_context_sources'] || 10
|
|
129
134
|
)
|
|
130
135
|
ctx_result = assembler.assemble(context_sources, context: inv_ctx)
|