kairos-chain 3.41.1 → 3.42.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e3b999308891ccbf76963cea480730833fc1d13289498ec80b7f11471d78148
4
- data.tar.gz: 1158b528c37b571283fbf86ea8d16b6feb05604aed70f7ae356961d99e123752
3
+ metadata.gz: 7d6b5abadc2727f37c441c8d0442da6470438537ad47f387e6c4021769c678dd
4
+ data.tar.gz: f5c625947d73fb41251829cf638b397308294daa4fed7ed94cae945ad6086b72
5
5
  SHA512:
6
- metadata.gz: cd647719bd49cd6a66f5836fc3c4acd98c07a5210876a83f4f5ed6cf76c100e801971b84289c515768c44754c5412db177e728c3fc00a0a34bee25b057281af4
7
- data.tar.gz: 9bbd8eed34dc64daa0fd7265ec6d42ee97e018399f9c090908688c0edbc0afa245782316fb66c02f6e9af4d8a01a9eedfca2ad46d678eda4af92465dc4019bd4
6
+ metadata.gz: 4948c9a12280c2ccc7e0083e056d0ad99d457fcb11d86761f060a891d2033924395521364b5f867ca30eec2cc806f5d539ccc22282657c31e31a0695a5270f55
7
+ data.tar.gz: 789de8f265c7d1dbbde4969643e26f3359887b3417ddd76fe5567348b41c9dc8c37b2f062f6e77d382abc49becd4a2703ac4de29cf8d2f63b62c9fef272a5db1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ 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.1] - 2026-07-14
8
+
9
+ ### Fixed — SectionWriter auto-chunk completeness & duplicate tail
10
+
11
+ Two follow-ups to the v3.42.0 auto-chunk on output-uncapped providers
12
+ (e.g. the `claude_code` CLI):
13
+
14
+ - **Completeness.** v3.42.0 added a per-chunk "rewrite ONLY these paragraphs"
15
+ bound to avoid a trailing duplicate. On `claude_code` that bound backfired —
16
+ the model under-produced and dropped the section tail, leaving long sections
17
+ incomplete. Removed the bound.
18
+ - **Duplicate tail (provider-agnostic).** Some providers "finish the section"
19
+ from the first chunk; the remaining passes then appended duplicate content.
20
+ `SectionWriter` now stops chunking once the assembled output already covers
21
+ ~the whole source (`section_complete_fraction`, default 0.85). Providers that
22
+ rewrite each chunk faithfully (e.g. API adapters, where `max_tokens` is
23
+ honoured) never trip this and still run every chunk. Net: a single clean,
24
+ complete section on both provider kinds. `chunks` in the result now reports
25
+ the number of passes actually run.
26
+
27
+ ## [3.42.0] - 2026-07-14
28
+
29
+ ### Fixed / Improved — agent SkillSet self-drive on long document sections
30
+
31
+ Surfaced while running the governed agent OODA loop on a long Japanese
32
+ naturalisation task. Four independent limitations:
33
+
34
+ - **`document_authoring` context truncation (root cause).** `write_section`
35
+ handed the assembler a 4000-char `max_chars_per_source` cap, so long
36
+ `context_sources` were silently cut before generation. Raised the default to
37
+ 500 000 (total 1 000 000); `SectionWriter` now bounds output size itself.
38
+ - **`SectionWriter` output completeness.** `llm_call` was invoked without
39
+ `max_tokens`, so API providers truncated long sections at the ~4096 default —
40
+ now sized from context length. Providers without an output-length knob (e.g.
41
+ the `claude_code` CLI) are handled by **auto-chunking** the context at
42
+ paragraph boundaries (`section_chunk_target_chars`, default 3000) with a
43
+ bounded per-chunk instruction. `word_count` is now language-aware (character
44
+ count for JA/ZH/KO); a `char_count` field was added.
45
+ - **Non-deterministic risk gate.** `Autonomos::Mandate.risk_exceeds_budget?`
46
+ used the DECIDE model's self-assigned per-step `risk`, so identical read-only
47
+ work gated inconsistently. Added a deterministic tool→risk map (`TOOL_RISK`),
48
+ authoritative for known tools (read-only ⇒ low, shell/destructive ⇒ high).
49
+ - **DECIDE instruction drift & file reads.** The DECIDE prompt now (1) steers
50
+ disk reads to `safe_file_read` instead of `resource_read` with a `file://`
51
+ URI, and (2) requires verbatim pass-through of caller-supplied instructions
52
+ to sub-tools (no paraphrasing / re-scoping).
53
+
54
+ Existing installs: run `system_upgrade` to pull the `external_tools` SkillSet
55
+ (ships `safe_file_read`) into the runtime.
56
+
7
57
  ## [3.41.1] - 2026-07-13
