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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e3b999308891ccbf76963cea480730833fc1d13289498ec80b7f11471d78148
4
- data.tar.gz: 1158b528c37b571283fbf86ea8d16b6feb05604aed70f7ae356961d99e123752
3
+ metadata.gz: 07f782a3ef998bf91435a2e35cde051338c6f1c2fa8f2e037fbba4427c400ccf
4
+ data.tar.gz: 8cd549860f69e91fa94bf50a41121cb3b7625686f2438c2c665c83192f081f27
5
5
  SHA512:
6
- metadata.gz: cd647719bd49cd6a66f5836fc3c4acd98c07a5210876a83f4f5ed6cf76c100e801971b84289c515768c44754c5412db177e728c3fc00a0a34bee25b057281af4
7
- data.tar.gz: 9bbd8eed34dc64daa0fd7265ec6d42ee97e018399f9c090908688c0edbc0afa245782316fb66c02f6e9af4d8a01a9eedfca2ad46d678eda4af92465dc4019bd4
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)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.41.1"
2
+ VERSION = "3.42.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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[:risk] || 'low'
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
- messages = [{
21
- 'role' => 'user',
22
- 'content' => build_user_prompt(section_name, instructions, context_text, max_words, language)
23
- }]
24
-
25
- llm_args = {
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
- # Parse pinned response contract:
35
- # { "status": "ok", "response": { "content": "..." }, "snapshot": {...} }
36
- # { "status": "error", "error": { "type": "...", "message": "..." } }
37
- raw = result.map { |b| b[:text] || b['text'] }.compact.join
38
- parsed = JSON.parse(raw)
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
- if parsed['status'] == 'error'
41
- error = parsed['error'] || {}
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
- # Write to file
56
- if append_mode
57
- File.open(output_file, 'a') { |f| f.write("\n\n#{generated_text}") }
58
- else
59
- File.write(output_file, generated_text)
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
- 'word_count' => generated_text.split.size
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
- "## Word limit: approximately #{max_words} words",
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'] || 4000,
127
- max_total_chars: config['max_total_context_chars'] || 16_000,
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.41.1
4
+ version: 3.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama