226 lines
8.3 KiB
Java
226 lines
8.3 KiB
Java
package shine.agent.botcoder.codex;
|
|
|
|
import java.io.BufferedReader;
|
|
import java.io.IOException;
|
|
import java.io.InputStreamReader;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.time.Duration;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.concurrent.atomic.AtomicReference;
|
|
import java.util.concurrent.TimeUnit;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
public class CodexClient {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(CodexClient.class);
|
|
|
|
private final Path codexBin;
|
|
private final Path codexWorkDir;
|
|
private final int timeoutSeconds;
|
|
private final AtomicReference<Process> activeProcess = new AtomicReference<>();
|
|
|
|
public CodexClient(Path codexBin, Path codexWorkDir, int timeoutSeconds) {
|
|
this.codexBin = codexBin;
|
|
this.codexWorkDir = codexWorkDir;
|
|
this.timeoutSeconds = timeoutSeconds;
|
|
}
|
|
|
|
public String executePrompt(String prompt, CodexStatusListener statusListener) throws IOException, InterruptedException {
|
|
Path lastMessageFile = Files.createTempFile("shine-codex-last-message-", ".txt");
|
|
List<String> command = new ArrayList<>();
|
|
command.add(codexBin.toString());
|
|
command.add("exec");
|
|
command.add("--dangerously-bypass-approvals-and-sandbox");
|
|
command.add("--json");
|
|
command.add("-C");
|
|
command.add(codexWorkDir.toString());
|
|
command.add("-o");
|
|
command.add(lastMessageFile.toString());
|
|
command.add(prompt);
|
|
log.info("Запуск codex exec, bin={}, workdir={}", codexBin, codexWorkDir);
|
|
|
|
ProcessBuilder builder = new ProcessBuilder(command);
|
|
builder.redirectErrorStream(true);
|
|
Process process = builder.start();
|
|
activeProcess.set(process);
|
|
if (statusListener != null) {
|
|
statusListener.onStatus("Codex запущен");
|
|
}
|
|
|
|
StringBuilder output = new StringBuilder();
|
|
Thread outputThread = new Thread(() -> readOutput(process, output, statusListener));
|
|
outputThread.setDaemon(true);
|
|
outputThread.start();
|
|
|
|
boolean finished;
|
|
try {
|
|
finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
} catch (InterruptedException interrupted) {
|
|
process.destroyForcibly();
|
|
joinOutputThread(outputThread);
|
|
activeProcess.compareAndSet(process, null);
|
|
Thread.currentThread().interrupt();
|
|
throw interrupted;
|
|
}
|
|
|
|
try {
|
|
if (!finished) {
|
|
process.destroyForcibly();
|
|
joinOutputThread(outputThread);
|
|
log.error("Codex timeout after {}s", timeoutSeconds);
|
|
throw new IOException("Codex timeout after " + timeoutSeconds + "s");
|
|
}
|
|
|
|
joinOutputThread(outputThread);
|
|
int exitCode = process.exitValue();
|
|
String lastMessage = "";
|
|
if (Files.exists(lastMessageFile)) {
|
|
lastMessage = Files.readString(lastMessageFile, StandardCharsets.UTF_8).trim();
|
|
}
|
|
|
|
if (exitCode != 0) {
|
|
log.error("Codex exit code={}, outputTail={}", exitCode, tail(output.toString(), 500));
|
|
throw new IOException("Codex exited with code " + exitCode + ". Output: " + tail(output.toString(), 1800));
|
|
}
|
|
|
|
if (!lastMessage.isBlank()) {
|
|
return lastMessage;
|
|
}
|
|
|
|
String fallback = extractFallbackMessage(output.toString());
|
|
if (fallback.isBlank()) {
|
|
throw new IOException("Codex returned empty response");
|
|
}
|
|
return fallback;
|
|
} finally {
|
|
activeProcess.compareAndSet(process, null);
|
|
try {
|
|
Files.deleteIfExists(lastMessageFile);
|
|
} catch (IOException ignored) {
|
|
}
|
|
}
|
|
}
|
|
|
|
public boolean stopActiveProcess() {
|
|
Process process = activeProcess.getAndSet(null);
|
|
if (process == null) {
|
|
return false;
|
|
}
|
|
process.destroy();
|
|
try {
|
|
if (!process.waitFor(2, TimeUnit.SECONDS)) {
|
|
process.destroyForcibly();
|
|
}
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
process.destroyForcibly();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private void readOutput(Process process, StringBuilder output, CodexStatusListener statusListener) {
|
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
String line;
|
|
while ((line = reader.readLine()) != null) {
|
|
output.append(line).append('\n');
|
|
String status = normalizeStatusLine(line);
|
|
if (status != null && statusListener != null) {
|
|
statusListener.onStatus(status);
|
|
}
|
|
}
|
|
} catch (Exception ignored) {
|
|
}
|
|
}
|
|
|
|
private String normalizeStatusLine(String line) {
|
|
String trimmed = line == null ? "" : line.trim();
|
|
if (trimmed.isEmpty()) {
|
|
return null;
|
|
}
|
|
if (trimmed.contains("\"type\":\"thread.started\"")) {
|
|
return "Codex: инициализировал сессию";
|
|
}
|
|
if (trimmed.contains("\"type\":\"turn.started\"")) {
|
|
return "Codex: начал обработку запроса";
|
|
}
|
|
if (trimmed.contains("\"type\":\"item.completed\"") && trimmed.contains("\"type\":\"agent_message\"")) {
|
|
return "Codex: формирует финальный ответ";
|
|
}
|
|
if (trimmed.contains("\"type\":\"turn.completed\"")) {
|
|
return "Codex: завершил шаг";
|
|
}
|
|
if (trimmed.contains("\"type\":\"error\"")) {
|
|
return "Codex: ошибка выполнения";
|
|
}
|
|
if (trimmed.contains("\"type\":\"reasoning\"")) {
|
|
return "Codex: анализирует задачу";
|
|
}
|
|
if (trimmed.contains("\"type\":\"function_call\"")) {
|
|
return "Codex: вызывает инструмент";
|
|
}
|
|
if (trimmed.contains("\"type\":\"function_call_output\"")) {
|
|
return "Codex: получил результат инструмента";
|
|
}
|
|
if (trimmed.contains("\"type\":\"message\"") && trimmed.contains("\"role\":\"assistant\"")) {
|
|
return "Codex: формирует ответ";
|
|
}
|
|
if (trimmed.startsWith("mcp")) {
|
|
return "Codex: инициализирует MCP";
|
|
}
|
|
if (trimmed.startsWith("tokens used")) {
|
|
return "Codex: завершает обработку";
|
|
}
|
|
if (trimmed.startsWith("ERROR:")) {
|
|
return "Codex: ошибка выполнения";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void joinOutputThread(Thread outputThread) throws InterruptedException {
|
|
try {
|
|
outputThread.join(Duration.ofSeconds(2).toMillis());
|
|
} catch (InterruptedException interrupted) {
|
|
Thread.currentThread().interrupt();
|
|
throw interrupted;
|
|
}
|
|
}
|
|
|
|
private String extractFallbackMessage(String rawOutput) {
|
|
String[] lines = rawOutput.split("\\R");
|
|
for (int i = lines.length - 1; i >= 0; i--) {
|
|
String line = lines[i].trim();
|
|
if (line.isEmpty()) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("tokens used")) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("OpenAI Codex")) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("workdir:") || line.startsWith("model:") || line.startsWith("provider:")) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("approval:") || line.startsWith("sandbox:") || line.startsWith("reasoning")) {
|
|
continue;
|
|
}
|
|
if (line.equals("user") || line.equals("exec") || line.equals("--------")) {
|
|
continue;
|
|
}
|
|
return line;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
private String tail(String value, int maxLen) {
|
|
if (value.length() <= maxLen) {
|
|
return value;
|
|
}
|
|
return value.substring(value.length() - maxLen);
|
|
}
|
|
}
|