8
58
 
9
59
  ### 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.1"
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,59 @@ 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
- }]
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)
26
+ source_chars = context_text.to_s.gsub(/\s+/, '').length
27
+ complete_at = (source_chars * complete_fraction).ceil
24
28
 
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)
29
+ total_units = 0
30
+ total_chars = 0
31
+ chunks_written = 0
32
+ chunks.each_with_index do |chunk, idx|
33
+ first = idx.zero?
34
+ # Provider-agnostic de-duplication: some output-uncapped providers (e.g. the
35
+ # claude_code CLI) "finish the section" from the first chunk. Once the assembled
36
+ # output already covers ~the whole source, stop — further passes would only
37
+ # append duplicate content. Providers that rewrite each chunk faithfully never
38
+ # trip this (their per-chunk output tracks per-chunk input), so all chunks run.
39
+ break if !first && total_chars >= complete_at
33
40
 
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)
41
+ # Continuation chunks must not repeat the section heading. (A stronger
42
+ # "rewrite only these paragraphs" bound was tried but backfired on the
43
+ # claude_code CLI it then under-produced and dropped the section tail.)
44
+ chunk_instructions =
45
+ if chunks.length > 1 && !first
46
+ "#{instructions}\n\n#{continuation_note(language)}"
47
+ else
48
+ instructions
49
+ end
39
50
 
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
51
+ gen = generate_one(section_name, chunk_instructions, chunk, max_words, language, invocation_context)
52
+ return gen if gen['error']
49
53
 
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
54
-
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)
54
+ # First chunk honours the caller's append_mode; later chunks always append
55
+ # so the section is assembled in order.
56
+ write_chunk(output_file, gen['content'], first ? append_mode : true)
57
+ total_units += count_units(gen['content'], language)
58
+ total_chars += gen['content'].gsub(/\s+/, '').length
59
+ chunks_written += 1
60
60
  end
61
61
 
62
62
  {
63
63
  'status' => 'ok',
64
64
  'section_name' => section_name,
65
65
  'output_file' => output_file,
66
- 'word_count' => generated_text.split.size
66
+ # `word_count` (whitespace split) is meaningless for space-less scripts (JA/ZH/KO)
67
+ # and misled progress judgement. Report a language-aware unit count plus a raw
68
+ # non-whitespace char count so callers never size output from a bogus metric.
69
+ 'word_count' => total_units,
70
+ 'char_count' => total_chars,
71
+ 'chunks' => chunks_written
67
72
  }
68
- rescue JSON::ParserError => e
69
- { 'error' => "Failed to parse LLM response: #{e.message}" }
70
73
  end
71
74
 
72
75
  private
@@ -74,20 +77,139 @@ module KairosMcp
74
77
  def system_prompt
75
78
  "You are a professional document writer. Write the requested section " \
76
79
  "following the instructions precisely. Use the provided context for accuracy. " \
77
- "Output ONLY the section content. You may use markdown formatting within the section."
80
+ "Output ONLY the section content. You may use markdown formatting within the section. " \
81
+ "When the instructions ask you to revise, translate, or rewrite the reference " \
82
+ "context, reproduce EVERY paragraph of that context — never summarise, merge, " \
83
+ "shorten, or omit a paragraph, and do not truncate. Preserve heading, list, and " \
84
+ "block-quote structure exactly."
78
85
  end
79
86
 
80
87
  def build_user_prompt(section_name, instructions, context_text, max_words, language)
81
88
  parts = [
82
89
  "## Section: #{section_name}",
83
90
  "## Instructions\n#{instructions}",
84
- "## Word limit: approximately #{max_words} words",
91
+ # `max_words` is a soft target only; the hard cap is enforced via max_tokens.
92
+ # Fidelity to the reference context takes precedence over any word target.
93
+ "## Length: aim for about #{max_words} words, but completeness wins — " \
94
+ "reproduce all reference paragraphs even if that exceeds the target.",
85
95
  "## Language: #{language}"
86
96
  ]
