chocomint 1.0.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.
Files changed (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. metadata +318 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "timeout"
5
+ require "json_schemer"
6
+ require_relative "errors"
7
+
8
+ module Chocomint
9
+ # ツール実行を完全制御するオーケストレータ (DESIGN §5, §6, §9)。
10
+ # LLM は提案者と監査者に限定し、実行・判定・再試行・記録は Ruby が担う (DESIGN §20)。
11
+ class Orchestrator
12
+ Result = Struct.new(:status, :trace_id, :attempts, :result, :proposal, keyword_init: true)
13
+
14
+ def initialize(primary:, registry:, machine_validator:, semantic_validator:, logger:,
15
+ max_retry: 10, timeout_sec: 30)
16
+ @primary = primary
17
+ @registry = registry
18
+ @machine_validator = machine_validator
19
+ @semantic_validator = semantic_validator
20
+ @logger = logger
21
+ @max_retry = max_retry
22
+ @timeout_sec = timeout_sec
23
+ end
24
+
25
+ StepResult = Struct.new(:machine_ok, :result, :error, keyword_init: true)
26
+
27
+ # マルチステップ (Planner) 用: 確定済みの 1 proposal を実行し、機械検証する。
28
+ # LLM への再提案は行わず、実行 + machine 検証の結果だけを返す
29
+ # (提案・全体達成判定・ループ制御は Planner の責務)。
30
+ # 提案の形状 (未知ツール / スキーマ不一致) は事前に検証し、不正なら例外を送出する。
31
+ # 戻り値: StepResult(machine_ok, result, error)。
32
+ def run_step(proposal)
33
+ validate_proposal_shape!(proposal)
34
+ result, machine_ok, error = execute_and_machine_validate(proposal)
35
+ result ||= { exit_code: 1, stdout: "", stderr: error.to_s, duration_ms: 0 }
36
+ # machine 検証は通ったが error が無い正常応答で exit_code!=0 のケースを補う。
37
+ error ||= result[:stderr].to_s.empty? ? nil : result[:stderr] unless machine_ok
38
+ StepResult.new(machine_ok: machine_ok, result: result, error: error)
39
+ end
40
+
41
+ # 成功時は Result(status: "PASS")。回復不能時は例外を送出する (DESIGN §14)。
42
+ def run(request, expectations: nil)
43
+ trace_id = SecureRandom.uuid
44
+
45
+ # Step 1: Tool Proposal — JSON invalid / unknown tool は FAIL。
46
+ # LLM 通信エラー (タイムアウト等) は一時的な障害として RETRY に乗せる。
47
+ proposal, propose_error = propose(request, trace_id)
48
+
49
+ attempt = 0
50
+ # 初回から前回までの全試行 (提案 + 失敗原因) を蓄積し、再提案時にまとめて渡す。
51
+ history = []
52
+ loop do
53
+ attempt += 1
54
+ # backoff は上限を設けて過度な待機を防ぐ (attempt 数増加に対応 / DESIGN §6)。
55
+ sleep([attempt - 1, 5].min) if attempt > 1
56
+
57
+ if proposal.nil?
58
+ # propose/repropose が LLM 通信エラーで失敗した回。ツール実行はスキップし RETRY へ。
59
+ result = { exit_code: 1, stdout: "", stderr: propose_error.to_s, duration_ms: 0 }
60
+ status = "RETRY"
61
+ verifier_output = nil
62
+ error = propose_error
63
+ else
64
+ result, machine_ok, error = execute_and_machine_validate(proposal)
65
+
66
+ if machine_ok
67
+ begin
68
+ pass, verifier_output = @semantic_validator.validate(
69
+ request: request, result: result, expectations: expectations,
70
+ trace_id: trace_id, proposal: proposal
71
+ )
72
+ rescue Chocomint::Error => e
73
+ # verifier の LLM 通信エラーも一時的な障害として RETRY 扱いにする。
74
+ pass = false
75
+ verifier_output = nil
76
+ error = "verifier request failed: #{e.message}"
77
+ end
78
+
79
+ if pass
80
+ @logger.record(trace_id: trace_id, attempt: attempt, request: request,
81
+ proposal: proposal, result: result, status: "PASS",
82
+ verifier_output: verifier_output)
83
+ return Result.new(status: "PASS", trace_id: trace_id, attempts: attempt,
84
+ result: result, proposal: proposal)
85
+ end
86
+
87
+ status = "RETRY" # verifier=0 or invalid
88
+ error ||= "semantic validation failed: #{verifier_output}"
89
+ else
90
+ verifier_output = nil
91
+ status = "RETRY"
92
+ result ||= { exit_code: 1, stdout: "", stderr: error.to_s, duration_ms: 0 }
93
+ # tool 例外時は error が入るが、ツールが正常応答で exit_code!=0 を
94
+ # 返しただけの場合は error が nil のまま残るため、result のエラー内容で補う
95
+ # (repropose の feedback に失敗理由を確実に載せるため)。
96
+ error ||= result[:stderr]&.empty? == false ? result[:stderr] : "machine validation failed"
97
+ end
98
+ end
99
+
100
+ @logger.record(trace_id: trace_id, attempt: attempt, request: request,
101
+ proposal: proposal || { "error" => error.to_s }, result: result,
102
+ status: status, verifier_output: verifier_output)
103
+
104
+ # この試行の提案と失敗原因を履歴に残す (次の再提案の材料にするため nil でも保持する)。
105
+ history << { attempt: attempt, proposal: proposal, error: error }
106
+
107
+ if attempt < @max_retry
108
+ # 初回〜前回までの全試行を渡し、同じ失敗を繰り返さないよう再提案する (DESIGN §6)。
109
+ proposal, propose_error = repropose(request, history, trace_id)
110
+ next
111
+ end
112
+
113
+ @logger.record(trace_id: trace_id, attempt: attempt, request: request,
114
+ proposal: proposal || { "error" => error.to_s }, result: result,
115
+ status: "FAIL", verifier_output: verifier_output)
116
+ raise RetryLimitExceededError, "retry limit exceeded (#{@max_retry})"
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # 戻り値: [proposal_or_nil, error_or_nil]。LLM 通信エラーは一時的な障害として
123
+ # nil 提案 + エラーメッセージを返し、呼び出し元で RETRY に乗せる。
124
+ def propose(request, trace_id)
125
+ proposal = @primary.propose(request, trace_id: trace_id)
126
+ validate_proposal_shape!(proposal)
127
+ [proposal, nil]
128
+ rescue InvalidProposalError, UnknownToolError => e
129
+ @logger.record(trace_id: trace_id, attempt: 0, request: request,
130
+ proposal: { "error" => e.message }, result: {}, status: "FAIL")
131
+ raise
132
+ rescue Chocomint::Error => e
133
+ [nil, e.message]
134
+ end
135
+
136
+ # 初回から前回までの全試行履歴を渡して別の引数の提案を得る。
137
+ # 再提案自体が失敗したら直前の提案を維持して再試行する (堅牢性優先)。
138
+ # LLM 通信エラーのみ nil 提案を返し、次の attempt で改めて再提案を試みる。
139
+ def repropose(request, history, trace_id)
140
+ proposal = @primary.propose(request, feedback: history, trace_id: trace_id)
141
+ validate_proposal_shape!(proposal)
142
+ [proposal, nil]
143
+ rescue InvalidProposalError, UnknownToolError
144
+ [history.last[:proposal], nil]
145
+ rescue Chocomint::Error => e
146
+ [nil, e.message]
147
+ end
148
+
149
+ def validate_proposal_shape!(proposal)
150
+ tool_name = proposal["tool"]
151
+ args = proposal["arguments"]
152
+ raise InvalidProposalError, "arguments must be an object" unless args.is_a?(Hash)
153
+
154
+ tool = @registry.fetch(tool_name) # 未知なら UnknownToolError
155
+ schemer = JSONSchemer.schema(tool.schema)
156
+ return if schemer.valid?(args)
157
+
158
+ raise InvalidProposalError,
159
+ "arguments do not match schema for #{tool_name}: " \
160
+ "#{schemer.validate(args).map { |e| e['error'] }.join('; ')}"
161
+ end
162
+
163
+ # 戻り値: [result_or_nil, machine_ok(bool), error_or_nil]
164
+ # tool 例外 / timeout は RETRY 扱い (DESIGN §14)。
165
+ def execute_and_machine_validate(proposal)
166
+ tool = @registry.fetch(proposal["tool"])
167
+ result = Timeout.timeout(@timeout_sec) { tool.call(proposal["arguments"]) }
168
+ [result, @machine_validator.valid?(proposal, result), nil]
169
+ rescue Timeout::Error
170
+ [nil, false, "timeout after #{@timeout_sec}s"]
171
+ rescue StandardError => e
172
+ [nil, false, e.message]
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "errors"
5
+
6
+ module Chocomint
7
+ # マルチステップの逐次実行オーケストレータ。
8
+ #
9
+ # 単一ツールで完結する要求だけでなく、「uv があるか確認 → python インストール →
10
+ # 依存追加 → 仮想環境作成」のように複数のツールを順に実行して達成する要求に対応する。
11
+ #
12
+ # 各ステップで主 LLM に「次に実行すべき 1 ツール」を提案させ (propose_step)、
13
+ # Orchestrator#run_step で実行・機械検証し、副 LLM (verifier) が
14
+ # 「要求全体が達成されたか」を判定する。達成なら完了、未達成なら次ステップへ進む。
15
+ # LLM は提案者・監査者に限定し、実行・判定・ループ制御・記録は Ruby が担う (DESIGN §20)。
16
+ class Planner
17
+ # steps: 実行した各ステップ [{ tool:, arguments:, result:, status: }, ...]
18
+ # message: モデルがユーザーへ伝える最終メッセージ (finish 時のみ / 任意)。
19
+ # incomplete: verifier 未達成のまま成果を返した (要求を満たしきれていない可能性がある) か。
20
+ # true のとき UI は message を「注記」として控えめに扱う (通常の要約とは区別する)。
21
+ Result = Struct.new(:status, :trace_id, :steps, :attempts, :message, :incomplete,
22
+ keyword_init: true)
23
+
24
+ # max_steps: 実行を許すステップ数の上限 (暴走防止)。max_retry を流用する。
25
+ # step_retry: 1 ステップが machine 検証で失敗したとき、引数を変えて再提案する回数。
26
+ def initialize(primary:, orchestrator:, verifier:, logger:,
27
+ max_steps: 10, step_retry: 3)
28
+ @primary = primary
29
+ @orchestrator = orchestrator
30
+ @verifier = verifier
31
+ @logger = logger
32
+ @max_steps = max_steps
33
+ @step_retry = step_retry
34
+ end
35
+
36
+ # 要求を達成するまでツールを 1 つずつ実行する。
37
+ # 成功時は Result(status: "PASS")。max_steps 到達で達成できなければ
38
+ # RetryLimitExceededError を送出する (それまでの成功ステップは監査ログに残る)。
39
+ #
40
+ # on_event: 進捗を逐次通知するコールバック (任意)。UI のリアルタイム表示に使う。
41
+ # 呼ばれるイベント (いずれも Hash):
42
+ # { type: "step_start", step: n, tool:, label:, target: } … ツール実行の直前
43
+ # { type: "step_done", step: n, tool:, label:, target:, status:, exit_code:, output: } … 実行後
44
+ # コールバック内の例外は握りつぶす (通知失敗で実行本体を止めないため)。
45
+ def run(request, expectations: nil, on_event: nil)
46
+ trace_id = SecureRandom.uuid
47
+ done_steps = []
48
+ total_attempts = 0
49
+
50
+ @max_steps.times do |i|
51
+ step_no = i + 1
52
+ proposal, error = propose_and_execute(request, done_steps, trace_id, step_no, on_event)
53
+ total_attempts += 1
54
+
55
+ if proposal.nil?
56
+ # ステップ内リトライを使い切っても実行できなかった。ここで打ち切る。
57
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
58
+ proposal: { "error" => error.to_s }, result: {}, status: "FAIL")
59
+ raise RetryLimitExceededError,
60
+ "step #{step_no} failed after #{@step_retry} retries: #{error}"
61
+ end
62
+
63
+ tool = proposal["tool"]
64
+ result = proposal.delete(:__result)
65
+
66
+ # finish 提案はループ終了の合図 (実行はしない)。
67
+ if tool == LLM::PrimaryClient::FINISH_TOOL
68
+ return finish(request, done_steps, trace_id, step_no, total_attempts,
69
+ expectations, message: finish_message(proposal))
70
+ end
71
+
72
+ step = { tool: tool, arguments: proposal["arguments"], result: result, status: "PASS" }
73
+ done_steps << step
74
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
75
+ proposal: proposal, result: result, status: "STEP")
76
+
77
+ # 全体達成判定。達成なら完了。未達成なら次ステップへ。
78
+ pass, verifier_output = verify_overall(request, done_steps, expectations, trace_id)
79
+ next unless pass
80
+
81
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
82
+ proposal: proposal, result: result, status: "PASS",
83
+ verifier_output: verifier_output)
84
+ return Result.new(status: "PASS", trace_id: trace_id, steps: done_steps,
85
+ attempts: total_attempts)
86
+ end
87
+
88
+ # max_steps 到達。要求を達成しきれなかった。
89
+ @logger.record(trace_id: trace_id, attempt: @max_steps, request: request,
90
+ proposal: { "error" => "max steps reached" }, result: {}, status: "FAIL")
91
+ raise RetryLimitExceededError, "step limit exceeded (#{@max_steps})"
92
+ end
93
+
94
+ private
95
+
96
+ # 次ステップを提案させ、machine 検証を通るまで最大 step_retry 回まで引数を変えて再提案する。
97
+ # 成功時は proposal に実行結果を :__result で埋めて返す。
98
+ # 戻り値: [proposal_or_nil, error_or_nil]。
99
+ def propose_and_execute(request, done_steps, trace_id, step_no, on_event = nil)
100
+ last_error = nil
101
+ @step_retry.times do
102
+ proposal = safe_propose(request, done_steps, trace_id)
103
+ return [nil, "proposal failed: #{@propose_error}"] if proposal.nil?
104
+
105
+ # finish はそのまま返す (実行不要)。
106
+ return [proposal, nil] if proposal["tool"] == LLM::PrimaryClient::FINISH_TOOL
107
+
108
+ # ツール名が確定したので、実行の直前に UI へ「実行中」を通知する。
109
+ emit(on_event, step_start_event(step_no, proposal))
110
+ step = @orchestrator.run_step(proposal)
111
+ if step.machine_ok
112
+ proposal[:__result] = step.result
113
+ emit(on_event, step_done_event(step_no, proposal, step.result, "PASS"))
114
+ return [proposal, nil]
115
+ end
116
+
117
+ last_error = step.error
118
+ emit(on_event, step_done_event(step_no, proposal, step.result, "FAILED"))
119
+ # 失敗ステップも監査ログに残す (RETRY)。次の周回で別の引数を提案させる。
120
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
121
+ proposal: proposal, result: step.result, status: "RETRY")
122
+ done_steps = done_steps + [failed_hint(proposal, step)]
123
+ end
124
+ [nil, last_error]
125
+ rescue InvalidProposalError, UnknownToolError => e
126
+ # 提案の形状が不正。ステップとして回復不能なのでそのまま伝播させる。
127
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
128
+ proposal: { "error" => e.message }, result: {}, status: "FAIL")
129
+ raise
130
+ end
131
+
132
+ # LLM 通信エラーは一時的な障害として nil を返し、呼び出し元でリトライさせる。
133
+ def safe_propose(request, done_steps, trace_id)
134
+ @propose_error = nil
135
+ @primary.propose_step(request, done_steps, trace_id: trace_id)
136
+ rescue InvalidProposalError, UnknownToolError
137
+ raise
138
+ rescue Chocomint::Error => e
139
+ @propose_error = e.message
140
+ nil
141
+ end
142
+
143
+ # 失敗したステップを、次の提案時に「避けるべき試み」として履歴へ添えるためのヒント。
144
+ def failed_hint(proposal, step)
145
+ { tool: proposal["tool"], arguments: proposal["arguments"],
146
+ result: step.result, status: "FAILED", error: step.error }
147
+ end
148
+
149
+ # 進捗コールバックを安全に呼ぶ (通知失敗で実行本体を止めない)。
150
+ def emit(on_event, event)
151
+ return unless on_event
152
+
153
+ on_event.call(event)
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ # UI 表示用の整形 (label/target) は呼び出し側 (handler) の責務なので、
159
+ # ここでは生の tool 名・引数・結果だけをイベントに載せる。
160
+ def step_start_event(step_no, proposal)
161
+ { type: "step_start", step: step_no,
162
+ tool: proposal["tool"], arguments: proposal["arguments"] }
163
+ end
164
+
165
+ def step_done_event(step_no, proposal, result, status)
166
+ { type: "step_done", step: step_no,
167
+ tool: proposal["tool"], arguments: proposal["arguments"],
168
+ result: result, status: status }
169
+ end
170
+
171
+ # verifier 無効時は常に達成扱い (単一ステップは 1 回で完了する)。
172
+ # LLM 通信エラーは未達成 (次ステップ継続) として扱い、ループを止めない。
173
+ def verify_overall(request, done_steps, expectations, trace_id)
174
+ return [true, nil] if @verifier.nil?
175
+
176
+ output = @verifier.verify_overall(
177
+ request: request, done_steps: done_steps,
178
+ expectations: expectations, trace_id: trace_id
179
+ )
180
+ [output == "1", output]
181
+ rescue Chocomint::Error => e
182
+ [false, "verifier request failed: #{e.message}"]
183
+ end
184
+
185
+ # 要求を完全には満たせていない可能性があるときにメッセージへ添える注記。
186
+ INCOMPLETE_NOTE = "要求を完全には満たせていない可能性があります。" \
187
+ "結果を確認し、必要なら続けて指示してください。"
188
+
189
+ # finish 提案を受けた場合の最終判定。verifier が達成を確認できれば PASS。
190
+ #
191
+ # ツールを一度も実行せずに message 付きで finish した場合は、モデルが
192
+ # ツール不要で答えた/情報不足を聞き返したケースとみなし、verifier をかけずに
193
+ # そのメッセージを返して正常終了する (聞き返しを失敗にしないため)。
194
+ #
195
+ # verifier が未達成と判断した場合でも、ステップを 1 つ以上実行済みなら、それまでの
196
+ # 成果を PASS として返す (未完了の注記を添える)。小型 verifier の誤判定で成果ごと
197
+ # 捨てて生エラーを出すより、実行済みの結果を見せて続きを促す方が有用なため。
198
+ # 何も実行していない (done_steps 空) 状態での未達成 finish は、誤った完了宣言として失敗にする。
199
+ def finish(request, done_steps, trace_id, step_no, total_attempts, expectations, message: nil)
200
+ if done_steps.empty? && !message.to_s.empty?
201
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
202
+ proposal: { "tool" => LLM::PrimaryClient::FINISH_TOOL, "message" => message },
203
+ result: {}, status: "PASS")
204
+ return Result.new(status: "PASS", trace_id: trace_id, steps: done_steps,
205
+ attempts: total_attempts, message: message)
206
+ end
207
+
208
+ pass, verifier_output = verify_overall(request, done_steps, expectations, trace_id)
209
+
210
+ # 未達成だがステップは実行済み: 成果を捨てず PASS で返す (incomplete フラグを立て、
211
+ # message は未完了の注記のみにする。実行内容の要約は呼び出し側が別途生成する)。
212
+ if !pass && !done_steps.empty?
213
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
214
+ proposal: { "tool" => LLM::PrimaryClient::FINISH_TOOL }, result: {},
215
+ status: "PASS", verifier_output: verifier_output)
216
+ return Result.new(status: "PASS", trace_id: trace_id, steps: done_steps,
217
+ attempts: total_attempts, message: INCOMPLETE_NOTE, incomplete: true)
218
+ end
219
+
220
+ status = pass ? "PASS" : "FAIL"
221
+ @logger.record(trace_id: trace_id, attempt: step_no, request: request,
222
+ proposal: { "tool" => LLM::PrimaryClient::FINISH_TOOL }, result: {},
223
+ status: status, verifier_output: verifier_output)
224
+ unless pass
225
+ raise RetryLimitExceededError,
226
+ "LLM declared finish but requirement not satisfied: #{verifier_output}"
227
+ end
228
+ Result.new(status: "PASS", trace_id: trace_id, steps: done_steps,
229
+ attempts: total_attempts, message: message)
230
+ end
231
+
232
+ # finish 提案の arguments からユーザー向けメッセージを取り出す (無ければ nil)。
233
+ def finish_message(proposal)
234
+ args = proposal["arguments"]
235
+ return nil unless args.is_a?(Hash)
236
+
237
+ msg = args["message"] || args["reason"]
238
+ msg.to_s.strip.empty? ? nil : msg.to_s.strip
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Chocomint
6
+ module Security
7
+ # 許可ディレクトリ配下のみアクセスを許し、パストラバーサルを防ぐ (DESIGN §13)。
8
+ class PathGuard
9
+ attr_reader :base_dir, :allowed_roots
10
+
11
+ def initialize(allowed_dirs, base_dir: Dir.pwd)
12
+ @base_dir = File.expand_path(base_dir)
13
+ @allowed_roots = Array(allowed_dirs).map do |d|
14
+ File.expand_path(d, @base_dir)
15
+ end
16
+ end
17
+
18
+ # 例外を投げずに許可判定だけ行う。
19
+ def allowed?(path)
20
+ resolve(path)
21
+ true
22
+ rescue PathAccessError
23
+ false
24
+ end
25
+
26
+ # 検証済み絶対パスを返す。許可外なら PathAccessError。
27
+ def resolve(path)
28
+ raise PathAccessError, "path is required" if path.nil? || path.to_s.empty?
29
+
30
+ abs = File.expand_path(path.to_s, @base_dir)
31
+ unless @allowed_roots.any? { |root| under?(root, abs) }
32
+ raise PathAccessError, "access denied: #{path} is outside allowed directories"
33
+ end
34
+
35
+ abs
36
+ end
37
+
38
+ # run_command / shell の引数トークン列を検査する。ファイル系ツールと違い外部
39
+ # プロセスは cwd (=base_dir) 配下で好き放題できてしまうため、引数に現れる
40
+ # 「パスらしいトークン」が allowed_roots 内に収まるかだけ事前に確認する。
41
+ # 過剰検知を避けるため、パス判定はスラッシュ/バックスラッシュを含むか ".." を
42
+ # 含むトークンに限る (venv / pandas / init のような非パス引数は素通しする)。
43
+ # 許可外を指すトークンがあれば PathAccessError。
44
+ def validate_args!(args)
45
+ Array(args).each do |token|
46
+ value = extract_path_value(token.to_s)
47
+ next unless value && path_like?(value)
48
+
49
+ resolve(value) # 許可外なら PathAccessError を送出
50
+ end
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ # "--directory=workspace/x" のような "フラグ=値" は値側だけを取り出す。
57
+ # 先頭が "-" のみのフラグ (値を含まない) は nil を返しパス扱いしない。
58
+ def extract_path_value(token)
59
+ if token.start_with?("-")
60
+ idx = token.index("=")
61
+ idx ? token[(idx + 1)..] : nil
62
+ else
63
+ token
64
+ end
65
+ end
66
+
67
+ # スラッシュ/バックスラッシュを含む、または ".." を含むものだけをパス候補とみなす。
68
+ def path_like?(token)
69
+ return false if token.empty?
70
+
71
+ token.include?("/") || token.include?("\\") ||
72
+ token.split(%r{[/\\]}).include?("..")
73
+ end
74
+
75
+ # abs が root と一致、または root 配下かを判定 (prefix 攻撃を避ける)。
76
+ def under?(root, abs)
77
+ return true if abs == root
78
+
79
+ prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
80
+ abs.start_with?(prefix)
81
+ end
82
+ end
83
+ end
84
+ end