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 +4 -4
- data/CHANGELOG.md +50 -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 +163 -41
- data/templates/skillsets/document_authoring/test/test_document_authoring.rb +123 -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: 7d6b5abadc2727f37c441c8d0442da6470438537ad47f387e6c4021769c678dd
|
|
4
|
+
data.tar.gz: f5c625947d73fb41251829cf638b397308294daa4fed7ed94cae945ad6086b72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
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,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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
51
|
+
gen = generate_one(section_name, chunk_instructions, chunk, max_words, language, invocation_context)
|
|
52
|
+
return gen if gen['error']
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'] ||
|
|
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)
|