87
97
  parts << "## Reference Context\n#{context_text}" if context_text && !context_text.empty?
88
98
  parts << "\nWrite the section now."
89
99
  parts.join("\n\n")
90
100
  end
101
+
102
+ # Derive a hard output token budget so faithful, full-length rewrites are not
103
+ # truncated. Sized from the reference context (rewrite output ≈ input length),
104
+ # clamped between a floor and a ceiling; the adapter clamps further to the
105
+ # model's own limit. Overridable via config for non-default models.
106
+ def resolve_max_tokens(max_words, context_text)
107
+ floor = (@config['section_max_tokens_floor'] || 4096).to_i
108
+ ceiling = (@config['section_max_tokens_ceiling'] || 8192).to_i
109
+ # ~1 token/char is a safe-high estimate for CJK; +20% margin for markup/expansion.
110
+ est_context = context_text ? (context_text.length * 1.2).ceil : 0
111
+ est_words = max_words.to_i * 3 # generous; CJK "words" undercount severely
112
+ [[est_context, est_words, floor].max, ceiling].min
113
+ end
114
+
115
+ # Language-aware unit count. Space-less scripts count non-whitespace characters;
116
+ # others fall back to whitespace-delimited words.
117
+ def count_units(text, language)
118
+ if %w[ja zh ko].include?(language.to_s)
119
+ text.gsub(/\s+/, '').length
120
+ else
121
+ text.split.size
122
+ end
123
+ end
124
+
125
+ # Single LLM pass over one chunk. Returns { 'content' => text } or { 'error' => msg }.
126
+ def generate_one(section_name, instructions, context_text, max_words, language, invocation_context)
127
+ messages = [{
128
+ 'role' => 'user',
129
+ 'content' => build_user_prompt(section_name, instructions, context_text, max_words, language)
130
+ }]
131
+ llm_args = {
132
+ 'messages' => messages,
133
+ 'system' => system_prompt,
134
+ 'max_tokens' => resolve_max_tokens(max_words, context_text)
135
+ }
136
+ # Forward InvocationContext via dispatch-level context: keyword only.
137
+ result = @caller.invoke_tool('llm_call', llm_args, context: invocation_context)
138
+
139
+ raw = result.map { |b| b[:text] || b['text'] }.compact.join
140
+ parsed = JSON.parse(raw)
141
+ if parsed['status'] == 'error'
142
+ error = parsed['error'] || {}
143
+ error_msg = error.is_a?(Hash) ? "#{error['type']}: #{error['message']}" : error.to_s
144
+ return { 'error' => "LLM call failed: #{error_msg}" }
145
+ end
146
+
147
+ generated_text = parsed.dig('response', 'content')
148
+ if generated_text.nil? || generated_text.strip.empty?
149
+ return { 'error' => 'LLM returned empty content' }
150
+ end
151
+ { 'content' => generated_text }
152
+ rescue JSON::ParserError => e
153
+ { 'error' => "Failed to parse LLM response: #{e.message}" }
154
+ end
155
+
156
+ # Group the reference context into chunks no larger than `target` characters,
157
+ # splitting ONLY at blank-line paragraph boundaries (never mid-paragraph). A short
158
+ # or empty context returns a single element so behaviour is unchanged. A single
159
+ # paragraph larger than `target` stays whole (correctness over chunk size).
160
+ def split_context(text, target)
161
+ return [text] if text.nil? || text.strip.empty? || text.length <= target
162
+
163
+ chunks = []
164
+ current = +''
165
+ text.split(/\n{2,}/).each do |para|
166
+ if !current.empty? && (current.length + para.length + 2) > target
167
+ chunks << current
168
+ current = +''
169
+ end
170
+ current << "\n\n" unless current.empty?
171
+ current << para
172
+ end
173
+ chunks << current unless current.empty?
174
+ chunks
175
+ end
176
+
177
+ # Auto-chunk threshold. Sized so each pass is small enough that output-uncapped
178
+ # providers (e.g. claude_code CLI) emit a full section without stopping early —
179
+ # empirically a larger single pass truncates, while ~3000-char passes complete.
180
+ # On such providers the first chunk may "finish the section", so the output can
181
+ # carry a trailing duplicate fragment; completeness is favoured over that cosmetic
182
+ # cost. API providers (with max_tokens honoured) produce a clean single section.
183
+ # Overridable via config.
184
+ def chunk_target
185
+ (@config['section_chunk_target_chars'] || 3000).to_i
186
+ end
187
+
188
+ # Fraction of the source (by non-whitespace chars) at which the assembled output is
189
+ # considered to already cover the whole section, so remaining chunks are skipped.
190
+ def complete_fraction
191
+ (@config['section_complete_fraction'] || 0.85).to_f
192
+ end
193
+
194
+ # Appended to a continuation chunk's instructions so the model does not repeat the
195
+ # section heading and continues the body under the same directives.
196
+ def continuation_note(language)
197
+ if %w[ja zh ko].include?(language.to_s)
198
+ 'これは直前のチャンクの続きである。見出し(## …)を繰り返さず、' \
199
+ '本文の続きの段落だけを、同じ方針で自然化して書け。'
200
+ else
201
+ 'This is a continuation of the previous chunk. Do NOT repeat the section ' \
202
+ 'heading; continue with the next body paragraphs under the same instructions.'
203
+ end
204
+ end
205
+
206
+ def write_chunk(output_file, text, append)
207
+ if append
208
+ File.open(output_file, 'a') { |f| f.write("\n\n#{text}") }
209
+ else
210
+ File.write(output_file, text)
211
+ end
212
+ end
91
213
  end
