ruby_coded 0.1.1 → 0.2.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/.rubocop_todo.yml +3 -1
- data/CHANGELOG.md +29 -0
- data/README.md +15 -10
- data/lib/ruby_coded/auth/auth_manager.rb +20 -5
- data/lib/ruby_coded/auth/jwt_decoder.rb +29 -0
- data/lib/ruby_coded/auth/providers/openai.rb +19 -5
- data/lib/ruby_coded/chat/app/event_dispatch.rb +23 -3
- data/lib/ruby_coded/chat/app/login_handler.rb +79 -0
- data/lib/ruby_coded/chat/app/oauth_handler.rb +105 -0
- data/lib/ruby_coded/chat/app.rb +65 -6
- data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +93 -0
- data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +104 -0
- data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +136 -0
- data/lib/ruby_coded/chat/codex_bridge/token_manager.rb +45 -0
- data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +51 -0
- data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +128 -0
- data/lib/ruby_coded/chat/codex_bridge.rb +126 -0
- data/lib/ruby_coded/chat/codex_models.rb +41 -0
- data/lib/ruby_coded/chat/command_handler/login_commands.rb +33 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +19 -6
- data/lib/ruby_coded/chat/command_handler.rb +6 -2
- data/lib/ruby_coded/chat/help.txt +2 -0
- data/lib/ruby_coded/chat/input_handler/login_inputs.rb +66 -0
- data/lib/ruby_coded/chat/input_handler.rb +3 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +1 -1
- data/lib/ruby_coded/chat/renderer/login_flow.rb +105 -0
- data/lib/ruby_coded/chat/renderer/login_flow_layout.rb +65 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +2 -1
- data/lib/ruby_coded/chat/renderer.rb +12 -3
- data/lib/ruby_coded/chat/state/login_flow.rb +117 -0
- data/lib/ruby_coded/chat/state/login_flow_steps.rb +69 -0
- data/lib/ruby_coded/chat/state.rb +25 -2
- data/lib/ruby_coded/initializer.rb +14 -3
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +1 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +4 -3
- data/lib/ruby_coded/version.rb +1 -1
- metadata +33 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CodexBridge
|
|
6
|
+
# Retry logic and error message formatting for the Codex API client.
|
|
7
|
+
module ErrorHandling
|
|
8
|
+
AGENT_SWITCH_PATTERN = /
|
|
9
|
+
\b(implement|go[ ]ahead|proceed|execut|ejecutar?|comenz|
|
|
10
|
+
comienz|hazlo|constru[iy]|adelante|dale|do[ ]it|build[ ]it)\b
|
|
11
|
+
/ix
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def build_connection
|
|
16
|
+
Faraday.new(url: CODEX_BASE_URL) do |f|
|
|
17
|
+
f.options.timeout = 300
|
|
18
|
+
f.options.open_timeout = 30
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset_call_counts
|
|
23
|
+
@tool_call_count = 0
|
|
24
|
+
@write_tool_call_count = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def prepare_send(input)
|
|
28
|
+
auto_switch_to_agent! if should_auto_switch_to_agent?(input)
|
|
29
|
+
reset_call_counts
|
|
30
|
+
@cancel_requested = false
|
|
31
|
+
@state.streaming = true
|
|
32
|
+
@state.add_message(:assistant, "")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def should_auto_switch_to_agent?(input)
|
|
36
|
+
@plan_mode && @state.respond_to?(:current_plan) && @state.current_plan &&
|
|
37
|
+
input.match?(AGENT_SWITCH_PATTERN)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def auto_switch_to_agent!
|
|
41
|
+
toggle_agentic_mode!(true)
|
|
42
|
+
@state.add_message(:system,
|
|
43
|
+
"Plan mode disabled — switching to agent mode to implement the plan.")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def attempt_with_retries(input, retries = 0)
|
|
47
|
+
perform_codex_request(input)
|
|
48
|
+
rescue Tools::AgentCancelledError, Tools::AgentIterationLimitError, Tools::ToolRejectedError => e
|
|
49
|
+
@state.add_message(:system, e.message)
|
|
50
|
+
rescue CodexAPIError => e
|
|
51
|
+
@state.fail_last_assistant(e, friendly_message: codex_error_message(e))
|
|
52
|
+
rescue Faraday::TooManyRequestsError => e
|
|
53
|
+
retry if (retries = handle_rate_limit_retry(e, retries))
|
|
54
|
+
@state.fail_last_assistant(e, friendly_message: rate_limit_message(e))
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
@state.fail_last_assistant(e, friendly_message: "Codex API error: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_rate_limit_retry(error, retries)
|
|
60
|
+
return unless retries < MAX_RATE_LIMIT_RETRIES && !@cancel_requested
|
|
61
|
+
|
|
62
|
+
retries += 1
|
|
63
|
+
delay = RATE_LIMIT_BASE_DELAY * (2**(retries - 1))
|
|
64
|
+
msg = "Rate limit reached. Retrying in #{delay}s... (#{retries}/#{MAX_RATE_LIMIT_RETRIES})"
|
|
65
|
+
@state.fail_last_assistant(error, friendly_message: msg)
|
|
66
|
+
sleep(delay)
|
|
67
|
+
@state.reset_last_assistant_content
|
|
68
|
+
retries
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def codex_error_message(error)
|
|
72
|
+
case error.status
|
|
73
|
+
when 401
|
|
74
|
+
"Authentication failed. Your OAuth session may have expired. " \
|
|
75
|
+
"Try /login to re-authenticate. (#{error.message})"
|
|
76
|
+
when 403
|
|
77
|
+
"Access denied. Your ChatGPT subscription may not include Codex access. (#{error.message})"
|
|
78
|
+
when 404 then "Codex endpoint not found. The API may have changed. (#{error.message})"
|
|
79
|
+
when 429 then rate_limit_message(error)
|
|
80
|
+
else "Codex API error: #{error.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def rate_limit_message(error)
|
|
85
|
+
<<~MSG.strip
|
|
86
|
+
ChatGPT usage limit reached. This may be a 5-hour or weekly limit on your Plus/Pro subscription.
|
|
87
|
+
Wait and try again later. Detail: #{error.message}
|
|
88
|
+
MSG
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CodexBridge
|
|
6
|
+
# Builds HTTP requests for the Codex Responses API.
|
|
7
|
+
module RequestBuilder
|
|
8
|
+
CODEX_HEADERS_BASE = {
|
|
9
|
+
"Content-Type" => "application/json",
|
|
10
|
+
"Accept" => "text/event-stream",
|
|
11
|
+
"originator" => "codex_cli_rs",
|
|
12
|
+
"OpenAI-Beta" => "responses=experimental"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_INSTRUCTIONS = "You are a helpful coding assistant. " \
|
|
16
|
+
"Answer concisely and provide code examples when relevant."
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def codex_headers
|
|
21
|
+
credentials = current_credentials
|
|
22
|
+
account_id = Auth::JWTDecoder.extract_account_id(credentials["access_token"])
|
|
23
|
+
|
|
24
|
+
CODEX_HEADERS_BASE.merge(
|
|
25
|
+
"Authorization" => "Bearer #{credentials["access_token"]}",
|
|
26
|
+
"chatgpt-account-id" => account_id.to_s
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_request_body
|
|
31
|
+
body = base_request_body
|
|
32
|
+
body[:tools] = build_tools_spec if @agentic_mode || @plan_mode
|
|
33
|
+
body
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def base_request_body
|
|
37
|
+
{
|
|
38
|
+
model: @model, instructions: build_instructions,
|
|
39
|
+
input: build_input_array, store: false, stream: true,
|
|
40
|
+
reasoning: { effort: "medium", summary: "auto" },
|
|
41
|
+
text: { verbosity: "medium" },
|
|
42
|
+
include: ["reasoning.encrypted_content"]
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_input_array
|
|
47
|
+
@conversation_history.map { |msg| format_history_message(msg) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_history_message(msg)
|
|
51
|
+
case msg[:type]
|
|
52
|
+
when "function_call" then format_function_call(msg)
|
|
53
|
+
when "function_call_output" then format_function_output(msg)
|
|
54
|
+
else { role: msg[:role], content: msg[:content].to_s }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_function_call(msg)
|
|
59
|
+
{
|
|
60
|
+
type: "function_call", name: msg[:name],
|
|
61
|
+
arguments: msg[:arguments].is_a?(String) ? msg[:arguments] : msg[:arguments].to_json,
|
|
62
|
+
call_id: msg[:call_id]
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def format_function_output(msg)
|
|
67
|
+
{ type: "function_call_output", call_id: msg[:call_id], output: msg[:output].to_s }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_instructions
|
|
71
|
+
if @agentic_mode
|
|
72
|
+
Tools::SystemPrompt.build(
|
|
73
|
+
project_root: @project_root, max_write_rounds: MAX_WRITE_TOOL_ROUNDS,
|
|
74
|
+
max_total_rounds: MAX_TOTAL_TOOL_ROUNDS
|
|
75
|
+
)
|
|
76
|
+
elsif @plan_mode
|
|
77
|
+
Tools::PlanSystemPrompt.build(project_root: @project_root)
|
|
78
|
+
else
|
|
79
|
+
DEFAULT_INSTRUCTIONS
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_tools_spec
|
|
84
|
+
tool_instances = if @agentic_mode
|
|
85
|
+
@tool_registry.build_tools
|
|
86
|
+
else
|
|
87
|
+
@tool_registry.build_readonly_tools
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
tool_instances.map { |tool| tool_to_responses_api(tool) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tool_to_responses_api(tool)
|
|
94
|
+
{
|
|
95
|
+
type: "function",
|
|
96
|
+
name: tool.name,
|
|
97
|
+
description: tool.description.to_s,
|
|
98
|
+
parameters: tool.params_schema
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CodexBridge
|
|
6
|
+
# Parses Server-Sent Events from the Codex streaming response and
|
|
7
|
+
# dispatches content deltas, tool calls, and completion signals.
|
|
8
|
+
module SSEParser
|
|
9
|
+
StreamContext = Struct.new(
|
|
10
|
+
:assistant_text, :pending_tool_calls, :buffer, :raw_body, :status, keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def perform_codex_request(input)
|
|
16
|
+
ensure_token_fresh!
|
|
17
|
+
ctx = StreamContext.new(assistant_text: +"", pending_tool_calls: [], buffer: +"", raw_body: +"", status: nil)
|
|
18
|
+
execute_streaming_post(ctx)
|
|
19
|
+
finalize_response(ctx.assistant_text)
|
|
20
|
+
process_pending_tool_calls(ctx.pending_tool_calls, input) if ctx.pending_tool_calls.any?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute_streaming_post(ctx)
|
|
24
|
+
response = post_streaming_request(ctx)
|
|
25
|
+
ctx.status ||= response.status
|
|
26
|
+
handle_http_error(ctx.status, ctx.raw_body) unless (200..299).cover?(ctx.status)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post_streaming_request(ctx)
|
|
30
|
+
@conn.post(CODEX_RESPONSES_PATH) do |req|
|
|
31
|
+
req.headers = codex_headers
|
|
32
|
+
req.body = build_request_body.to_json
|
|
33
|
+
req.options.on_data = proc { |chunk, _size, env| handle_stream_chunk(ctx, chunk, env) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_stream_chunk(ctx, chunk, env)
|
|
38
|
+
ctx.status ||= env&.status
|
|
39
|
+
ctx.raw_body << chunk
|
|
40
|
+
return if @cancel_requested || (ctx.status && !(200..299).cover?(ctx.status))
|
|
41
|
+
|
|
42
|
+
ctx.buffer << chunk
|
|
43
|
+
process_sse_buffer(ctx.buffer, ctx.assistant_text, ctx.pending_tool_calls)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def process_sse_buffer(buffer, assistant_text, pending_tool_calls)
|
|
47
|
+
while (line_end = buffer.index("\n"))
|
|
48
|
+
line = buffer.slice!(0, line_end + 1).strip
|
|
49
|
+
next if line.empty?
|
|
50
|
+
|
|
51
|
+
process_sse_line(line, assistant_text, pending_tool_calls)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process_sse_line(line, assistant_text, pending_tool_calls)
|
|
56
|
+
return unless line.start_with?("data: ")
|
|
57
|
+
|
|
58
|
+
data = line[6..]
|
|
59
|
+
return if data == "[DONE]"
|
|
60
|
+
|
|
61
|
+
event = parse_json(data)
|
|
62
|
+
return unless event
|
|
63
|
+
|
|
64
|
+
dispatch_sse_event(event, assistant_text, pending_tool_calls)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dispatch_sse_event(event, assistant_text, pending_tool_calls)
|
|
68
|
+
case event["type"]
|
|
69
|
+
when "response.output_text.delta" then handle_text_delta(event, assistant_text)
|
|
70
|
+
when "response.function_call_arguments.delta" then handle_function_args_delta(event, pending_tool_calls)
|
|
71
|
+
when "response.function_call_arguments.done" then handle_function_call_done(event, pending_tool_calls)
|
|
72
|
+
when "response.output_item.added" then handle_output_item_added(event, pending_tool_calls)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def handle_text_delta(event, assistant_text)
|
|
77
|
+
delta = event["delta"]
|
|
78
|
+
return unless delta.is_a?(String) && !delta.empty?
|
|
79
|
+
|
|
80
|
+
assistant_text << delta
|
|
81
|
+
@state.streaming_append(delta)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_output_item_added(event, pending_tool_calls)
|
|
85
|
+
item = event["item"]
|
|
86
|
+
return unless item && item["type"] == "function_call"
|
|
87
|
+
|
|
88
|
+
pending_tool_calls << {
|
|
89
|
+
call_id: item["call_id"] || item["id"],
|
|
90
|
+
name: item["name"],
|
|
91
|
+
arguments: +""
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_function_args_delta(event, pending_tool_calls)
|
|
96
|
+
delta = event["delta"]
|
|
97
|
+
return unless delta.is_a?(String)
|
|
98
|
+
|
|
99
|
+
pending_tool_calls.last&.tap { |c| c[:arguments] << delta }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_function_call_done(event, pending_tool_calls)
|
|
103
|
+
call_id = event["call_id"] || event.dig("item", "call_id")
|
|
104
|
+
return unless call_id
|
|
105
|
+
|
|
106
|
+
tc = pending_tool_calls.find { |c| c[:call_id] == call_id }
|
|
107
|
+
tc[:arguments] = event["arguments"] || tc[:arguments] if tc
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def finalize_response(assistant_text)
|
|
111
|
+
@conversation_history << { role: "assistant", content: assistant_text } unless assistant_text.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_http_error(status, body_text)
|
|
115
|
+
detail = extract_error_detail(body_text)
|
|
116
|
+
raise CodexAPIError.new(status, detail)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_error_detail(body_text)
|
|
120
|
+
parsed = JSON.parse(body_text)
|
|
121
|
+
return body_text[0, 300] unless parsed.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
parsed.dig("error", "message") || parsed["detail"] || parsed["message"] || body_text[0, 300]
|
|
124
|
+
rescue StandardError
|
|
125
|
+
body_text[0, 300]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_json(str)
|
|
129
|
+
JSON.parse(str)
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CodexBridge
|
|
6
|
+
# Manages OAuth token refresh for the Codex bridge.
|
|
7
|
+
# Checks token expiration before each request and refreshes
|
|
8
|
+
# via the existing OAuthStrategy/AuthManager infrastructure.
|
|
9
|
+
module TokenManager
|
|
10
|
+
TOKEN_REFRESH_BUFFER = 60
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def current_credentials
|
|
15
|
+
@credentials_store.retrieve(:openai)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ensure_token_fresh!
|
|
19
|
+
credentials = current_credentials
|
|
20
|
+
return unless credentials && credentials["auth_method"] == "oauth"
|
|
21
|
+
return unless token_expired?(credentials)
|
|
22
|
+
|
|
23
|
+
refresh_token!(credentials)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def token_expired?(credentials)
|
|
27
|
+
expires_at = credentials["expires_at"]
|
|
28
|
+
return false unless expires_at
|
|
29
|
+
|
|
30
|
+
Time.parse(expires_at) <= Time.now + TOKEN_REFRESH_BUFFER
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def refresh_token!(credentials)
|
|
34
|
+
provider = Auth::AuthManager::PROVIDERS[:openai]
|
|
35
|
+
strategy = Strategies::OAuthStrategy.new(provider)
|
|
36
|
+
refreshed = strategy.refresh(credentials)
|
|
37
|
+
@credentials_store.store(:openai, refreshed)
|
|
38
|
+
@auth_manager&.configure_ruby_llm!
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CodexBridge
|
|
6
|
+
# Manages interactive approval flow for tool calls in agentic mode.
|
|
7
|
+
module ToolApproval
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def request_approval(_tool_call, display_name, args, risk)
|
|
11
|
+
args_summary = args.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
12
|
+
|
|
13
|
+
if risk == Tools::BaseTool::SAFE_RISK || @state.auto_approve_tools?
|
|
14
|
+
@state.add_message(:tool_call, "[#{display_name}] #{args_summary}")
|
|
15
|
+
else
|
|
16
|
+
risk_label = risk == Tools::BaseTool::DANGEROUS_RISK ? "DANGEROUS" : "WRITE"
|
|
17
|
+
@state.request_tool_confirmation!(display_name, args, risk_label: risk_label)
|
|
18
|
+
decision = poll_tool_decision
|
|
19
|
+
apply_tool_decision(decision, display_name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def poll_tool_decision
|
|
24
|
+
@state.mutex.synchronize do
|
|
25
|
+
loop do
|
|
26
|
+
return :cancelled if @cancel_requested
|
|
27
|
+
|
|
28
|
+
resp = @state.instance_variable_get(:@tool_confirmation_response)
|
|
29
|
+
return resp if %i[approved rejected].include?(resp)
|
|
30
|
+
|
|
31
|
+
@state.tool_cv.wait(@state.mutex, 0.1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply_tool_decision(decision, display_name)
|
|
37
|
+
case decision
|
|
38
|
+
when :cancelled
|
|
39
|
+
@state.clear_tool_confirmation!
|
|
40
|
+
raise Tools::AgentCancelledError, "Operation cancelled by user"
|
|
41
|
+
when :approved
|
|
42
|
+
@state.resolve_tool_confirmation!(:approved)
|
|
43
|
+
when :rejected
|
|
44
|
+
@state.resolve_tool_confirmation!(:rejected)
|
|
45
|
+
raise Tools::ToolRejectedError, "User rejected #{display_name}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_approval"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Chat
|
|
7
|
+
class CodexBridge
|
|
8
|
+
# Handles tool call execution, confirmation, and the multi-turn loop
|
|
9
|
+
# for agentic mode over the Codex Responses API.
|
|
10
|
+
module ToolHandling
|
|
11
|
+
include ToolApproval
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def process_pending_tool_calls(pending_tool_calls, _original_input)
|
|
16
|
+
pending_tool_calls.each do |tool_call|
|
|
17
|
+
break if @cancel_requested
|
|
18
|
+
|
|
19
|
+
execute_tool_call(tool_call)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
return if @cancel_requested
|
|
23
|
+
|
|
24
|
+
@state.add_message(:assistant, "")
|
|
25
|
+
continue_after_tools
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute_tool_call(tool_call)
|
|
29
|
+
name = tool_call[:name]
|
|
30
|
+
args = parse_tool_arguments(tool_call[:arguments])
|
|
31
|
+
display_name = short_tool_name(name)
|
|
32
|
+
risk = @tool_registry.risk_level_for(name)
|
|
33
|
+
|
|
34
|
+
increment_call_counts(risk)
|
|
35
|
+
check_tool_limits!
|
|
36
|
+
warn_approaching_limit
|
|
37
|
+
|
|
38
|
+
request_approval(tool_call, display_name, args, risk)
|
|
39
|
+
result = run_tool(name, args)
|
|
40
|
+
record_tool_result(tool_call, result)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_tool_arguments(args_str)
|
|
44
|
+
return args_str if args_str.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
JSON.parse(args_str)
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run_tool(name, args)
|
|
52
|
+
tool_instances = @agentic_mode ? @tool_registry.build_tools : @tool_registry.build_readonly_tools
|
|
53
|
+
tool = tool_instances.find { |t| tool_name_match?(t, name) }
|
|
54
|
+
|
|
55
|
+
return { error: "Unknown tool: #{name}" } unless tool
|
|
56
|
+
|
|
57
|
+
symbolized = args.transform_keys(&:to_sym)
|
|
58
|
+
tool.execute(**symbolized)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
{ error: e.message }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tool_name_match?(tool, name)
|
|
64
|
+
tool.name == name || tool.name.split("--").last == name.split("--").last
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_tool_result(tool_call, result)
|
|
68
|
+
@state.add_message(:tool_result, truncate_result(result))
|
|
69
|
+
record_tool_call_history(tool_call, result)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def truncate_result(result)
|
|
73
|
+
text = result.to_s
|
|
74
|
+
return text if text.length <= MAX_TOOL_RESULT_CHARS
|
|
75
|
+
|
|
76
|
+
"#{text[0, MAX_TOOL_RESULT_CHARS]}\n... (truncated, #{text.length} total characters)"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def record_tool_call_history(tool_call, result)
|
|
80
|
+
@conversation_history << {
|
|
81
|
+
type: "function_call", call_id: tool_call[:call_id],
|
|
82
|
+
name: tool_call[:name], arguments: tool_call[:arguments]
|
|
83
|
+
}
|
|
84
|
+
@conversation_history << {
|
|
85
|
+
type: "function_call_output", call_id: tool_call[:call_id], output: result.to_s
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def continue_after_tools
|
|
90
|
+
perform_codex_request(nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def increment_call_counts(risk)
|
|
94
|
+
@tool_call_count += 1
|
|
95
|
+
@write_tool_call_count += 1 unless risk == Tools::BaseTool::SAFE_RISK
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_tool_limits!
|
|
99
|
+
if @write_tool_call_count >= MAX_WRITE_TOOL_ROUNDS
|
|
100
|
+
@write_tool_call_count = 0
|
|
101
|
+
@state.add_message(:system,
|
|
102
|
+
"Write tool call budget (#{MAX_WRITE_TOOL_ROUNDS}) reached — auto-resetting counter.")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
return unless @tool_call_count > MAX_TOTAL_TOOL_ROUNDS
|
|
106
|
+
|
|
107
|
+
raise Tools::AgentIterationLimitError,
|
|
108
|
+
"Reached maximum of #{MAX_TOTAL_TOOL_ROUNDS} total tool calls. " \
|
|
109
|
+
"Send a new message to continue, or use /agent on to reset counters."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def warn_approaching_limit
|
|
113
|
+
threshold = (MAX_TOTAL_TOOL_ROUNDS * TOOL_ROUNDS_WARNING_THRESHOLD).to_i
|
|
114
|
+
return unless @tool_call_count == threshold
|
|
115
|
+
|
|
116
|
+
remaining = MAX_TOTAL_TOOL_ROUNDS - threshold
|
|
117
|
+
@state.add_message(:system,
|
|
118
|
+
"Approaching total tool call limit: #{remaining} calls remaining. " \
|
|
119
|
+
"Prioritize completing the most important work.")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def short_tool_name(name)
|
|
123
|
+
name.split("--").last
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "../tools/registry"
|
|
8
|
+
require_relative "../tools/system_prompt"
|
|
9
|
+
require_relative "../tools/plan_system_prompt"
|
|
10
|
+
require_relative "../tools/agent_cancelled_error"
|
|
11
|
+
require_relative "../tools/agent_iteration_limit_error"
|
|
12
|
+
require_relative "../auth/jwt_decoder"
|
|
13
|
+
require_relative "codex_bridge/request_builder"
|
|
14
|
+
require_relative "codex_bridge/sse_parser"
|
|
15
|
+
require_relative "codex_bridge/tool_handling"
|
|
16
|
+
require_relative "codex_bridge/token_manager"
|
|
17
|
+
require_relative "codex_bridge/error_handling"
|
|
18
|
+
|
|
19
|
+
module RubyCoded
|
|
20
|
+
module Chat
|
|
21
|
+
# Raised when the Codex HTTP API returns a non-2xx response.
|
|
22
|
+
class CodexAPIError < StandardError
|
|
23
|
+
attr_reader :status
|
|
24
|
+
|
|
25
|
+
def initialize(status, detail)
|
|
26
|
+
@status = status
|
|
27
|
+
super("HTTP #{status}: #{detail}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# HTTP client for the ChatGPT Codex backend (Responses API).
|
|
32
|
+
# Implements the same public interface as LLMBridge so App can
|
|
33
|
+
# swap between them based on the active auth_method.
|
|
34
|
+
class CodexBridge
|
|
35
|
+
include RequestBuilder
|
|
36
|
+
include SSEParser
|
|
37
|
+
include ToolHandling
|
|
38
|
+
include TokenManager
|
|
39
|
+
include ErrorHandling
|
|
40
|
+
|
|
41
|
+
CODEX_BASE_URL = "https://chatgpt.com"
|
|
42
|
+
CODEX_RESPONSES_PATH = "/backend-api/codex/responses"
|
|
43
|
+
DEFAULT_MODEL = "gpt-5.4"
|
|
44
|
+
|
|
45
|
+
MAX_RATE_LIMIT_RETRIES = 2
|
|
46
|
+
RATE_LIMIT_BASE_DELAY = 2
|
|
47
|
+
MAX_WRITE_TOOL_ROUNDS = 50
|
|
48
|
+
MAX_TOTAL_TOOL_ROUNDS = 200
|
|
49
|
+
TOOL_ROUNDS_WARNING_THRESHOLD = 0.8
|
|
50
|
+
MAX_TOOL_RESULT_CHARS = 10_000
|
|
51
|
+
|
|
52
|
+
attr_reader :agentic_mode, :plan_mode, :project_root
|
|
53
|
+
|
|
54
|
+
def initialize(state, credentials_store:, auth_manager:, project_root: Dir.pwd)
|
|
55
|
+
@state = state
|
|
56
|
+
@credentials_store = credentials_store
|
|
57
|
+
@auth_manager = auth_manager
|
|
58
|
+
@project_root = project_root
|
|
59
|
+
@cancel_requested = @agentic_mode = @plan_mode = false
|
|
60
|
+
@model = state.model
|
|
61
|
+
@conversation_history = []
|
|
62
|
+
@tool_registry = Tools::Registry.new(project_root: @project_root)
|
|
63
|
+
reset_call_counts
|
|
64
|
+
@conn = build_connection
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def send_async(input)
|
|
68
|
+
prepare_send(input)
|
|
69
|
+
@conversation_history << { role: "user", content: input }
|
|
70
|
+
Thread.new do
|
|
71
|
+
attempt_with_retries(input)
|
|
72
|
+
ensure
|
|
73
|
+
@state.streaming = false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cancel!
|
|
78
|
+
@cancel_requested = true
|
|
79
|
+
@state.mutex.synchronize { @state.tool_cv.signal }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_chat!(model_name)
|
|
83
|
+
@model = model_name
|
|
84
|
+
@conversation_history = []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def toggle_agentic_mode!(enabled)
|
|
88
|
+
@agentic_mode = enabled
|
|
89
|
+
@state.agentic_mode = enabled
|
|
90
|
+
if enabled && @plan_mode
|
|
91
|
+
@plan_mode = false
|
|
92
|
+
@state.deactivate_plan_mode!
|
|
93
|
+
end
|
|
94
|
+
@state.disable_auto_approve! unless enabled
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def toggle_plan_mode!(enabled)
|
|
98
|
+
@plan_mode = enabled
|
|
99
|
+
return unless enabled && @agentic_mode
|
|
100
|
+
|
|
101
|
+
@agentic_mode = false
|
|
102
|
+
@state.agentic_mode = false
|
|
103
|
+
@state.disable_auto_approve!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def approve_tool!
|
|
107
|
+
@state.tool_confirmation_response = :approved
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def approve_all_tools!
|
|
111
|
+
@state.enable_auto_approve!
|
|
112
|
+
@state.tool_confirmation_response = :approved
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reject_tool!
|
|
116
|
+
@state.tool_confirmation_response = :rejected
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def reset_agent_session!
|
|
120
|
+
@tool_call_count = 0
|
|
121
|
+
@write_tool_call_count = 0
|
|
122
|
+
@conversation_history = []
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|