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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +224 -0
- data/THIRD_PARTY_LICENSES.md +45 -0
- data/bin/chocomint +37 -0
- data/bin/chocomint-console-worker +35 -0
- data/bin/chocomint-server +43 -0
- data/config/config.yml +37 -0
- data/lib/chocomint/config.rb +74 -0
- data/lib/chocomint/console/conpty.rb +296 -0
- data/lib/chocomint/console/server.rb +164 -0
- data/lib/chocomint/console/worker.rb +88 -0
- data/lib/chocomint/console/worker_process.rb +254 -0
- data/lib/chocomint/edit_git_handlers.rb +397 -0
- data/lib/chocomint/edit_handlers.rb +696 -0
- data/lib/chocomint/edit_html.rb +9 -0
- data/lib/chocomint/errors.rb +18 -0
- data/lib/chocomint/factory.rb +145 -0
- data/lib/chocomint/llm/base_client.rb +113 -0
- data/lib/chocomint/llm/chat_client.rb +206 -0
- data/lib/chocomint/llm/primary_client.rb +320 -0
- data/lib/chocomint/llm/verifier_client.rb +127 -0
- data/lib/chocomint/logger/sqlite_logger.rb +245 -0
- data/lib/chocomint/orchestrator.rb +175 -0
- data/lib/chocomint/planner.rb +241 -0
- data/lib/chocomint/security/path_guard.rb +84 -0
- data/lib/chocomint/server.rb +863 -0
- data/lib/chocomint/tools/append_file.rb +50 -0
- data/lib/chocomint/tools/base.rb +88 -0
- data/lib/chocomint/tools/delete_file.rb +42 -0
- data/lib/chocomint/tools/edit.rb +65 -0
- data/lib/chocomint/tools/fetch_url.rb +156 -0
- data/lib/chocomint/tools/file_info.rb +54 -0
- data/lib/chocomint/tools/glob.rb +58 -0
- data/lib/chocomint/tools/grep.rb +78 -0
- data/lib/chocomint/tools/list_dir.rb +41 -0
- data/lib/chocomint/tools/ls.rb +51 -0
- data/lib/chocomint/tools/make_dir.rb +44 -0
- data/lib/chocomint/tools/read_file.rb +45 -0
- data/lib/chocomint/tools/registry.rb +29 -0
- data/lib/chocomint/tools/run_command.rb +96 -0
- data/lib/chocomint/tools/shell.rb +169 -0
- data/lib/chocomint/tools/write_file.rb +54 -0
- data/lib/chocomint/validator/machine_validator.rb +65 -0
- data/lib/chocomint/validator/semantic_validator.rb +52 -0
- data/lib/chocomint.rb +51 -0
- data/public/edit/edit.css +414 -0
- data/public/edit/edit.js +2137 -0
- data/public/edit/index.html +251 -0
- data/public/vendor/monaco/LICENSE.txt +23 -0
- data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- data/public/vendor/monaco/vs/loader.js +11 -0
- data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- data/public/vendor/xterm/LICENSE +25 -0
- data/public/vendor/xterm/addon-fit.js +2 -0
- data/public/vendor/xterm/xterm.css +218 -0
- data/public/vendor/xterm/xterm.js +2 -0
- 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
|