92
214
  end
93
215
  end
@@ -392,6 +392,129 @@ 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("T18h: stops chunking once output already covers the whole source (no duplicate tail)") do
472
+ caller = MockCallerTool.new
473
+ calls = 0
474
+ caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
475
+ calls += 1
476
+ # First pass "finishes the section" (output >= source); a later pass would be a dup.
477
+ body = calls == 1 ? ('な' * 500) : 'DUPLICATE'
478
+ [{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => body }, 'snapshot' => {} }) }]
479
+ end
480
+ writer = SW.new(caller, { 'section_chunk_target_chars' => 100 })
481
+ ctx = (1..5).map { ('あ' * 80) }.join("\n\n") # ~400 non-ws source chars, would be 5 chunks
482
+ out = File.join(TMPDIR, 'earlystop.md')
483
+ r = writer.write(section_name: 's', instructions: 'i', context_text: ctx,
484
+ output_file: out, max_words: 100, language: 'ja', append_mode: false)
485
+ # chunk1 output (500) >= 0.85 * 400 = 340 → stop after the first pass
486
+ r['chunks'] == 1 && calls == 1 && !File.read(out).include?('DUPLICATE')
487
+ end
488
+
489
+ assert("T18i: faithful per-chunk provider still runs all chunks (no premature stop)") do
490
+ caller = MockCallerTool.new
491
+ calls = 0
492
+ caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
493
+ calls += 1
494
+ [{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => "p#{calls}" }, 'snapshot' => {} }) }]
495
+ end
496
+ writer = SW.new(caller, { 'section_chunk_target_chars' => 100 })
497
+ ctx = (1..4).map { ('あ' * 80) }.join("\n\n")
498
+ out = File.join(TMPDIR, 'faithful.md')
499
+ r = writer.write(section_name: 's', instructions: 'i', context_text: ctx,
500
+ output_file: out, max_words: 100, language: 'ja', append_mode: false)
501
+ # short per-chunk output never reaches the completeness threshold → all chunks run
502
+ r['chunks'] > 1 && calls == r['chunks'] && File.read(out).include?("p#{calls}")
503
+ end
504
+
505
+ assert("T18d: max_tokens respects config ceiling override") do
506
+ caller = MockCallerTool.new
507
+ captured = {}
508
+ caller.define_singleton_method(:invoke_tool) do |tool_name, arguments = {}, context: nil|
509
+ captured[tool_name] = arguments
510
+ [{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'x' }, 'snapshot' => {} }) }]
511
+ end
512
+ writer = SW.new(caller, { 'section_max_tokens_ceiling' => 5000 })
513
+ writer.write(section_name: 's', instructions: 'rewrite', context_text: 'あ' * 9000,
514
+ output_file: File.join(TMPDIR, 'mt2.md'), max_words: 500, language: 'ja')
515
+ captured['llm_call']['max_tokens'] == 5000
516
+ end
517
+
395
518
  assert("T19: handles empty LLM content") do
396
519
  caller = MockCallerTool.new
397
520
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama